mirror of
https://github.com/langgenius/dify.git
synced 2026-09-05 08:48:10 +08:00
Merge remote-tracking branch 'origin/main' into deploy/konwledge
This commit is contained in:
commit
ecd41aed3f
@ -61,7 +61,7 @@ Flag effects that:
|
||||
- Transform props/state for rendering.
|
||||
- Copy one state value into another representing the same concept.
|
||||
- Handle user actions that belong in event handlers.
|
||||
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
|
||||
- Reset local state from props or visibility when derivation, a stable semantic identity, or the intended mounted owner already expresses the lifecycle.
|
||||
- Fetch data that belongs in framework APIs or TanStack Query.
|
||||
|
||||
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
|
||||
@ -71,11 +71,24 @@ If an effect remains, it must synchronize with a named external system: browser
|
||||
Flag:
|
||||
|
||||
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
|
||||
- Per-session state held by a longer-lived visibility coordinator and cleared through an open-state Effect or a generated key when the primitive's mounted-content lifecycle already matches the intended state lifetime.
|
||||
- A DOM field mirrored into competing prop, default, and React state sources when editing does not require those sources to synchronize.
|
||||
- Local state used to fake server data or generated contract fields.
|
||||
- UI state persisted to localStorage when it is live app state.
|
||||
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
|
||||
Review state lifetime before its storage mechanism. For a hidden surface, distinguish the
|
||||
visibility coordinator from mounted content. State private to one mounted session belongs in that
|
||||
content owner; promote it only when the draft must survive that content owner's unmount or another
|
||||
owner coordinates it. A stable semantic identity key may create a new snapshot when the represented
|
||||
identity changes; a generated key is not a routine reset command.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled
|
||||
popups, and feature UI state that has no server source. Submit-only DOM fields may remain
|
||||
uncontrolled; use local controlled state when React must own the current value to drive rendering
|
||||
or coordination. Observing change events or tracking a derived fact such as dirty state does not
|
||||
require mirroring the field value. Do not flag controlled state by itself without a concrete
|
||||
competing-source, stale-state, or ownership defect.
|
||||
|
||||
## Navigation
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ Flag missing coverage when a change alters a reachable contract such as:
|
||||
- User interaction, navigation, form submission, validation, or permissions.
|
||||
- Query or mutation behavior, URL state, persistence, or one-shot signals.
|
||||
- Loading, error, empty, and recovery states that users can encounter.
|
||||
- A hidden surface whose close-and-reopen behavior changes whether in-progress state resets or persists.
|
||||
- Accessibility-critical labels, keyboard flow, focus, disabled state, or overlay behavior.
|
||||
- A regression-prone business rule or bug fix that can be reproduced through a public boundary.
|
||||
|
||||
|
||||
@ -9,10 +9,11 @@ Use this skill to route component architecture decisions to its bundled referenc
|
||||
|
||||
## First Decisions
|
||||
|
||||
| Question | Default | Promote only when |
|
||||
| Question | Default | Choose differently when |
|
||||
| --- | --- | --- |
|
||||
| Where should code live? | In the product workflow, route, or feature owner. | Several verticals need the same stable contract. |
|
||||
| Who owns state and handlers? | The lowest visual owner that consumes them. | A parent coordinates one workflow or consistent snapshot. |
|
||||
| Who owns state and handlers? | The lowest owner that consumes them and whose lifetime matches the state. | Another owner coordinates the value or it must survive the local owner's unmount. |
|
||||
| Should React control a value? | Leave submit-only DOM fields uncontrolled. | The workflow must own the current value to drive rendering or coordination. |
|
||||
| Should state enter Jotai? | Keep component and form state local. | Siblings need one source of truth or scoped workflow persistence. |
|
||||
| Who owns URL state? | Next.js route APIs and `nuqs`. | Atoms require a read-only route-identity bridge. |
|
||||
| Who owns remote state? | TanStack Query at the lowest consumer. | Atom state drives the query or shared derivations consume it. |
|
||||
@ -24,12 +25,12 @@ Use this skill to route component architecture decisions to its bundled referenc
|
||||
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
|
||||
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
|
||||
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable.
|
||||
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
|
||||
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Identify the behavior owner and the public contract being changed.
|
||||
1. Identify the behavior owner, the required state lifetime, and the public contract being changed.
|
||||
2. Read the nearby implementation, tests, and only the routed skill references.
|
||||
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
|
||||
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and `packages/dify-ui/docs/testing.md` for Dify UI.
|
||||
|
||||
@ -23,8 +23,10 @@ Read this document when a change involves application hotkeys, focus, dialogs, m
|
||||
- Follow the [overlay contract] for primitive choice and shared mechanics. The nearest consumer `AGENTS.md` owns application-specific composite reuse policy.
|
||||
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
|
||||
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
|
||||
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
|
||||
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
|
||||
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
|
||||
- Keep overlay open-state ownership separate from content-session ownership. A controlled root does not require controlled fields or root-owned drafts.
|
||||
- Match transient state to the primitive's content mount lifecycle. State below an unmounting content boundary gets a fresh instance after unmount; intentionally kept-mounted content needs an explicit persistence or reset policy.
|
||||
- Keep a controlled overlay root at its coordination owner so the primitive can complete exit transitions, focus restoration, and detached-handle behavior. Do not conditionally remove the root to reset content state, and use keys only for stable semantic identity.
|
||||
- Place query subscriptions and mutation observers at the owner whose lifetime matches when they should run. Mounted-session work may belong inside content; work that must start or stop exactly with `open` needs an explicit open-state condition.
|
||||
- Prefer primitive-owned open state unless another owner must observe or coordinate it. Analytics callbacks and local cleanup alone do not require a controlled root.
|
||||
|
||||
[overlay contract]: ../../../../packages/dify-ui/docs/overlays.md
|
||||
|
||||
@ -12,8 +12,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
|
||||
## Component Ownership
|
||||
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest visual owner that uses them.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot or coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Put state, data access, loading, empty, error, and handlers in the lowest owner that uses them and whose mounted lifetime matches the required persistence.
|
||||
- Keep coordination in a parent only when it needs one consistent snapshot, the value must intentionally survive the local owner's unmount, or the parent coordinates submission, shared selection, batch behavior, navigation, or cross-section loading and errors.
|
||||
- Repeated TanStack Query calls in siblings are acceptable when each sibling independently consumes the data; the cache already deduplicates requests.
|
||||
- Pass stable domain identity across boundaries. Do not pass raw server data together with separately derived flags for the same concept.
|
||||
- One pass-through prop layer is acceptable. Repeated forwarding means ownership should move closer to the consumer or into feature-scoped shared state.
|
||||
@ -23,7 +23,8 @@ Read this document when adding, moving, splitting, or refactoring React componen
|
||||
## Boundaries
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows can form a small vertical surface with an entrypoint, optional feature-local state, and shallow owners matching real visual regions.
|
||||
- The entrypoint owns route integration, provider wiring, close behavior, and mounting. Composition owners handle workflow branches; the closest visual owner handles section branches.
|
||||
- The entrypoint owns route integration, provider wiring, placement, and open-state coordination. A content or session owner keeps state scoped to that mounted surface.
|
||||
- Judge hook lifetime by the component that declares the hook and the primitive's mount contract, not only by where its rendered controls appear in JSX.
|
||||
- Separate hidden dialogs, dropdowns, and popovers into small local owners when their content obscures the parent flow.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they have their own state, reuse, or semantic boundary.
|
||||
- Avoid wrapper components and wrapper DOM that only rename props, pass children through, or hide the real primitive. A wrapper must own behavior, validation, state, accessibility, layout, or library integration.
|
||||
|
||||
@ -7,7 +7,7 @@ Read this document when a change introduces Effects, navigation side effects, me
|
||||
- Keep render pure: do not read or write `ref.current` during render except for predictable null-guarded lazy initialization. Update interaction-owned refs in event handlers, synchronize external-system refs after commit, and use state or derivation for rendered values.
|
||||
- Use Effects only to synchronize with a named external system such as a browser API, subscription, timer, analytics integration, non-React widget, or imperative DOM API.
|
||||
- Do not use Effects to transform render state, handle user actions, copy query data, reset state from props, or fetch data owned by framework APIs or TanStack Query.
|
||||
- Initialize query-backed forms with keyed remounts or surface-entry hydration instead of copying data through Effects.
|
||||
- Initialize query-backed form sessions after their defaults are available instead of copying data through Effects. Use a stable semantic identity key when the represented identity changes; use the intended surface lifecycle for per-session reset.
|
||||
|
||||
## Navigation
|
||||
|
||||
|
||||
@ -9,10 +9,12 @@ Read this document when a change involves Jotai, form drafts, route identity, sh
|
||||
- Keep server and cache state in TanStack Query. Use existing feature stores for complex, high-frequency interaction state such as workflow canvas drag, resize, and runtime panels.
|
||||
- Use feature-owned storage only for low-frequency client preferences, dismissed notices, and UI defaults. Live application state does not belong in local storage.
|
||||
|
||||
## Forms
|
||||
## Forms And Sessions
|
||||
|
||||
- Prefer uncontrolled Dify UI form and field controls when values are only read at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
|
||||
- Promote form values to atoms only when another owner reacts to in-progress values, the draft must survive scoped unmounting, or several workflow steps edit the same draft.
|
||||
- Keep form state in the narrowest owner whose lifetime matches the draft. A draft scoped to one mounted surface belongs to that content or session owner; a draft that must survive its current owner's unmount belongs to an explicit longer-lived feature owner.
|
||||
- Prefer uncontrolled fields when values are only read at submit time. Use local controlled state only when React must own the current value to drive dependent UI or linked fields; track derived facts such as dirty state without mirroring the field value. Controlledness does not decide whether a draft is local or persisted.
|
||||
- For query-backed defaults, establish the form session after the required defaults are available. `defaultValue` initializes the current mount; a stable semantic identity key may create a fresh snapshot when the represented identity changes. Do not use a generated key as a routine reset command.
|
||||
- Promote drafts beyond the session only when another owner reacts to in-progress values, several workflow steps share one draft, or the draft must intentionally survive unmounting. Start with the lowest shared React owner; use feature-scoped atoms only when their coordination or persistence contract is needed.
|
||||
- Keep validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission.
|
||||
|
||||
## Route And URL State
|
||||
|
||||
45
.github/workflows/cli-e2e.yml
vendored
45
.github/workflows/cli-e2e.yml
vendored
@ -112,7 +112,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-framework-output-error:
|
||||
name: 'Suite: framework + output + error-handling'
|
||||
if: ${{ inputs.suite_framework_output_error != 'false' }}
|
||||
if: ${{ inputs.suite_framework_output_error }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@ -129,9 +129,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@ -159,7 +162,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-discovery:
|
||||
name: 'Suite: discovery'
|
||||
if: ${{ inputs.suite_discovery != 'false' }}
|
||||
if: ${{ inputs.suite_discovery }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@ -176,9 +179,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@ -207,7 +213,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-run:
|
||||
name: 'Suite: run / ${{ matrix.name }}'
|
||||
if: ${{ inputs.suite_run != 'false' }}
|
||||
if: ${{ inputs.suite_run }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
@ -239,9 +245,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@ -284,7 +293,7 @@ jobs:
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-auth-safe:
|
||||
name: 'Suite: auth (login / status / whoami)'
|
||||
if: ${{ inputs.suite_auth != 'false' }}
|
||||
if: ${{ inputs.suite_auth }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
@ -301,9 +310,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
@ -333,7 +345,7 @@ jobs:
|
||||
suite-last:
|
||||
name: 'Suite: auth-use + devices + logout + agent (last, serial)'
|
||||
# Runs when auth is selected; also runs after all parallel jobs finish
|
||||
if: ${{ inputs.suite_auth != 'false' || inputs.suite_agent != 'false' }}
|
||||
if: ${{ inputs.suite_auth || inputs.suite_agent }}
|
||||
needs:
|
||||
- provision
|
||||
- suite-framework-output-error
|
||||
@ -357,9 +369,12 @@ jobs:
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
with:
|
||||
bun-version: latest
|
||||
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
|
||||
42
.github/workflows/cli-release.yml
vendored
42
.github/workflows/cli-release.yml
vendored
@ -7,12 +7,22 @@ on:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Build and checksum only — skip asset upload and stale-asset prune
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Build and checksum only — skip asset upload and stale-asset prune
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
release:
|
||||
types: [released]
|
||||
|
||||
@ -39,11 +49,8 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
run: node scripts/release-naming.mjs validate
|
||||
|
||||
- name: Resolve target Dify release
|
||||
id: resolve
|
||||
@ -75,15 +82,6 @@ jobs:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
- name: Reject duplicate difyctl version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then
|
||||
echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
@ -120,6 +118,7 @@ jobs:
|
||||
|
||||
- name: Compile standalone binaries (all targets)
|
||||
run: |
|
||||
CLI_VERSION="$version" \
|
||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
pnpm build:bin
|
||||
@ -128,6 +127,7 @@ jobs:
|
||||
run: scripts/release-write-checksums.sh
|
||||
|
||||
- name: Attach difyctl assets to Dify release
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@ -135,6 +135,7 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
- name: Prune stale difyctl assets
|
||||
if: ${{ !inputs.dry_run }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@ -149,18 +150,3 @@ jobs:
|
||||
--repo "$GITHUB_REPOSITORY" --yes
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Create provenance tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ref="refs/tags/${difyctlTag}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \
|
||||
| awk 'NR==1 {print $2; exit}' || true)"
|
||||
case "$status" in
|
||||
201) echo "::notice::created ${ref}" ;;
|
||||
422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;;
|
||||
*) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;;
|
||||
esac
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Validate release manifest
|
||||
if: matrix.os == 'depot-ubuntu-24.04'
|
||||
run: scripts/release-validate-manifest.sh
|
||||
run: node scripts/release-naming.mjs validate
|
||||
|
||||
- name: CI pipeline (tree, coverage, build)
|
||||
run: pnpm run ci
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -162,7 +162,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@dcb57747bfceeaa1fa72638cae52295d1d853d4a # v1.0.199
|
||||
uses: anthropics/claude-code-action@a874e9ecd7bb36efdad65429c6b35815f5a08f10 # v1.0.210
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@ -207,6 +207,7 @@ source_modules =
|
||||
services.account_avatar_service
|
||||
services.account_change_email_ports
|
||||
services.account_change_email_service
|
||||
services.account_email_registration_service
|
||||
services.account_deletion_service
|
||||
services.account_deletion_feedback_service
|
||||
services.account_education_service
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
@ -6,6 +7,7 @@ from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from sqlalchemy import func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
@ -76,16 +78,27 @@ from services.agent.observability_service import (
|
||||
AgentStatisticsQueryParams,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.app_service import AgentAppPublicationCounts, AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import ComposerSavePayload, RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
|
||||
|
||||
AgentPublicationStatus = Literal["published", "drafts"]
|
||||
|
||||
|
||||
class AgentInviteOptionsQuery(RosterListQuery):
|
||||
app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers")
|
||||
|
||||
|
||||
class AgentAppListQuery(AppListQuery):
|
||||
publication_status: AgentPublicationStatus | None = Field(
|
||||
default=None,
|
||||
description="Filter by published or draft Agent configuration status",
|
||||
)
|
||||
|
||||
|
||||
class AgentIdPath(BaseModel):
|
||||
agent_id: str
|
||||
|
||||
@ -299,7 +312,19 @@ class AgentSimpleResultResponse(BaseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class AgentPublicationCountsResponse(ResponseModel):
|
||||
published: int = Field(
|
||||
ge=0,
|
||||
description="Published Agent Apps in the current list scope, excluding the publication status filter",
|
||||
)
|
||||
drafts: int = Field(
|
||||
ge=0,
|
||||
description="Draft Agent Apps in the current list scope, excluding the publication status filter",
|
||||
)
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
publication_counts: AgentPublicationCountsResponse
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
)
|
||||
@ -314,6 +339,7 @@ register_schema_models(
|
||||
AgentBuildDraftCheckoutPayload,
|
||||
ComposerSavePayload,
|
||||
AgentApiStatusPayload,
|
||||
AgentAppListQuery,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
AgentStatisticsQuery,
|
||||
@ -323,6 +349,7 @@ register_schema_models(
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentPublicationCountsResponse,
|
||||
AgentAppPagination,
|
||||
AgentApiAccessResponse,
|
||||
AgentAppPublishedReferenceResponse,
|
||||
@ -408,7 +435,14 @@ def _serialize_agent_app_detail(
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_id: str, current_user: Account) -> dict:
|
||||
def _serialize_agent_app_pagination(
|
||||
session: Session,
|
||||
app_pagination,
|
||||
*,
|
||||
tenant_id: str,
|
||||
current_user: Account,
|
||||
publication_counts: AgentAppPublicationCounts,
|
||||
) -> dict:
|
||||
"""Serialize Agent App lists with roster-shaped items.
|
||||
|
||||
Each item starts from the shared App list shape, then drops
|
||||
@ -441,8 +475,17 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
|
||||
account_id=current_user.id,
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(
|
||||
app_pagination,
|
||||
from_attributes=True,
|
||||
{
|
||||
"page": app_pagination.page,
|
||||
"limit": app_pagination.per_page,
|
||||
"total": app_pagination.total,
|
||||
"has_more": app_pagination.has_next,
|
||||
"data": app_pagination.items,
|
||||
"publication_counts": {
|
||||
"published": publication_counts.published,
|
||||
"drafts": publication_counts.drafts,
|
||||
},
|
||||
},
|
||||
context={"session": session},
|
||||
).model_dump(mode="json")
|
||||
for item in payload["data"]:
|
||||
@ -562,7 +605,7 @@ def _query_values(name: str, alias_name: str | None = None) -> list[str]:
|
||||
|
||||
@console_ns.route("/agent")
|
||||
class AgentAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.doc(params=query_params_from_model(AgentAppListQuery))
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -572,7 +615,9 @@ class AgentAppListApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@with_session
|
||||
def get(self, session: Session, current_tenant_id: str, current_user: Account):
|
||||
args = query_params_from_request(AppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
|
||||
args = query_params_from_request(AgentAppListQuery, list_fields=APP_LIST_QUERY_ARRAY_FIELDS)
|
||||
agent_is_published = None if args.publication_status is None else args.publication_status == "published"
|
||||
|
||||
params = AppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
@ -583,11 +628,29 @@ class AgentAppListApi(Resource):
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
status="normal",
|
||||
agent_is_published=agent_is_published,
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, session)
|
||||
app_service = AppService()
|
||||
publication_counts = app_service.get_agent_publication_counts(
|
||||
current_user.id,
|
||||
current_tenant_id,
|
||||
params,
|
||||
session,
|
||||
)
|
||||
app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params, session)
|
||||
if app_pagination is None:
|
||||
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
empty = AgentAppPagination(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
total=0,
|
||||
has_more=False,
|
||||
publication_counts=AgentPublicationCountsResponse(
|
||||
published=publication_counts.published,
|
||||
drafts=publication_counts.drafts,
|
||||
),
|
||||
data=[],
|
||||
)
|
||||
return empty.model_dump(mode="json")
|
||||
|
||||
return _serialize_agent_app_pagination(
|
||||
@ -595,6 +658,7 @@ class AgentAppListApi(Resource):
|
||||
app_pagination,
|
||||
tenant_id=current_tenant_id,
|
||||
current_user=current_user,
|
||||
publication_counts=publication_counts,
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
|
||||
@ -623,6 +687,15 @@ class AgentAppListApi(Resource):
|
||||
)
|
||||
|
||||
app = AppService().create_app(current_tenant_id, params, current_user, session=session)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
str(app.id),
|
||||
enterprise_rbac_service.ReplaceMemberBindings(automatic_include_workspace_members=True),
|
||||
)
|
||||
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app_id=app.id)
|
||||
|
||||
return _serialize_agent_app_detail(session, app, current_user=current_user), 201
|
||||
|
||||
|
||||
|
||||
@ -642,13 +642,13 @@ class AppListApi(Resource):
|
||||
)
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
permissions=permissions,
|
||||
@ -675,7 +675,7 @@ class AppListApi(Resource):
|
||||
pagination_model = pagination_model.model_copy(
|
||||
update={
|
||||
"data": [
|
||||
item.model_copy(update={"permission_keys": permission_keys_map.get(str(item.id), [])})
|
||||
item.model_copy(update={"permission_keys": permission_keys_map.get(item.id, [])})
|
||||
for item in pagination_model.data
|
||||
]
|
||||
}
|
||||
@ -712,7 +712,7 @@ class AppListApi(Resource):
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(current_tenant_id, params, current_user, session=session)
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
[str(app.id)],
|
||||
session=session,
|
||||
@ -882,7 +882,7 @@ class AppApi(Resource):
|
||||
app_model.access_mode = app_setting.access_mode
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
app_id=str(app_model.id),
|
||||
session=session,
|
||||
@ -1020,7 +1020,7 @@ class AppCopyApi(Resource):
|
||||
raise NotFound("App not found")
|
||||
|
||||
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
|
||||
str(current_tenant_id),
|
||||
current_tenant_id,
|
||||
current_user.id,
|
||||
[str(app.id)],
|
||||
session=session,
|
||||
@ -1088,7 +1088,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
|
||||
# TODO: Move this configuration and OAuth orchestration into the Creators Platform application service
|
||||
# when that domain is refactored. This controller-level integration is a temporary compatibility bridge.
|
||||
oauth_code = None
|
||||
client_id = str(dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or "")
|
||||
client_id = dify_config.CREATORS_PLATFORM_OAUTH_CLIENT_ID or ""
|
||||
if client_id:
|
||||
authorization = application_services().oauth_server.issue_authorization_code(
|
||||
client_id=client_id,
|
||||
|
||||
@ -16,6 +16,7 @@ from controllers.common.schema import register_response_schema_models, register_
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
|
||||
from controllers.console.app.error import (
|
||||
AgentSessionConfigurationChangedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@ -620,6 +621,10 @@ def _raise_agent_stream_error_before_response(response):
|
||||
if isinstance(response, _ClosableStream):
|
||||
response.close()
|
||||
message = error_payload.get("message")
|
||||
if error_payload.get("code") == AgentSessionConfigurationChangedError.error_code:
|
||||
raise AgentSessionConfigurationChangedError(
|
||||
str(message or AgentSessionConfigurationChangedError.description)
|
||||
)
|
||||
raise CompletionRequestError(str(message or "Agent App chat failed."))
|
||||
|
||||
return _prepend_stream_chunks(buffered, chunk, iterator)
|
||||
|
||||
@ -1,3 +1,7 @@
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE,
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE,
|
||||
)
|
||||
from libs.exception import BaseHTTPException
|
||||
|
||||
|
||||
@ -49,6 +53,12 @@ class CompletionRequestError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class AgentSessionConfigurationChangedError(BaseHTTPException):
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
description = AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE
|
||||
code = 409
|
||||
|
||||
|
||||
class AppMoreLikeThisDisabledError(BaseHTTPException):
|
||||
error_code = "app_more_like_this_disabled"
|
||||
description = "The 'More like this' feature is disabled. Please refresh your page."
|
||||
|
||||
@ -412,6 +412,7 @@ class InstructionGenerateApi(Resource):
|
||||
model_config=req_data.model_config_data,
|
||||
ideal_output=req_data.ideal_output,
|
||||
workflow_service=WorkflowService(),
|
||||
session=session,
|
||||
)
|
||||
return {"error": "incompatible parameters"}, 400
|
||||
except ProviderTokenNotInitError as ex:
|
||||
|
||||
@ -44,6 +44,7 @@ from controllers.console.wraps import (
|
||||
RBACResourceScope,
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
model_validate,
|
||||
rbac_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
@ -352,7 +353,7 @@ class WorkflowResponse(ResponseModel):
|
||||
return [_serialize_environment_variable(item) for item in value]
|
||||
|
||||
|
||||
class _WorkflowResponseSource:
|
||||
class WorkflowResponseSource:
|
||||
def __init__(self, workflow: Workflow, *, session: Session) -> None:
|
||||
self._workflow = workflow
|
||||
self._session = session
|
||||
@ -589,7 +590,8 @@ class DraftWorkflowApi(Resource):
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
workflow_service = WorkflowService()
|
||||
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=db.session())
|
||||
session = db.session()
|
||||
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=session)
|
||||
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
@ -598,9 +600,11 @@ class DraftWorkflowApi(Resource):
|
||||
|
||||
# Return workflow with response-only Agent node job projection so the
|
||||
# front-end can treat draft graph node data as the editing source.
|
||||
response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response = WorkflowResponse.model_validate(
|
||||
WorkflowResponseSource(workflow, session=session), from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=db.session(),
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
)
|
||||
return response
|
||||
@ -705,12 +709,12 @@ class AdvancedChatDraftWorkflowRunApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
@model_validate(AdvancedChatWorkflowRunPayload)
|
||||
def post(self, payload: AdvancedChatWorkflowRunPayload, session: Session, current_user: Account, app_model: App):
|
||||
"""
|
||||
Run draft workflow
|
||||
"""
|
||||
args_model = AdvancedChatWorkflowRunPayload.model_validate(console_ns.payload or {})
|
||||
args = args_model.model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
@ -760,11 +764,12 @@ class AdvancedChatDraftRunIterationNodeApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(IterationNodeRunPayload)
|
||||
def post(self, payload: IterationNodeRunPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow iteration node
|
||||
"""
|
||||
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate_single_iteration(
|
||||
@ -808,11 +813,12 @@ class WorkflowDraftRunIterationNodeApi(Resource):
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(IterationNodeRunPayload)
|
||||
def post(self, payload: IterationNodeRunPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow iteration node
|
||||
"""
|
||||
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate_single_iteration(
|
||||
@ -852,11 +858,11 @@ class AdvancedChatDraftRunLoopNodeApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(LoopNodeRunPayload)
|
||||
def post(self, args: LoopNodeRunPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow loop node
|
||||
"""
|
||||
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate_single_loop(
|
||||
@ -900,11 +906,11 @@ class WorkflowDraftRunLoopNodeApi(Resource):
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(LoopNodeRunPayload)
|
||||
def post(self, args: LoopNodeRunPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow loop node
|
||||
"""
|
||||
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate_single_loop(
|
||||
@ -977,11 +983,11 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource):
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(HumanInputFormPreviewPayload)
|
||||
def post(self, args: HumanInputFormPreviewPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Preview human input form content and placeholders
|
||||
"""
|
||||
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
|
||||
inputs = args.inputs
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
@ -1013,11 +1019,11 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(HumanInputFormSubmitPayload)
|
||||
def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Submit human input form preview
|
||||
"""
|
||||
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
|
||||
workflow_service = WorkflowService()
|
||||
result = workflow_service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
@ -1045,11 +1051,11 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource):
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(HumanInputFormPreviewPayload)
|
||||
def post(self, args: HumanInputFormPreviewPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Preview human input form content and placeholders
|
||||
"""
|
||||
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
|
||||
inputs = args.inputs
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
@ -1081,12 +1087,12 @@ class WorkflowDraftHumanInputFormRunApi(Resource):
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(HumanInputFormSubmitPayload)
|
||||
def post(self, args: HumanInputFormSubmitPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Submit human input form preview
|
||||
"""
|
||||
workflow_service = WorkflowService()
|
||||
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
|
||||
result = workflow_service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@ -1113,12 +1119,12 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource):
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(HumanInputDeliveryTestPayload)
|
||||
def post(self, args: HumanInputDeliveryTestPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Test human input delivery
|
||||
"""
|
||||
workflow_service = WorkflowService()
|
||||
args = HumanInputDeliveryTestPayload.model_validate(console_ns.payload or {})
|
||||
workflow_service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@ -1150,11 +1156,12 @@ class DraftWorkflowRunApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
@model_validate(DraftWorkflowRunPayload)
|
||||
def post(self, payload: DraftWorkflowRunPayload, session: Session, current_user: Account, app_model: App):
|
||||
"""
|
||||
Run draft workflow
|
||||
"""
|
||||
args = DraftWorkflowRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
@ -1223,14 +1230,14 @@ class DraftWorkflowNodeRunApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
@model_validate(DraftWorkflowNodeRunPayload)
|
||||
def post(self, payload: DraftWorkflowNodeRunPayload, current_user: Account, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow node
|
||||
"""
|
||||
args_model = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
args = args_model.model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
user_inputs = args_model.inputs
|
||||
user_inputs = payload.inputs
|
||||
if user_inputs is None:
|
||||
raise ValueError("missing inputs")
|
||||
|
||||
@ -1279,13 +1286,14 @@ class PublishedWorkflowApi(Resource):
|
||||
"""
|
||||
# fetch published workflow by app_model
|
||||
workflow_service = WorkflowService()
|
||||
workflow = workflow_service.get_published_workflow(app_model=app_model, session=db.session())
|
||||
session = db.session()
|
||||
workflow = workflow_service.get_published_workflow(app_model=app_model, session=session)
|
||||
|
||||
# return workflow, if not found, return None
|
||||
if workflow is None:
|
||||
return None
|
||||
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session))
|
||||
|
||||
@console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__])
|
||||
@console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__])
|
||||
@ -1296,13 +1304,12 @@ class PublishedWorkflowApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
@model_validate(PublishWorkflowPayload)
|
||||
def post(self, args: PublishWorkflowPayload, current_user: Account, app_model: App):
|
||||
"""
|
||||
Publish workflow
|
||||
"""
|
||||
|
||||
args = PublishWorkflowPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
@ -1371,11 +1378,11 @@ class DefaultBlockConfigApi(Resource):
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def get(self, app_model: App, block_type: str):
|
||||
@model_validate(DefaultBlockConfigQuery)
|
||||
def get(self, args: DefaultBlockConfigQuery, app_model: App, block_type: str):
|
||||
"""
|
||||
Get default block config
|
||||
"""
|
||||
args = DefaultBlockConfigQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
filters = None
|
||||
if args.q:
|
||||
@ -1410,14 +1417,14 @@ class ConvertToWorkflowApi(Resource):
|
||||
@with_current_tenant_id
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
@model_validate(ConvertToWorkflowPayload)
|
||||
def post(self, payload: ConvertToWorkflowPayload, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
"""
|
||||
Convert basic mode of chatbot app to workflow mode
|
||||
Convert expert mode of chatbot app to workflow mode
|
||||
Convert Completion App to Workflow App
|
||||
"""
|
||||
payload = console_ns.payload or {}
|
||||
args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True)
|
||||
args = payload.model_dump(exclude_none=True)
|
||||
|
||||
# convert to workflow mode
|
||||
workflow_service = WorkflowService()
|
||||
@ -1452,9 +1459,8 @@ class WorkflowFeaturesApi(Resource):
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
|
||||
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
@model_validate(WorkflowFeaturesPayload)
|
||||
def post(self, args: WorkflowFeaturesPayload, current_user: Account, app_model: App):
|
||||
features = args.features.model_dump(mode="json", exclude_unset=True)
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
@ -1483,12 +1489,12 @@ class PublishedAllWorkflowApi(Resource):
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
@model_validate(WorkflowListQuery)
|
||||
def get(self, args: WorkflowListQuery, current_user: Account, app_model: App):
|
||||
"""
|
||||
Get published workflows
|
||||
"""
|
||||
|
||||
args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
page = args.page
|
||||
limit = args.limit
|
||||
user_id = args.user_id
|
||||
@ -1510,7 +1516,7 @@ class PublishedAllWorkflowApi(Resource):
|
||||
)
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": [_WorkflowResponseSource(workflow, session=session) for workflow in workflows],
|
||||
"items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
@ -1573,11 +1579,11 @@ class WorkflowByIdApi(Resource):
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
|
||||
def patch(self, current_user: Account, app_model: App, workflow_id: str):
|
||||
@model_validate(WorkflowUpdatePayload)
|
||||
def patch(self, args: WorkflowUpdatePayload, current_user: Account, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Update workflow attributes
|
||||
"""
|
||||
args = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
# Prepare update data
|
||||
update_data = {}
|
||||
@ -1604,7 +1610,7 @@ class WorkflowByIdApi(Resource):
|
||||
if not workflow:
|
||||
raise NotFound("Workflow not found")
|
||||
|
||||
response = dump_response(WorkflowResponse, _WorkflowResponseSource(workflow, session=session))
|
||||
response = dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session))
|
||||
|
||||
return response
|
||||
|
||||
@ -1710,11 +1716,11 @@ class DraftWorkflowTriggerRunApi(Resource):
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
@model_validate(DraftWorkflowTriggerRunPayload)
|
||||
def post(self, args: DraftWorkflowTriggerRunPayload, session: Session, current_user: Account, app_model: App):
|
||||
"""
|
||||
Poll for trigger events and execute full workflow when event arrives
|
||||
"""
|
||||
args = DraftWorkflowTriggerRunPayload.model_validate(console_ns.payload or {})
|
||||
node_id = args.node_id
|
||||
workflow_service = WorkflowService()
|
||||
draft_workflow = workflow_service.get_draft_workflow(app_model, session=session)
|
||||
@ -1862,12 +1868,12 @@ class DraftWorkflowTriggerRunAllApi(Resource):
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@with_session
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def post(self, session: Session, current_user: Account, app_model: App):
|
||||
@model_validate(DraftWorkflowTriggerRunAllPayload)
|
||||
def post(self, args: DraftWorkflowTriggerRunAllPayload, session: Session, current_user: Account, app_model: App):
|
||||
"""
|
||||
Full workflow debug when the start node is a trigger
|
||||
"""
|
||||
|
||||
args = DraftWorkflowTriggerRunAllPayload.model_validate(console_ns.payload or {})
|
||||
node_ids = args.node_ids
|
||||
workflow_service = WorkflowService()
|
||||
draft_workflow = workflow_service.get_draft_workflow(app_model, session=session)
|
||||
@ -1929,9 +1935,8 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@model_validate(WorkflowOnlineUsersPayload)
|
||||
def post(self, args: WorkflowOnlineUsersPayload, current_tenant_id: str, current_user: Account):
|
||||
app_ids = args.app_ids
|
||||
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS:
|
||||
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.")
|
||||
|
||||
@ -2,8 +2,6 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import get_valid_language, languages
|
||||
from controllers.common.fields import SimpleResultDataResponse, VerificationTokenResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
@ -11,31 +9,35 @@ from controllers.console.auth.error import (
|
||||
EmailAlreadyInUseError,
|
||||
EmailCodeError,
|
||||
EmailRegisterLimitError,
|
||||
EmailRegisterRateLimitExceededError,
|
||||
InvalidEmailError,
|
||||
InvalidTokenError,
|
||||
NormalizedEmailAlreadyInUseError,
|
||||
PasswordMismatchError,
|
||||
)
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_email_registration_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import EmailStr, extract_remote_ip
|
||||
from libs.helper import EmailStr, dump_response, extract_remote_ip
|
||||
from libs.helper import timezone as validate_timezone_string
|
||||
from libs.password import valid_password
|
||||
from models import Account
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.account import (
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
AccountRegisterError,
|
||||
SeatsLimitExceededError,
|
||||
)
|
||||
from services.errors.account import (
|
||||
EmailDomainSuspendedError as EmailDomainSuspendedRegistrationError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
EmailRegistrationSendIPLimitedError,
|
||||
EmailRegistrationSendRateLimitError,
|
||||
EmailRegistrationVerificationLimitError,
|
||||
InvalidEmailRegistrationAddressError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
|
||||
from ..error import AccountInFreezeError, EmailDomainSuspendedError, EmailSendIpLimitError, SeatsLimitExceeded
|
||||
from ..wraps import email_password_login_enabled, email_register_enabled, model_validate, setup_required
|
||||
|
||||
|
||||
class EmailRegisterSendPayload(BaseModel):
|
||||
@ -91,146 +93,91 @@ register_response_schema_models(
|
||||
|
||||
@console_ns.route("/email-register/send-email")
|
||||
class EmailRegisterSendEmailApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterSendPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterSendPayload)
|
||||
def post(self, req_data: EmailRegisterSendPayload):
|
||||
normalized_email = req_data.email.lower()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
if AccountService.is_email_send_ip_limit(ip_address):
|
||||
raise EmailSendIpLimitError()
|
||||
language = "en-US"
|
||||
if req_data.language is not None and req_data.language in languages:
|
||||
language = req_data.language
|
||||
|
||||
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
||||
freeze_type = BillingService.get_email_freeze_type(normalized_email)
|
||||
if freeze_type:
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise EmailDomainSuspendedError()
|
||||
raise AccountInFreezeError()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(req_data.email, session=db.session())
|
||||
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
|
||||
return {"result": "success", "data": token}
|
||||
def post(self, args: EmailRegisterSendPayload):
|
||||
try:
|
||||
token = application_services().accounts.email_registration.send_code(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
requested_email=args.email,
|
||||
requested_language=args.language,
|
||||
)
|
||||
except EmailRegistrationSendIPLimitedError:
|
||||
raise EmailSendIpLimitError() from None
|
||||
except EmailRegistrationSendRateLimitError as error:
|
||||
raise EmailRegisterRateLimitExceededError(error.retry_after_minutes) from None
|
||||
except AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
return dump_response(SimpleResultDataResponse, {"result": "success", "data": token})
|
||||
|
||||
|
||||
@console_ns.route("/email-register/validity")
|
||||
class EmailRegisterCheckApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterValidityPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterValidityPayload)
|
||||
def post(self, req_data: EmailRegisterValidityPayload):
|
||||
|
||||
user_email = req_data.email.lower()
|
||||
|
||||
is_email_register_error_rate_limit = AccountService.is_email_register_error_rate_limit(user_email)
|
||||
if is_email_register_error_rate_limit:
|
||||
raise EmailRegisterLimitError()
|
||||
|
||||
token_data = AccountService.get_email_register_data(req_data.token)
|
||||
if token_data is None:
|
||||
raise InvalidTokenError()
|
||||
|
||||
token_email = token_data.get("email")
|
||||
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
|
||||
|
||||
if user_email != normalized_token_email:
|
||||
raise InvalidEmailError()
|
||||
|
||||
if req_data.code != token_data.get("code"):
|
||||
AccountService.add_email_register_error_rate_limit(user_email)
|
||||
raise EmailCodeError()
|
||||
|
||||
# Verified, revoke the first token
|
||||
AccountService.revoke_email_register_token(req_data.token)
|
||||
|
||||
# Refresh token data by generating a new token
|
||||
_, new_token = AccountService.generate_email_register_token(
|
||||
user_email, code=req_data.code, additional_data={"phase": "register"}
|
||||
def post(self, args: EmailRegisterValidityPayload):
|
||||
try:
|
||||
verification = application_services().accounts.email_registration.verify_code(
|
||||
email=args.email,
|
||||
code=args.code,
|
||||
token=args.token,
|
||||
)
|
||||
except EmailRegistrationVerificationLimitError:
|
||||
raise EmailRegisterLimitError() from None
|
||||
except InvalidEmailRegistrationTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except InvalidEmailRegistrationAddressError:
|
||||
raise InvalidEmailError() from None
|
||||
except InvalidEmailRegistrationCodeError:
|
||||
raise EmailCodeError() from None
|
||||
return dump_response(
|
||||
VerificationTokenResponse,
|
||||
{
|
||||
"is_valid": True,
|
||||
"email": verification.email,
|
||||
"token": verification.token,
|
||||
},
|
||||
)
|
||||
|
||||
AccountService.reset_email_register_error_rate_limit(user_email)
|
||||
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
|
||||
|
||||
|
||||
@console_ns.route("/email-register")
|
||||
class EmailRegisterResetApi(Resource):
|
||||
@setup_required
|
||||
@email_password_login_enabled
|
||||
@email_register_enabled
|
||||
@console_ns.expect(console_ns.models[EmailRegisterResetPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[EmailRegisterResetResponse.__name__])
|
||||
@console_email_registration_admission
|
||||
@model_validate(EmailRegisterResetPayload)
|
||||
def post(self, req_data: EmailRegisterResetPayload):
|
||||
|
||||
# Validate passwords match
|
||||
if req_data.new_password != req_data.password_confirm:
|
||||
raise PasswordMismatchError()
|
||||
|
||||
# Validate token and get register data
|
||||
register_data = AccountService.get_email_register_data(req_data.token)
|
||||
if not register_data:
|
||||
raise InvalidTokenError()
|
||||
# Must use token in reset phase
|
||||
if register_data.get("phase", "") != "register":
|
||||
raise InvalidTokenError()
|
||||
|
||||
# Revoke token to prevent reuse
|
||||
AccountService.revoke_email_register_token(req_data.token)
|
||||
|
||||
email = register_data.get("email", "")
|
||||
normalized_email = email.lower()
|
||||
|
||||
account = AccountService.get_account_by_email_with_case_fallback(email, session=db.session())
|
||||
|
||||
if account:
|
||||
raise EmailAlreadyInUseError()
|
||||
|
||||
ip_address = extract_remote_ip(request)
|
||||
account = self._create_new_account(
|
||||
email=normalized_email,
|
||||
password=req_data.password_confirm,
|
||||
timezone=req_data.timezone,
|
||||
language=req_data.language,
|
||||
ip_address=ip_address,
|
||||
)
|
||||
token_pair = AccountService.login(account=account, session=db.session(), ip_address=ip_address)
|
||||
AccountService.reset_login_error_rate_limit(normalized_email)
|
||||
|
||||
return {"result": "success", "data": token_pair.model_dump()}
|
||||
|
||||
def _create_new_account(
|
||||
self,
|
||||
email: str,
|
||||
password: str,
|
||||
timezone: str | None = None,
|
||||
language: str | None = None,
|
||||
ip_address: str | None = None,
|
||||
) -> Account:
|
||||
def post(self, args: EmailRegisterResetPayload):
|
||||
try:
|
||||
return AccountService.create_account_and_tenant(
|
||||
email=email,
|
||||
name=email,
|
||||
password=password,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=db.session(),
|
||||
token_pair = application_services().accounts.email_registration.register(
|
||||
remote_ip=extract_remote_ip(request),
|
||||
token=args.token,
|
||||
new_password=args.new_password,
|
||||
password_confirm=args.password_confirm,
|
||||
language=args.language,
|
||||
timezone=args.timezone,
|
||||
)
|
||||
except SeatsLimitExceededError:
|
||||
raise SeatsLimitExceeded()
|
||||
except EmailDomainSuspendedRegistrationError as exc:
|
||||
raise EmailDomainSuspendedError() from exc
|
||||
except AccountNormalizedEmailAlreadyInUseError as exc:
|
||||
raise NormalizedEmailAlreadyInUseError() from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountInFreezeError() from exc
|
||||
except EmailRegistrationPasswordMismatchError:
|
||||
raise PasswordMismatchError() from None
|
||||
except InvalidEmailRegistrationTokenError:
|
||||
raise InvalidTokenError() from None
|
||||
except AccountNormalizedEmailAlreadyInUseError:
|
||||
raise NormalizedEmailAlreadyInUseError() from None
|
||||
except AccountEmailAlreadyInUseError:
|
||||
raise EmailAlreadyInUseError() from None
|
||||
except EmailRegistrationSeatsLimitError:
|
||||
raise SeatsLimitExceeded() from None
|
||||
except AccountEmailDomainSuspendedError:
|
||||
raise EmailDomainSuspendedError() from None
|
||||
except AccountEmailFrozenError:
|
||||
raise AccountInFreezeError() from None
|
||||
|
||||
return dump_response(
|
||||
EmailRegisterResetResponse,
|
||||
{"result": "success", "data": token_pair},
|
||||
)
|
||||
|
||||
@ -55,7 +55,7 @@ class PasswordResetRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 1):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
@ -65,7 +65,7 @@ class EmailRegisterRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 1):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
@ -75,7 +75,7 @@ class EmailChangeRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 1):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
@ -85,7 +85,7 @@ class OwnerTransferRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 1):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
@ -137,7 +137,7 @@ class EmailCodeLoginRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 5):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
@ -147,7 +147,7 @@ class EmailCodeAccountDeletionRateLimitExceededError(BaseHTTPException):
|
||||
code = 429
|
||||
|
||||
def __init__(self, minutes: int = 5):
|
||||
description = self.description.format(minutes=int(minutes)) if self.description else None
|
||||
description = self.description.format(minutes=minutes) if self.description else None
|
||||
super().__init__(description=description)
|
||||
|
||||
|
||||
|
||||
@ -26,6 +26,7 @@ from controllers.console.app.workflow import (
|
||||
DefaultBlockConfigsResponse,
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowResponse,
|
||||
WorkflowResponseSource,
|
||||
)
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.datasets.wraps import get_rag_pipeline, load_rag_pipeline
|
||||
@ -202,14 +203,15 @@ class DraftRagPipelineApi(Resource):
|
||||
Get draft rag pipeline's workflow
|
||||
"""
|
||||
# fetch draft workflow by app_model
|
||||
rag_pipeline_service = RagPipelineService(db.session())
|
||||
session = db.session()
|
||||
rag_pipeline_service = RagPipelineService(session)
|
||||
workflow = rag_pipeline_service.get_draft_workflow(pipeline=pipeline)
|
||||
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
# return workflow, if not found, return 404
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session))
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -548,14 +550,15 @@ class PublishedRagPipelineApi(Resource):
|
||||
if not pipeline.is_published:
|
||||
return None
|
||||
# fetch published workflow by pipeline
|
||||
rag_pipeline_service = RagPipelineService(db.session())
|
||||
session = db.session()
|
||||
rag_pipeline_service = RagPipelineService(session)
|
||||
workflow = rag_pipeline_service.get_published_workflow(pipeline=pipeline)
|
||||
|
||||
# return workflow, if not found, return None
|
||||
if workflow is None:
|
||||
return None
|
||||
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session))
|
||||
|
||||
@console_ns.response(200, "Success", console_ns.models[RagPipelineWorkflowPublishResponse.__name__])
|
||||
@setup_required
|
||||
@ -684,7 +687,7 @@ class PublishedAllRagPipelineApi(Resource):
|
||||
|
||||
return WorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"has_more": has_more,
|
||||
@ -763,7 +766,7 @@ class RagPipelineByIdApi(Resource):
|
||||
if not workflow:
|
||||
raise NotFound("Workflow not found")
|
||||
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
return dump_response(WorkflowResponse, WorkflowResponseSource(workflow, session=session))
|
||||
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
@setup_required
|
||||
|
||||
@ -22,6 +22,22 @@ from libs.login import current_account_with_tenant, login_required
|
||||
from machinery.context import RequestContext
|
||||
from machinery.errors import AdmissionConfigurationError
|
||||
from models.account import TenantAccountRole
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
|
||||
def console_email_registration_admission[T, **P, R](
|
||||
view: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
"""Apply the complete admission policy for anonymous email registration."""
|
||||
|
||||
@wraps(view)
|
||||
def check_registration_features(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
features = FeatureService.get_system_features()
|
||||
if not features.enable_email_password_login or not features.is_allow_register:
|
||||
abort(403)
|
||||
return view(self, *args, **kwargs)
|
||||
|
||||
return setup_required(check_registration_features)
|
||||
|
||||
|
||||
def console_account_admission[T, **P, R](
|
||||
|
||||
@ -1,56 +1,16 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import TypedDict
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
only_edition_cloud,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from enums import DeploymentEdition
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.billing_service import BillingService
|
||||
|
||||
# Notification content is stored under three lang tags.
|
||||
_FALLBACK_LANG = "en-US"
|
||||
|
||||
|
||||
class NotificationLangContent(TypedDict, total=False):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
titlePicUrl: str
|
||||
|
||||
|
||||
class NotificationItemDict(TypedDict):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResponseDict(TypedDict):
|
||||
should_show: bool
|
||||
notifications: list[NotificationItemDict]
|
||||
|
||||
|
||||
def _pick_lang_content(contents: Mapping[str, NotificationLangContent], lang: str) -> NotificationLangContent:
|
||||
"""Return the single LangContent for *lang*, falling back to English."""
|
||||
return (
|
||||
contents.get(lang) or contents.get(_FALLBACK_LANG) or next(iter(contents.values()), NotificationLangContent())
|
||||
)
|
||||
from libs.helper import dump_response
|
||||
from machinery.context import RequestContext
|
||||
|
||||
|
||||
class DismissNotificationPayload(BaseModel):
|
||||
@ -92,39 +52,10 @@ class NotificationApi(Resource):
|
||||
},
|
||||
)
|
||||
@console_ns.response(200, "Success", console_ns.models[NotificationResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
def get(self, current_user: Account):
|
||||
result = BillingService.get_account_notification(str(current_user.id))
|
||||
|
||||
# Proto JSON uses camelCase field names (Kratos default marshaling).
|
||||
response: NotificationResponseDict
|
||||
if not result.get("shouldShow"):
|
||||
response = {"should_show": False, "notifications": []}
|
||||
return response, 200
|
||||
|
||||
lang = current_user.interface_language or _FALLBACK_LANG
|
||||
|
||||
notifications: list[NotificationItemDict] = []
|
||||
for notification in result.get("notifications") or []:
|
||||
contents: Mapping[str, NotificationLangContent] = notification.get("contents") or {}
|
||||
lang_content = _pick_lang_content(contents, lang)
|
||||
item: NotificationItemDict = {
|
||||
"notification_id": notification.get("notificationId"),
|
||||
"frequency": notification.get("frequency"),
|
||||
"lang": lang_content.get("lang", lang),
|
||||
"title": lang_content.get("title", ""),
|
||||
"subtitle": lang_content.get("subtitle", ""),
|
||||
"body": lang_content.get("body", ""),
|
||||
"title_pic_url": lang_content.get("titlePicUrl", ""),
|
||||
}
|
||||
notifications.append(item)
|
||||
|
||||
response = {"should_show": bool(notifications), "notifications": notifications}
|
||||
return response, 200
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
result = application_services().notifications.get_active(request_context)
|
||||
return dump_response(NotificationResponse, result), 200
|
||||
|
||||
|
||||
@console_ns.route("/notification/dismiss")
|
||||
@ -134,17 +65,10 @@ class NotificationDismissApi(Resource):
|
||||
description="Mark a notification as dismissed for the current user.",
|
||||
responses={200: "Success", 401: "Unauthorized"},
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@with_current_user
|
||||
@account_initialization_required
|
||||
@only_edition_cloud
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
@console_ns.expect(console_ns.models[DismissNotificationPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@model_validate(DismissNotificationPayload)
|
||||
def post(self, payload: DismissNotificationPayload, current_user: Account):
|
||||
BillingService.dismiss_notification(
|
||||
notification_id=payload.notification_id,
|
||||
account_id=str(current_user.id),
|
||||
)
|
||||
return {"result": "success"}, 200
|
||||
def post(self, payload: DismissNotificationPayload, request_context: RequestContext):
|
||||
application_services().notifications.dismiss(request_context, payload.notification_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"}), 200
|
||||
|
||||
@ -7,36 +7,20 @@ action-based so callers do not replace server-side arrays with stale snapshots.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Literal, cast
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from controllers.console.flask_admission import console_account_admission
|
||||
from controllers.console.wraps import model_validate
|
||||
from extensions.ext_application_services import application_services
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from services.step_by_step_tour_service import StepByStepTourPatch, StepByStepTourService
|
||||
from machinery.context import RequestContext
|
||||
from services.entities.onboarding_entities import StepByStepTourAction, StepByStepTourPatch, StepByStepTourTaskId
|
||||
|
||||
from . import console_ns
|
||||
from .wraps import (
|
||||
account_initialization_required,
|
||||
model_validate,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
|
||||
StepByStepTourAction = Literal[
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId = Literal["home", "studio", "knowledge", "integration"]
|
||||
|
||||
|
||||
class StepByStepTourStatePatchPayload(BaseModel):
|
||||
@ -74,39 +58,22 @@ class StepByStepTourStateApi(Resource):
|
||||
@console_ns.doc("get_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Get account-level Step-by-step Tour state")
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
@console_account_admission()
|
||||
def get(self, request_context: RequestContext):
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.get_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.get_state(request_context),
|
||||
)
|
||||
|
||||
@console_ns.doc("patch_step_by_step_tour_state")
|
||||
@console_ns.doc(description="Update account-level Step-by-step Tour state")
|
||||
@console_ns.expect(console_ns.models[StepByStepTourStatePatchPayload.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[StepByStepTourStateResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@console_account_admission()
|
||||
@model_validate(StepByStepTourStatePatchPayload)
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, current_tenant_id: str, current_user: Account):
|
||||
patch = cast(StepByStepTourPatch, req_data.model_dump(exclude_unset=True, exclude_none=True))
|
||||
def patch(self, req_data: StepByStepTourStatePatchPayload, request_context: RequestContext):
|
||||
patch = StepByStepTourPatch(action=req_data.action, task_id=req_data.task_id)
|
||||
return dump_response(
|
||||
StepByStepTourStateResponse,
|
||||
StepByStepTourService.patch_state(
|
||||
account=current_user,
|
||||
current_tenant_id=current_tenant_id,
|
||||
patch=patch,
|
||||
session=db.session,
|
||||
),
|
||||
application_services().step_by_step_tour.patch_state(request_context, patch),
|
||||
)
|
||||
|
||||
@ -19,6 +19,7 @@ from controllers.console.app.workflow import (
|
||||
WorkflowPaginationResponse,
|
||||
WorkflowPublishResponse,
|
||||
WorkflowResponse,
|
||||
WorkflowResponseSource,
|
||||
WorkflowRestoreResponse,
|
||||
)
|
||||
from controllers.console.snippets.payloads import (
|
||||
@ -179,9 +180,12 @@ class SnippetDraftWorkflowApi(Resource):
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
workflow.conversation_variables = []
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
session = db.session()
|
||||
response = SnippetWorkflowResponse.model_validate(
|
||||
WorkflowResponseSource(workflow, session=session), from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=db.session(),
|
||||
session=session,
|
||||
draft_workflow=workflow,
|
||||
)
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
@ -274,7 +278,9 @@ class SnippetPublishedWorkflowApi(Resource):
|
||||
if not workflow:
|
||||
return None
|
||||
|
||||
response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response = SnippetWorkflowResponse.model_validate(
|
||||
WorkflowResponseSource(workflow, session=db.session()), from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@ -365,15 +371,15 @@ class SnippetPublishedAllWorkflowApi(Resource):
|
||||
limit=req_data.limit,
|
||||
)
|
||||
|
||||
response = SnippetWorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": workflows,
|
||||
"page": req_data.page,
|
||||
"limit": req_data.limit,
|
||||
"has_more": has_more,
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
response = SnippetWorkflowPaginationResponse.model_validate(
|
||||
{
|
||||
"items": [WorkflowResponseSource(workflow, session=session) for workflow in workflows],
|
||||
"page": req_data.page,
|
||||
"limit": req_data.limit,
|
||||
"has_more": has_more,
|
||||
},
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
for item in response["items"]:
|
||||
item["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
@ -464,9 +470,11 @@ class SnippetWorkflowByIdApi(Resource):
|
||||
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
|
||||
response = SnippetWorkflowResponse.model_validate(
|
||||
WorkflowResponseSource(workflow, session=session), from_attributes=True
|
||||
).model_dump(mode="json")
|
||||
response["input_fields"] = snippet.input_fields_list
|
||||
return response
|
||||
|
||||
@console_ns.doc("delete_snippet_workflow_by_id")
|
||||
@console_ns.doc(description="Delete a published snippet workflow version")
|
||||
|
||||
@ -292,10 +292,8 @@ class AccountInitApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountInitPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission(require_initialized=False)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInitPayload.model_validate(payload)
|
||||
|
||||
@model_validate(AccountInitPayload)
|
||||
def post(self, args: AccountInitPayload, request_context: RequestContext):
|
||||
try:
|
||||
application_services().accounts.initialization.initialize(
|
||||
request_context,
|
||||
@ -344,9 +342,8 @@ class AccountNameApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountNamePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountNamePayload.model_validate(payload)
|
||||
@model_validate(AccountNamePayload)
|
||||
def post(self, args: AccountNamePayload, request_context: RequestContext):
|
||||
return _update_account_profile(request_context, AccountProfileChanges(name=args.name))
|
||||
|
||||
|
||||
@ -371,9 +368,8 @@ class AccountAvatarApi(Resource):
|
||||
@console_ns.doc(description="Deprecated. Use PATCH /account/profile instead.")
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountAvatarPayload.model_validate(payload)
|
||||
@model_validate(AccountAvatarPayload)
|
||||
def post(self, args: AccountAvatarPayload, request_context: RequestContext):
|
||||
return _update_account_profile(request_context, AccountProfileChanges(avatar=args.avatar))
|
||||
|
||||
|
||||
@ -387,9 +383,8 @@ class AccountInterfaceLanguageApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceLanguagePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceLanguagePayload.model_validate(payload)
|
||||
@model_validate(AccountInterfaceLanguagePayload)
|
||||
def post(self, args: AccountInterfaceLanguagePayload, request_context: RequestContext):
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_language=args.interface_language),
|
||||
@ -406,9 +401,8 @@ class AccountInterfaceThemeApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountInterfaceThemePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountInterfaceThemePayload.model_validate(payload)
|
||||
@model_validate(AccountInterfaceThemePayload)
|
||||
def post(self, args: AccountInterfaceThemePayload, request_context: RequestContext):
|
||||
return _update_account_profile(
|
||||
request_context,
|
||||
AccountProfileChanges(interface_theme=args.interface_theme),
|
||||
@ -425,9 +419,8 @@ class AccountTimezoneApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountTimezonePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountTimezonePayload.model_validate(payload)
|
||||
@model_validate(AccountTimezonePayload)
|
||||
def post(self, args: AccountTimezonePayload, request_context: RequestContext):
|
||||
return _update_account_profile(request_context, AccountProfileChanges(timezone=args.timezone))
|
||||
|
||||
|
||||
@ -436,10 +429,8 @@ class AccountPasswordApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountPasswordPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountPasswordPayload.model_validate(payload)
|
||||
|
||||
@model_validate(AccountPasswordPayload)
|
||||
def post(self, args: AccountPasswordPayload, request_context: RequestContext):
|
||||
try:
|
||||
assert args.password is not None
|
||||
account = application_services().accounts.password.change(
|
||||
@ -498,10 +489,8 @@ class AccountDeleteApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountDeletePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_account_admission()
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountDeletePayload.model_validate(payload)
|
||||
|
||||
@model_validate(AccountDeletePayload)
|
||||
def post(self, args: AccountDeletePayload, request_context: RequestContext):
|
||||
try:
|
||||
application_services().accounts.deletion.request_deletion(
|
||||
request_context,
|
||||
@ -519,10 +508,8 @@ class AccountDeleteUpdateFeedbackApi(Resource):
|
||||
@console_ns.expect(console_ns.models[AccountDeletionFeedbackPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
def post(self):
|
||||
payload = console_ns.payload or {}
|
||||
args = AccountDeletionFeedbackPayload.model_validate(payload)
|
||||
|
||||
@model_validate(AccountDeletionFeedbackPayload)
|
||||
def post(self, args: AccountDeletionFeedbackPayload):
|
||||
application_services().accounts.deletion_feedback.submit(email=args.email, feedback=args.feedback)
|
||||
|
||||
return SimpleResultResponse(result="success").model_dump(mode="json")
|
||||
@ -547,9 +534,8 @@ class EducationApi(Resource):
|
||||
@console_ns.expect(console_ns.models[EducationActivatePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationActivateResponse.__name__])
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = EducationActivatePayload.model_validate(payload)
|
||||
@model_validate(EducationActivatePayload)
|
||||
def post(self, args: EducationActivatePayload, request_context: RequestContext):
|
||||
try:
|
||||
activation = application_services().accounts.education.activate(
|
||||
request_context,
|
||||
@ -574,10 +560,8 @@ class EducationAutoCompleteApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(EducationAutocompleteQuery))
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[EducationAutocompleteResponse.__name__])
|
||||
@console_account_admission(editions=frozenset({DeploymentEdition.CLOUD}))
|
||||
def get(self, request_context: RequestContext):
|
||||
payload = request.args.to_dict(flat=True)
|
||||
args = EducationAutocompleteQuery.model_validate(payload)
|
||||
|
||||
@model_validate(EducationAutocompleteQuery)
|
||||
def get(self, args: EducationAutocompleteQuery, request_context: RequestContext):
|
||||
return dump_response(
|
||||
EducationAutocompleteResponse,
|
||||
application_services().accounts.education.autocomplete(
|
||||
@ -594,10 +578,8 @@ class ChangeEmailSendEmailApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailSendPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultDataResponse.__name__])
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailSendPayload.model_validate(payload)
|
||||
|
||||
@model_validate(ChangeEmailSendPayload)
|
||||
def post(self, args: ChangeEmailSendPayload, request_context: RequestContext):
|
||||
ip_address = extract_remote_ip(request)
|
||||
language = "zh-Hans" if args.language == "zh-Hans" else "en-US"
|
||||
try:
|
||||
@ -627,10 +609,8 @@ class ChangeEmailCheckApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailValidityPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[VerificationTokenResponse.__name__])
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailValidityPayload.model_validate(payload)
|
||||
|
||||
@model_validate(ChangeEmailValidityPayload)
|
||||
def post(self, args: ChangeEmailValidityPayload, request_context: RequestContext):
|
||||
try:
|
||||
verification = application_services().accounts.change_email.verify_code(
|
||||
request_context,
|
||||
@ -656,9 +636,8 @@ class ChangeEmailResetApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ChangeEmailResetPayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[AccountResponse.__name__])
|
||||
@console_account_admission(require_change_email_enabled=True)
|
||||
def post(self, request_context: RequestContext):
|
||||
payload = console_ns.payload or {}
|
||||
args = ChangeEmailResetPayload.model_validate(payload)
|
||||
@model_validate(ChangeEmailResetPayload)
|
||||
def post(self, args: ChangeEmailResetPayload, request_context: RequestContext):
|
||||
try:
|
||||
updated_account = application_services().accounts.change_email.reset(
|
||||
request_context,
|
||||
@ -684,9 +663,8 @@ class CheckEmailUnique(Resource):
|
||||
@console_ns.expect(console_ns.models[CheckEmailUniquePayload.__name__])
|
||||
@console_ns.response(HTTPStatus.OK, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
def post(self):
|
||||
payload = console_ns.payload or {}
|
||||
args = CheckEmailUniquePayload.model_validate(payload)
|
||||
@model_validate(CheckEmailUniquePayload)
|
||||
def post(self, args: CheckEmailUniquePayload):
|
||||
try:
|
||||
application_services().accounts.change_email.ensure_available(args.email)
|
||||
except account_errors.AccountEmailDomainSuspendedError:
|
||||
|
||||
@ -221,7 +221,7 @@ class CustomizedSnippetDetailApi(Resource):
|
||||
"""Update customized snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
@ -265,7 +265,7 @@ class CustomizedSnippetDetailApi(Resource):
|
||||
"""Delete customized snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
@ -304,7 +304,7 @@ class CustomizedSnippetExportApi(Resource):
|
||||
"""Export snippet as DSL."""
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
@ -428,7 +428,7 @@ class CustomizedSnippetCheckDependenciesApi(Resource):
|
||||
"""Check dependencies for a snippet."""
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
@ -458,7 +458,7 @@ class CustomizedSnippetUseCountIncrementApi(Resource):
|
||||
"""Increment snippet use count when it is inserted into a workflow."""
|
||||
snippet_service = _snippet_service()
|
||||
snippet = snippet_service.get_snippet_by_id(
|
||||
snippet_id=str(snippet_id),
|
||||
snippet_id=snippet_id,
|
||||
tenant_id=current_tenant_id,
|
||||
)
|
||||
|
||||
|
||||
@ -352,19 +352,6 @@ def email_password_login_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]
|
||||
return decorated
|
||||
|
||||
|
||||
def email_register_enabled[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
features = FeatureService.get_system_features()
|
||||
if features.is_allow_register:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
# otherwise, return 403
|
||||
abort(403)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def enable_change_email[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs):
|
||||
@ -652,6 +639,23 @@ def with_current_user_id[T, **P, R](
|
||||
return decorated
|
||||
|
||||
|
||||
def validate_request[M: BaseModel](model: type[M]) -> M:
|
||||
"""Parse and validate the current request without exposing submitted values."""
|
||||
|
||||
if request.method == "GET":
|
||||
raw = request.args.to_dict(flat=True)
|
||||
elif request.method == "DELETE":
|
||||
raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {})
|
||||
else:
|
||||
raw = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
return model.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
errors = exc.errors(include_url=False, include_input=False, include_context=False)
|
||||
raise UnprocessableEntity(json.dumps(errors)) from None
|
||||
|
||||
|
||||
def model_validate[T, M: BaseModel, **P, R](
|
||||
model: type[M],
|
||||
) -> Callable[
|
||||
@ -671,19 +675,7 @@ def model_validate[T, M: BaseModel, **P, R](
|
||||
) -> Callable[Concatenate[T, P], R]:
|
||||
@wraps(view)
|
||||
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R:
|
||||
if request.method == "GET":
|
||||
raw = request.args.to_dict(flat=True)
|
||||
elif request.method == "DELETE":
|
||||
raw = request.args.to_dict(flat=True) or (request.get_json(silent=True) or {})
|
||||
else:
|
||||
raw = request.get_json(silent=True) or {}
|
||||
|
||||
try:
|
||||
validated = model.model_validate(raw)
|
||||
except ValidationError as exc:
|
||||
raise UnprocessableEntity(exc.json())
|
||||
|
||||
return view(self, validated, *args, **kwargs)
|
||||
return view(self, validate_request(model), *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@ -74,6 +74,7 @@ class OpenApiErrorCode(StrEnum):
|
||||
KNOWLEDGE_FS_REQUEST_TOO_LARGE = "knowledge_fs_request_too_large"
|
||||
KNOWLEDGE_FS_RESOURCE_NOT_FOUND = "knowledge_fs_resource_not_found"
|
||||
KNOWLEDGE_FS_UNAVAILABLE = "knowledge_fs_unavailable"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
|
||||
@ -35,6 +35,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
@ -57,6 +58,9 @@ from services.errors.app import (
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -70,6 +74,8 @@ def _translate_service_errors() -> Generator[None, None, None]:
|
||||
raise NotFound(str(ex))
|
||||
except (IsDraftWorkflowError, WorkflowIdFormatError) as ex:
|
||||
raise BadRequest(str(ex))
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
|
||||
@ -9,7 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import edit_permission_required
|
||||
from controllers.console.wraps import edit_permission_required, model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import validate_app_token
|
||||
from extensions.ext_redis import redis_client
|
||||
@ -106,9 +106,9 @@ class AnnotationReplyActionApi(Resource):
|
||||
service_api_ns.models[AnnotationJobStatusResponse.__name__],
|
||||
)
|
||||
@validate_app_token
|
||||
def post(self, app_model: App, action: Literal["enable", "disable"]):
|
||||
@model_validate(AnnotationReplyActionPayload)
|
||||
def post(self, payload: AnnotationReplyActionPayload, app_model: App, action: Literal["enable", "disable"]):
|
||||
"""Enable or disable annotation reply feature."""
|
||||
payload = AnnotationReplyActionPayload.model_validate(service_api_ns.payload or {})
|
||||
match action:
|
||||
case "enable":
|
||||
enable_args: EnableAnnotationArgs = {
|
||||
@ -250,9 +250,9 @@ class AnnotationListApi(Resource):
|
||||
)
|
||||
@validate_app_token
|
||||
@with_session
|
||||
def post(self, session: Session, app_model: App):
|
||||
@model_validate(AnnotationCreatePayload)
|
||||
def post(self, payload: AnnotationCreatePayload, session: Session, app_model: App):
|
||||
"""Create a new annotation."""
|
||||
payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
insert_args: InsertAnnotationArgs = {"question": payload.question, "answer": payload.answer}
|
||||
annotation = AppAnnotationService.insert_app_annotation_directly(insert_args, app_model.id, session)
|
||||
return dump_response(Annotation, annotation), HTTPStatus.CREATED
|
||||
@ -290,9 +290,9 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@validate_app_token
|
||||
@with_session
|
||||
@edit_permission_required
|
||||
def put(self, session: Session, app_model: App, annotation_id: UUID):
|
||||
@model_validate(AnnotationCreatePayload)
|
||||
def put(self, payload: AnnotationCreatePayload, session: Session, app_model: App, annotation_id: UUID):
|
||||
"""Update an existing annotation."""
|
||||
payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer}
|
||||
app_ref = AppRefService.create_app_ref(app_model)
|
||||
annotation_ref = AppRefService.create_annotation_ref(app_ref, str(annotation_id))
|
||||
|
||||
@ -8,6 +8,7 @@ import services
|
||||
from controllers.common.controller_schemas import TextToAudioPayload
|
||||
from controllers.common.fields import AudioBinaryResponse, AudioTranscriptResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@ -181,14 +182,13 @@ class TextApi(Resource):
|
||||
# TTS returns provider audio bytes, so the success response is intentionally schema-less.
|
||||
@service_api_ns.response(200, "Text successfully converted to audio")
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
@model_validate(TextToAudioPayload)
|
||||
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
|
||||
"""Convert text to audio using text-to-speech.
|
||||
|
||||
Converts the provided text to audio using the specified voice.
|
||||
"""
|
||||
try:
|
||||
payload = TextToAudioPayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
message_id = payload.message_id
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
|
||||
@ -11,6 +11,7 @@ from werkzeug.exceptions import BadRequest, NotFound
|
||||
import services
|
||||
from controllers.common.controller_schemas import ConversationRenamePayload
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
from controllers.service_api.schema import expect_user_json, expect_with_user
|
||||
@ -293,7 +294,8 @@ class ConversationRenameApi(Resource):
|
||||
service_api_ns.models[SimpleConversation.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
|
||||
def post(self, app_model: App, end_user: EndUser, conversation_id: UUID):
|
||||
@model_validate(ConversationRenamePayload)
|
||||
def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, conversation_id: UUID):
|
||||
"""Rename a conversation or auto-generate a name."""
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
@ -301,8 +303,6 @@ class ConversationRenameApi(Resource):
|
||||
|
||||
conversation_id_str = str(conversation_id)
|
||||
|
||||
payload = ConversationRenamePayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
try:
|
||||
session = db.session()
|
||||
conversation = ConversationService.rename(
|
||||
@ -408,7 +408,15 @@ class ConversationVariableDetailApi(Resource):
|
||||
service_api_ns.models[ConversationVariableResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
|
||||
def put(self, app_model: App, end_user: EndUser, conversation_id: UUID, variable_id: UUID):
|
||||
@model_validate(ConversationVariableUpdatePayload)
|
||||
def put(
|
||||
self,
|
||||
payload: ConversationVariableUpdatePayload,
|
||||
app_model: App,
|
||||
end_user: EndUser,
|
||||
conversation_id: UUID,
|
||||
variable_id: UUID,
|
||||
):
|
||||
"""Update a conversation variable's value.
|
||||
|
||||
Allows updating the value of a specific conversation variable.
|
||||
@ -421,8 +429,6 @@ class ConversationVariableDetailApi(Resource):
|
||||
conversation_id_str = str(conversation_id)
|
||||
variable_id_str = str(variable_id)
|
||||
|
||||
payload = ConversationVariableUpdatePayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
try:
|
||||
variable = ConversationService.update_conversation_variable(
|
||||
app_model, conversation_id_str, variable_id_str, end_user, payload.value, session=db.session()
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@ -37,6 +41,12 @@ class WorkflowVersionExecutionNotAllowedError(BaseHTTPException):
|
||||
code = 403
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@ -17,6 +17,7 @@ from werkzeug.exceptions import BadRequest, NotFound
|
||||
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
|
||||
@ -163,9 +164,8 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
service_api_ns.models[HumanInputFormSubmitResponse.__name__],
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
|
||||
def post(self, app_model: App, end_user: EndUser, form_token: str):
|
||||
payload = HumanInputFormSubmitPayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
@model_validate(HumanInputFormSubmitPayload)
|
||||
def post(self, payload: HumanInputFormSubmitPayload, app_model: App, end_user: EndUser, form_token: str):
|
||||
service = HumanInputService(db.engine)
|
||||
form = service.get_form_by_token(form_token)
|
||||
if form is None:
|
||||
|
||||
@ -11,6 +11,7 @@ import services
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
|
||||
from controllers.common.fields import SimpleResultStringListResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import NotChatAppError
|
||||
from controllers.service_api.schema import expect_with_user
|
||||
@ -160,15 +161,14 @@ class MessageFeedbackApi(Resource):
|
||||
}
|
||||
)
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
|
||||
def post(self, app_model: App, end_user: EndUser, message_id: UUID):
|
||||
@model_validate(MessageFeedbackPayload)
|
||||
def post(self, payload: MessageFeedbackPayload, app_model: App, end_user: EndUser, message_id: UUID):
|
||||
"""Submit feedback for a message.
|
||||
|
||||
Allows users to rate messages as like/dislike and provide optional feedback content.
|
||||
"""
|
||||
message_id_str = str(message_id)
|
||||
|
||||
payload = MessageFeedbackPayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
try:
|
||||
MessageService.create_feedback(
|
||||
app_model=app_model,
|
||||
|
||||
@ -28,6 +28,7 @@ from controllers.service_api.app.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowVersionExecutionNotAllowedError,
|
||||
)
|
||||
from controllers.service_api.schema import (
|
||||
@ -61,7 +62,14 @@ from models.model import App, AppMode, EndUser
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
IsDraftWorkflowError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_app_service import WorkflowAppService
|
||||
|
||||
@ -300,6 +308,11 @@ class WorkflowRunApi(Resource):
|
||||
"- `completion_request_error` : Workflow execution request failed.\n"
|
||||
"- `invalid_param` : Invalid parameter value."
|
||||
),
|
||||
403: (
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through "
|
||||
"Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
429: (
|
||||
"- `too_many_requests` : Too many concurrent requests for this app.\n"
|
||||
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
|
||||
@ -360,6 +373,8 @@ class WorkflowRunApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
@ -406,8 +421,11 @@ class WorkflowRunByIdApi(Resource):
|
||||
"- `invalid_param` : Required parameter missing or invalid."
|
||||
),
|
||||
403: (
|
||||
"`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan."
|
||||
"- `forbidden` : Token scope, app, or workspace access denied.\n"
|
||||
"- `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
|
||||
"current plan. Upgrade to a paid plan.\n"
|
||||
"- `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry "
|
||||
"and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
),
|
||||
404: "`not_found` : Workflow not found.",
|
||||
429: (
|
||||
@ -487,6 +505,8 @@ class WorkflowRunByIdApi(Resource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except WorkflowNotFoundError as ex:
|
||||
raise NotFound(str(ex))
|
||||
except IsDraftWorkflowError as ex:
|
||||
|
||||
@ -25,7 +25,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import edit_permission_required
|
||||
from controllers.console.wraps import edit_permission_required, model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.dataset.error import DatasetInUseError, DatasetNameDuplicateError, InvalidActionError
|
||||
from controllers.service_api.wraps import (
|
||||
@ -669,14 +669,13 @@ class DatasetApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@with_session
|
||||
def patch(self, session: Session, _, dataset_id: UUID):
|
||||
@model_validate(DatasetUpdatePayload)
|
||||
def patch(self, payload: DatasetUpdatePayload, session: Session, _, dataset_id: UUID):
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
payload_dict = service_api_ns.payload or {}
|
||||
payload = DatasetUpdatePayload.model_validate(payload_dict)
|
||||
update_data = payload.model_dump(exclude_unset=True)
|
||||
if payload.permission is not None:
|
||||
update_data["permission"] = str(payload.permission)
|
||||
@ -944,13 +943,13 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
service_api_ns.models[KnowledgeTagResponse.__name__],
|
||||
)
|
||||
@with_session
|
||||
def post(self, session: Session, _):
|
||||
@model_validate(TagCreatePayload)
|
||||
def post(self, payload: TagCreatePayload, session: Session, _):
|
||||
"""Add a knowledge type tag."""
|
||||
assert isinstance(current_user, Account)
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagCreatePayload.model_validate(service_api_ns.payload or {})
|
||||
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), session)
|
||||
|
||||
response = KnowledgeTagResponse(id=tag.id, name=tag.name, type=tag.type, binding_count="0")
|
||||
@ -982,12 +981,12 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
service_api_ns.models[KnowledgeTagResponse.__name__],
|
||||
)
|
||||
@with_session
|
||||
def patch(self, session: Session, _):
|
||||
@model_validate(TagUpdatePayload)
|
||||
def patch(self, payload: TagUpdatePayload, session: Session, _):
|
||||
assert isinstance(current_user, Account)
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
|
||||
tag_id = payload.tag_id
|
||||
tag = TagService.update_tags(
|
||||
UpdateTagServicePayload(name=payload.name), tag_id, session, tag_type=TagType.KNOWLEDGE
|
||||
@ -1019,9 +1018,9 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
)
|
||||
@edit_permission_required
|
||||
@with_session
|
||||
def delete(self, session: Session, _):
|
||||
@model_validate(TagDeletePayload)
|
||||
def delete(self, payload: TagDeletePayload, session: Session, _):
|
||||
"""Delete a knowledge type tag."""
|
||||
payload = TagDeletePayload.model_validate(service_api_ns.payload or {})
|
||||
TagService.delete_tag(payload.tag_id, session, tag_type=TagType.KNOWLEDGE)
|
||||
|
||||
return "", 204
|
||||
@ -1049,13 +1048,13 @@ class DatasetTagBindingApi(DatasetApiResource):
|
||||
}
|
||||
)
|
||||
@with_session
|
||||
def post(self, session: Session, _):
|
||||
@model_validate(TagBindingPayload)
|
||||
def post(self, payload: TagBindingPayload, session: Session, _):
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
assert isinstance(current_user, Account)
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagBindingPayload.model_validate(service_api_ns.payload or {})
|
||||
TagService.save_tag_binding(
|
||||
TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
|
||||
session,
|
||||
@ -1086,13 +1085,13 @@ class DatasetTagUnbindingApi(DatasetApiResource):
|
||||
}
|
||||
)
|
||||
@with_session
|
||||
def post(self, session: Session, _):
|
||||
@model_validate(TagUnbindingPayload)
|
||||
def post(self, payload: TagUnbindingPayload, session: Session, _):
|
||||
# The role of the current user in the ta table must be admin, owner, editor, or dataset_operator
|
||||
assert isinstance(current_user, Account)
|
||||
if not (current_user.has_edit_permission or current_user.is_dataset_editor):
|
||||
raise Forbidden()
|
||||
|
||||
payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {})
|
||||
TagService.delete_tag_binding(
|
||||
TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
|
||||
session,
|
||||
|
||||
@ -45,6 +45,7 @@ from controllers.common.schema import (
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.app.error import ProviderNotInitializeError
|
||||
from controllers.service_api.dataset.error import (
|
||||
@ -1069,9 +1070,8 @@ class DocumentBatchDownloadZipApi(DatasetApiResource):
|
||||
@service_api_ns.response(200, "ZIP archive generated successfully")
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@with_session(write=False)
|
||||
def post(self, session: Session, tenant_id, dataset_id: UUID):
|
||||
payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
@model_validate(DocumentBatchDownloadZipPayload)
|
||||
def post(self, payload: DocumentBatchDownloadZipPayload, session: Session, tenant_id, dataset_id: UUID):
|
||||
upload_files, download_name = DocumentService.prepare_document_batch_download_zip(
|
||||
dataset_id=str(dataset_id),
|
||||
document_ids=[str(document_id) for document_id in payload.document_ids],
|
||||
|
||||
@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound
|
||||
from controllers.common.controller_schemas import MetadataUpdatePayload
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models
|
||||
from controllers.common.session import with_session
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
|
||||
from fields.dataset_fields import (
|
||||
@ -81,9 +82,9 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id, dataset_id: UUID):
|
||||
@model_validate(MetadataArgs)
|
||||
def post(self, metadata_args: MetadataArgs, session: Session, tenant_id, dataset_id: UUID):
|
||||
"""Create metadata for a dataset."""
|
||||
metadata_args = MetadataArgs.model_validate(service_api_ns.payload or {})
|
||||
|
||||
dataset_id_str = str(dataset_id)
|
||||
dataset = DatasetService.get_dataset(dataset_id_str, session)
|
||||
@ -156,9 +157,9 @@ class DatasetMetadataServiceApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@with_session
|
||||
def patch(self, session: Session, tenant_id, dataset_id: UUID, metadata_id: UUID):
|
||||
@model_validate(MetadataUpdatePayload)
|
||||
def patch(self, payload: MetadataUpdatePayload, session: Session, tenant_id, dataset_id: UUID, metadata_id: UUID):
|
||||
"""Update metadata name."""
|
||||
payload = MetadataUpdatePayload.model_validate(service_api_ns.payload or {})
|
||||
|
||||
dataset_id_str = str(dataset_id)
|
||||
metadata_id_str = str(metadata_id)
|
||||
@ -315,15 +316,14 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
|
||||
)
|
||||
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
|
||||
@with_session
|
||||
def post(self, session: Session, tenant_id, dataset_id: UUID):
|
||||
@model_validate(MetadataOperationData)
|
||||
def post(self, metadata_args: MetadataOperationData, session: Session, tenant_id, dataset_id: UUID):
|
||||
"""Update metadata for multiple documents."""
|
||||
dataset = DatasetService.get_dataset_for_tenant(str(dataset_id), str(tenant_id), session=session)
|
||||
if dataset is None:
|
||||
raise NotFound("Dataset not found.")
|
||||
DatasetService.check_dataset_permission(dataset, current_user, session)
|
||||
|
||||
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
|
||||
|
||||
try:
|
||||
MetadataService.update_documents_metadata(
|
||||
dataset, metadata_args, cast(Account, current_user), session=session
|
||||
|
||||
@ -24,6 +24,7 @@ from controllers.common.schema import (
|
||||
register_schema_model,
|
||||
)
|
||||
from controllers.console.app.wraps import with_session
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.service_api import service_api_ns
|
||||
from controllers.service_api.dataset.error import PipelineRunError
|
||||
from controllers.service_api.schema import event_stream_response, json_or_event_stream_response, multipart_file_params
|
||||
@ -215,7 +216,8 @@ class DatasourceNodeRunApi(DatasetApiResource):
|
||||
}
|
||||
)
|
||||
@service_api_ns.expect(service_api_ns.models[DatasourceNodeRunPayload.__name__])
|
||||
def post(self, tenant_id: str, dataset_id: UUID, node_id: str):
|
||||
@model_validate(DatasourceNodeRunPayload)
|
||||
def post(self, payload: DatasourceNodeRunPayload, tenant_id: str, dataset_id: UUID, node_id: str):
|
||||
"""Resource for getting datasource plugins."""
|
||||
dataset_id_str = str(dataset_id)
|
||||
# Verify dataset ownership
|
||||
@ -224,7 +226,6 @@ class DatasourceNodeRunApi(DatasetApiResource):
|
||||
if not dataset:
|
||||
raise NotFound("Dataset not found.")
|
||||
|
||||
payload = DatasourceNodeRunPayload.model_validate(service_api_ns.payload or {})
|
||||
assert isinstance(current_user, Account)
|
||||
rag_pipeline_service: RagPipelineService = RagPipelineService(db.session())
|
||||
pipeline: Pipeline = rag_pipeline_service.get_pipeline(tenant_id=tenant_id, dataset_id=dataset_id_str)
|
||||
|
||||
@ -6,6 +6,7 @@ from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import TextToAudioPayload as TextToAudioPayloadBase
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import (
|
||||
AppUnavailableError,
|
||||
@ -131,11 +132,10 @@ class TextApi(WebApiResource):
|
||||
)
|
||||
# response-contract:ignore provider audio bytes; TODO: model binary audio response if shape is standardized.
|
||||
@web_ns.response(200, "Success")
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
@model_validate(TextToAudioPayload)
|
||||
def post(self, payload: TextToAudioPayload, app_model: App, end_user: EndUser):
|
||||
"""Convert text to audio"""
|
||||
try:
|
||||
payload = TextToAudioPayload.model_validate(web_ns.payload or {})
|
||||
|
||||
message_id = payload.message_id
|
||||
text = payload.text
|
||||
voice = payload.voice
|
||||
|
||||
@ -8,6 +8,7 @@ from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.controller_schemas import ConversationRenamePayload
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console.wraps import model_validate
|
||||
from controllers.web import web_ns
|
||||
from controllers.web.error import NotChatAppError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
@ -153,15 +154,14 @@ class ConversationRenameApi(WebApiResource):
|
||||
)
|
||||
@web_ns.response(200, "Conversation renamed successfully", web_ns.models[SimpleConversation.__name__])
|
||||
@web_ns.expect(web_ns.models[ConversationRenamePayload.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
@model_validate(ConversationRenamePayload)
|
||||
def post(self, payload: ConversationRenamePayload, app_model: App, end_user: EndUser, c_id: UUID):
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
|
||||
raise NotChatAppError()
|
||||
|
||||
conversation_id = str(c_id)
|
||||
|
||||
payload = ConversationRenamePayload.model_validate(web_ns.payload or {})
|
||||
|
||||
try:
|
||||
session = db.session()
|
||||
conversation = ConversationService.rename(
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
from libs.exception import BaseHTTPException
|
||||
from services.errors.app import (
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE,
|
||||
)
|
||||
|
||||
|
||||
class AppUnavailableError(BaseHTTPException):
|
||||
@ -31,6 +35,12 @@ class NotWorkflowAppError(BaseHTTPException):
|
||||
code = 400
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(BaseHTTPException):
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
description = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE
|
||||
code = 403
|
||||
|
||||
|
||||
class ConversationCompletedError(BaseHTTPException):
|
||||
error_code = "conversation_completed"
|
||||
description = "The conversation has ended. Please start a new conversation."
|
||||
|
||||
@ -9,6 +9,7 @@ from controllers.common.errors import (
|
||||
RemoteFileUploadError,
|
||||
UnsupportedFileTypeError,
|
||||
)
|
||||
from controllers.console.wraps import model_validate
|
||||
from core.file import remote_fetcher
|
||||
from extensions.ext_database import db
|
||||
from fields.file_fields import FileWithSignedUrl, RemoteFileInfo
|
||||
@ -86,7 +87,8 @@ class RemoteFileUploadApi(WebApiResource):
|
||||
)
|
||||
@web_ns.response(201, "Remote file uploaded", web_ns.models[FileWithSignedUrl.__name__])
|
||||
@web_ns.expect(web_ns.models[RemoteFileUploadPayload.__name__])
|
||||
def post(self, app_model: App, end_user: EndUser):
|
||||
@model_validate(RemoteFileUploadPayload)
|
||||
def post(self, payload: RemoteFileUploadPayload, app_model: App, end_user: EndUser):
|
||||
"""Upload a file from a remote URL.
|
||||
|
||||
Downloads a file from the provided remote URL and uploads it
|
||||
@ -108,7 +110,6 @@ class RemoteFileUploadApi(WebApiResource):
|
||||
FileTooLargeError: File exceeds size limit
|
||||
UnsupportedFileTypeError: File type not supported
|
||||
"""
|
||||
payload = RemoteFileUploadPayload.model_validate(web_ns.payload or {})
|
||||
url = str(payload.url)
|
||||
|
||||
try:
|
||||
|
||||
@ -14,6 +14,7 @@ from controllers.web.error import (
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from controllers.web.wraps import WebApiResource
|
||||
@ -30,6 +31,9 @@ from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from models.model import App, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import (
|
||||
TriggerWorkflowServiceModeUnavailableError as TriggerWorkflowServiceModeUnavailableServiceError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -78,6 +82,8 @@ class WorkflowRunApi(WebApiResource):
|
||||
|
||||
# response-contract:ignore compact_generate_response
|
||||
return helper.compact_generate_response(response)
|
||||
except TriggerWorkflowServiceModeUnavailableServiceError:
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
|
||||
@ -31,7 +31,11 @@ from core.agent.publish_visibility import agent_has_workflow_callable_active_sna
|
||||
from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter
|
||||
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
|
||||
from core.app.apps.agent_app.app_runner import AgentAppRunner
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.apps.agent_app.errors import (
|
||||
AgentAppGeneratorError,
|
||||
AgentAppNotPublishedError,
|
||||
AgentSessionSnapshotIncompatibleError,
|
||||
)
|
||||
from core.app.apps.agent_app.generate_response_converter import AgentAppGenerateResponseConverter
|
||||
from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeRequestBuilder
|
||||
from core.app.apps.agent_app.session_store import AgentAppWorkspaceStore
|
||||
@ -531,6 +535,15 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
)
|
||||
except GenerateTaskStoppedError:
|
||||
pass
|
||||
except AgentSessionSnapshotIncompatibleError as error:
|
||||
logger.info(
|
||||
"Agent App session snapshot no longer matches the current composition",
|
||||
extra={
|
||||
"agent_id": application_generate_entity.agent_id,
|
||||
"conversation_id": conversation_id,
|
||||
},
|
||||
)
|
||||
queue_manager.publish_error(error, PublishFrom.APPLICATION_MANAGER)
|
||||
except Exception as e:
|
||||
logger.exception("Unknown Error in Agent App generate worker")
|
||||
queue_manager.publish_error(e, PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE = "agent_session_configuration_changed"
|
||||
AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE = (
|
||||
"The Agent configuration changed after this conversation started. Start a new conversation to continue."
|
||||
)
|
||||
|
||||
|
||||
class AgentAppGeneratorError(ValueError):
|
||||
"""Raised when an Agent App turn cannot be set up."""
|
||||
|
||||
|
||||
class AgentAppNotPublishedError(AgentAppGeneratorError):
|
||||
"""Raised when a public Agent App runtime is requested before publish."""
|
||||
|
||||
|
||||
class AgentSessionSnapshotIncompatibleError(AppGenerateError):
|
||||
"""Raised when a retained session snapshot no longer matches the current composition."""
|
||||
|
||||
error_code = AGENT_SESSION_CONFIGURATION_CHANGED_ERROR_CODE
|
||||
status_code = 409
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(AGENT_SESSION_CONFIGURATION_CHANGED_MESSAGE)
|
||||
|
||||
@ -50,6 +50,8 @@ from models.agent_config_entities import AgentSoulConfig, AgentSoulToolsConfig
|
||||
from models.provider_ids import ModelProviderID
|
||||
from services.agent.prompt_mentions import expand_prompt_mentions
|
||||
|
||||
from .errors import AgentSessionSnapshotIncompatibleError
|
||||
|
||||
|
||||
class AgentAppRuntimeRequestBuildError(ValueError):
|
||||
"""Raised when Agent App state cannot be mapped to a valid run request."""
|
||||
@ -192,6 +194,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
metadata=metadata,
|
||||
)
|
||||
)
|
||||
self._validate_session_snapshot_layers(request)
|
||||
redacted = cast(dict[str, Any], redact_for_agent_backend_log(request))
|
||||
return AgentAppRuntimeRequest(
|
||||
request=request,
|
||||
@ -200,6 +203,24 @@ class AgentAppRuntimeRequestBuilder:
|
||||
binding_id=context.binding_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_session_snapshot_layers(request: CreateRunRequest) -> None:
|
||||
"""Reject stale snapshots before they reach the Agent backend.
|
||||
|
||||
Draft rows are updated in place, so their IDs cannot prove that a
|
||||
retained snapshot still belongs to the current composition. Agenton
|
||||
requires the ordered layer names to match exactly; enforce the same
|
||||
invariant at the API boundary and return a product-level error.
|
||||
"""
|
||||
|
||||
snapshot = request.session_snapshot
|
||||
if snapshot is None:
|
||||
return
|
||||
snapshot_layer_names = tuple(layer.name for layer in snapshot.layers)
|
||||
composition_layer_names = tuple(layer.name for layer in request.composition.layers)
|
||||
if snapshot_layer_names != composition_layer_names:
|
||||
raise AgentSessionSnapshotIncompatibleError()
|
||||
|
||||
def _build_tool_layers(
|
||||
self,
|
||||
*,
|
||||
|
||||
@ -7,6 +7,7 @@ from dify_agent.protocol import RunFailureType
|
||||
from pydantic import JsonValue
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendError, AgentBackendRunFailedError
|
||||
from core.app.apps.exc import AppGenerateError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
@ -125,6 +126,13 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
if isinstance(e, AppGenerateError):
|
||||
return {
|
||||
"code": e.error_code,
|
||||
"status": e.status_code,
|
||||
"message": str(e),
|
||||
}
|
||||
|
||||
error_responses: dict[type[Exception], dict[str, JsonValue]] = {
|
||||
ValueError: {"code": "invalid_param", "status": 400},
|
||||
ProviderTokenNotInitError: {"code": "provider_not_initialize", "status": 400},
|
||||
|
||||
@ -1,2 +1,9 @@
|
||||
class AppGenerateError(ValueError):
|
||||
"""Base class for application-generation errors with a stable response contract."""
|
||||
|
||||
error_code: str
|
||||
status_code: int
|
||||
|
||||
|
||||
class GenerateTaskStoppedError(Exception):
|
||||
pass
|
||||
|
||||
@ -841,9 +841,8 @@ class LLMGenerator:
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str | None,
|
||||
workflow_service: WorkflowServiceInterface,
|
||||
session: Session,
|
||||
):
|
||||
session = db.session()
|
||||
|
||||
app: App | None = session.scalar(select(App).where(App.id == flow_id, App.tenant_id == tenant_id).limit(1))
|
||||
if not app:
|
||||
raise ValueError("App not found.")
|
||||
|
||||
@ -12,6 +12,7 @@ from core.mcp import types as mcp_types
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models.model import App, AppMCPServer, AppMode, EndUser
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import TriggerWorkflowServiceModeUnavailableError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -93,11 +94,16 @@ def handle_mcp_request(
|
||||
result=result_data.model_dump(by_alias=True, mode="json", exclude_none=True),
|
||||
)
|
||||
|
||||
def create_error_response(code: int, message: str) -> mcp_types.JSONRPCError:
|
||||
def create_error_response(
|
||||
code: int,
|
||||
message: str,
|
||||
*,
|
||||
data: Mapping[str, Any] | None = None,
|
||||
) -> mcp_types.JSONRPCError:
|
||||
"""Create error response with error code and message"""
|
||||
from core.mcp.types import ErrorData
|
||||
|
||||
error_data = ErrorData(code=code, message=message)
|
||||
error_data = ErrorData(code=code, message=message, data=data)
|
||||
return mcp_types.JSONRPCError(
|
||||
jsonrpc="2.0",
|
||||
id=request_id,
|
||||
@ -131,6 +137,12 @@ def handle_mcp_request(
|
||||
case _:
|
||||
return create_error_response(mcp_types.METHOD_NOT_FOUND, f"Method not found: {request_type.__name__}")
|
||||
|
||||
except TriggerWorkflowServiceModeUnavailableError as e:
|
||||
return create_error_response(
|
||||
mcp_types.INVALID_REQUEST,
|
||||
str(e),
|
||||
data={"code": e.error_code},
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.exception("Invalid params")
|
||||
return create_error_response(mcp_types.INVALID_PARAMS, str(e))
|
||||
|
||||
@ -1193,7 +1193,7 @@ class WorkflowGenerator:
|
||||
if node.get("node_type") == BuiltinNodeTypes.TOOL and node.get("id")
|
||||
}
|
||||
for node in graph.get("nodes") or []:
|
||||
planned = planned_by_id.get(str(node.get("id") or ""))
|
||||
planned = planned_by_id.get(node.get("id") or "")
|
||||
if planned is None:
|
||||
continue
|
||||
data = node.get("data")
|
||||
|
||||
@ -76,6 +76,10 @@ def _schema_markdown_type(schema: object) -> str:
|
||||
item_type = _schema_markdown_type(schema.get("items"))
|
||||
return f"[ {item_type or 'object'} ]"
|
||||
if isinstance(schema_type, str):
|
||||
enum_values = schema.get("enum")
|
||||
if isinstance(enum_values, list) and enum_values:
|
||||
rendered_values = ", ".join(json.dumps(value, ensure_ascii=False) for value in enum_values)
|
||||
return f"{schema_type}, <br>**Available values:** {rendered_values}"
|
||||
return schema_type
|
||||
|
||||
return ""
|
||||
|
||||
@ -31,6 +31,7 @@ from repositories.factory import DifyAPIRepositoryFactory
|
||||
from repositories.installation_state_repository import InstallationStateRepository
|
||||
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
|
||||
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
|
||||
from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository
|
||||
from repositories.tag_repository import TagRepository
|
||||
from repositories.trial_app_query_repository import TrialAppQueryRepository
|
||||
from repositories.trial_app_usage_repository import TrialAppUsageRepository
|
||||
@ -70,6 +71,16 @@ from services.account_deletion_adapters import (
|
||||
from services.account_deletion_feedback_service import AccountDeletionFeedbackService
|
||||
from services.account_deletion_service import AccountDeletionService
|
||||
from services.account_education_service import AccountEducationService
|
||||
from services.account_email_registration_adapters import (
|
||||
AccountServiceRegistrationGateway,
|
||||
BillingAccountRegistrationPolicyGateway,
|
||||
CeleryEmailRegistrationNotificationGateway,
|
||||
RateLimiterEmailRegistrationSendLimiter,
|
||||
RedisEmailRegistrationSecurityGateway,
|
||||
SecureEmailRegistrationCodeGenerator,
|
||||
TokenManagerEmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_email_registration_service import AccountEmailRegistrationService
|
||||
from services.account_initialization_service import AccountInitializationService
|
||||
from services.account_integration_service import AccountIntegrationService
|
||||
from services.account_password_hasher import LegacyAccountPasswordHasher
|
||||
@ -94,6 +105,8 @@ from services.feature_service import FeatureService
|
||||
from services.feature_service_gateway import FeatureServiceGateway
|
||||
from services.file_service import FileService
|
||||
from services.init_validation_service import InitValidationService
|
||||
from services.notification_gateway import BillingNotificationGateway
|
||||
from services.notification_service import NotificationService
|
||||
from services.notion_data_source_gateway import NotionDataSourceGateway
|
||||
from services.oauth_server_service import OAUTH_ACCESS_TOKEN_EXPIRES_IN, OAuthServerService
|
||||
from services.partner_tenant_binding_service import PartnerTenantBindingService
|
||||
@ -112,6 +125,7 @@ from services.retention.workflow_run.archive_log_service import WorkflowRunArchi
|
||||
from services.schema_definition_service import SchemaDefinitionService
|
||||
from services.setup_adapters import RedisSetupLock, RegisterServiceAccountProvisioner
|
||||
from services.setup_service import SetupService
|
||||
from services.step_by_step_tour_service import StepByStepTourService
|
||||
from services.tag_application_service import TagApplicationService
|
||||
from services.trial_app_usage import TrialAppUsageRecorder
|
||||
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
|
||||
@ -150,6 +164,7 @@ def _is_user_allowed_to_access_webapp(user_id: str, app_id: str) -> bool:
|
||||
class AccountServices:
|
||||
avatar: AccountAvatarService
|
||||
change_email: AccountChangeEmailService
|
||||
email_registration: AccountEmailRegistrationService
|
||||
deletion: AccountDeletionService
|
||||
deletion_feedback: AccountDeletionFeedbackService
|
||||
education: AccountEducationService
|
||||
@ -177,6 +192,8 @@ class ApplicationServices:
|
||||
feature_queries: FeatureQueryService
|
||||
oauth_server: OAuthServerService
|
||||
init_validation: InitValidationService
|
||||
notifications: NotificationService
|
||||
step_by_step_tour: StepByStepTourService
|
||||
partner_tenant_bindings: PartnerTenantBindingService
|
||||
recommended_app_queries: RecommendedAppQueryService
|
||||
trial_app_usage: TrialAppUsageRecorder
|
||||
@ -278,6 +295,29 @@ def build_application_services(
|
||||
billing_enabled=deployment_edition == DeploymentEdition.CLOUD,
|
||||
),
|
||||
),
|
||||
email_registration=AccountEmailRegistrationService(
|
||||
accounts=accounts,
|
||||
tokens=TokenManagerEmailRegistrationTokenGateway(),
|
||||
codes=SecureEmailRegistrationCodeGenerator(),
|
||||
notifications=CeleryEmailRegistrationNotificationGateway(),
|
||||
send_limits=RateLimiterEmailRegistrationSendLimiter(
|
||||
rate_limiter=RateLimiter(
|
||||
prefix="email_register_rate_limit",
|
||||
max_attempts=1,
|
||||
time_window=60,
|
||||
redis_client=redis,
|
||||
)
|
||||
),
|
||||
security=RedisEmailRegistrationSecurityGateway(
|
||||
redis=redis,
|
||||
verification_failure_limit=5,
|
||||
verification_lockout_duration=dify_config.EMAIL_REGISTER_LOCKOUT_DURATION,
|
||||
),
|
||||
account_policy=BillingAccountRegistrationPolicyGateway(
|
||||
enabled=deployment_edition == DeploymentEdition.CLOUD,
|
||||
),
|
||||
registration=AccountServiceRegistrationGateway(session_factory=database_client),
|
||||
),
|
||||
deletion=AccountDeletionService(
|
||||
accounts=accounts,
|
||||
memberships=workspace_query_repository,
|
||||
@ -400,6 +440,16 @@ def build_application_services(
|
||||
validation_required=(deployment_edition != DeploymentEdition.CLOUD and bool(initialization_password)),
|
||||
expected_password=initialization_password,
|
||||
),
|
||||
notifications=NotificationService(
|
||||
accounts=accounts,
|
||||
notifications=BillingNotificationGateway(),
|
||||
),
|
||||
step_by_step_tour=StepByStepTourService(
|
||||
accounts=accounts,
|
||||
states=SQLAlchemyStepByStepTourStateRepository(session_factory=database_client),
|
||||
enabled=dify_config.ENABLE_STEP_BY_STEP_TOUR,
|
||||
rollout_started_at=dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT,
|
||||
),
|
||||
partner_tenant_bindings=PartnerTenantBindingService(
|
||||
sync_bindings=BillingService.sync_partner_tenants_bindings,
|
||||
),
|
||||
|
||||
@ -297,18 +297,16 @@ class Workflow(Base): # bug
|
||||
workflow.updated_at = workflow.created_at
|
||||
return workflow
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
return self.get_created_by_account(session=db.session())
|
||||
def created_by_account(self, session: orm.Session) -> Account | None:
|
||||
return self.get_created_by_account(session=session)
|
||||
|
||||
def get_created_by_account(self, *, session: orm.Session) -> Account | None:
|
||||
def get_created_by_account(self, session: orm.Session) -> Account | None:
|
||||
return session.get(Account, self.created_by)
|
||||
|
||||
@property
|
||||
def updated_by_account(self) -> Account | None:
|
||||
return self.get_updated_by_account(session=db.session())
|
||||
def updated_by_account(self, session: orm.Session) -> Account | None:
|
||||
return self.get_updated_by_account(session=session)
|
||||
|
||||
def get_updated_by_account(self, *, session: orm.Session) -> Account | None:
|
||||
def get_updated_by_account(self, session: orm.Session) -> Account | None:
|
||||
return session.get(Account, self.updated_by) if self.updated_by else None
|
||||
|
||||
@property
|
||||
@ -564,18 +562,17 @@ class Workflow(Base): # bug
|
||||
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
@property
|
||||
@deprecated(
|
||||
"This property is not accurate for determining if a workflow is published as a tool."
|
||||
"This method is not accurate for determining if a workflow is published as a tool."
|
||||
"It only checks if there's a WorkflowToolProvider for the app, "
|
||||
"not if this specific workflow version is the one being used by the tool."
|
||||
)
|
||||
def tool_published(self) -> bool:
|
||||
return self.get_tool_published(session=db.session())
|
||||
def tool_published(self, session: orm.Session) -> bool:
|
||||
return self.get_tool_published(session=session)
|
||||
|
||||
def get_tool_published(self, *, session: orm.Session) -> bool:
|
||||
def get_tool_published(self, session: orm.Session) -> bool:
|
||||
"""
|
||||
DEPRECATED: This property is not accurate for determining if a workflow is published as a tool.
|
||||
DEPRECATED: This method is not accurate for determining if a workflow is published as a tool.
|
||||
It only checks if there's a WorkflowToolProvider for the app, not if this specific workflow version
|
||||
is the one being used by the tool.
|
||||
|
||||
@ -1393,17 +1390,15 @@ class WorkflowAppLog(TypeBase):
|
||||
|
||||
return None
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
def created_by_account(self, session: orm.Session) -> Account | None:
|
||||
created_by_role = CreatorUserRole(self.created_by_role)
|
||||
return db.session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None
|
||||
return session.get(Account, self.created_by) if created_by_role == CreatorUserRole.ACCOUNT else None
|
||||
|
||||
@property
|
||||
def created_by_end_user(self):
|
||||
def created_by_end_user(self, session: orm.Session):
|
||||
from .model import EndUser
|
||||
|
||||
created_by_role = CreatorUserRole(self.created_by_role)
|
||||
return db.session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None
|
||||
return session.get(EndUser, self.created_by) if created_by_role == CreatorUserRole.END_USER else None
|
||||
|
||||
def to_dict(self) -> WorkflowAppLogDict:
|
||||
result: WorkflowAppLogDict = {
|
||||
|
||||
@ -346,6 +346,7 @@ Check if activation token is valid
|
||||
| mode | query | App mode filter | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all |
|
||||
| name | query | Filter by app name | No | string |
|
||||
| page | query | Page number (1-99999) | No | integer, <br>**Default:** 1 |
|
||||
| publication_status | query | Filter by published or draft Agent configuration status | No | string, <br>**Available values:** "drafts", "published" |
|
||||
| sort_by | query | Sort apps by last modified, recently created, or earliest created | No | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified |
|
||||
| tag_ids | query | Filter by tag IDs | No | [ string ] |
|
||||
|
||||
@ -15600,6 +15601,20 @@ default (the config form sends the full desired feature state on save).
|
||||
| suggested_questions_after_answer | [AgentSuggestedQuestionsAfterAnswerFeatureConfig](#agentsuggestedquestionsafteranswerfeatureconfig) | Follow-up suggestions config, e.g. {'enabled': true} | No |
|
||||
| text_to_speech | [AgentTextToSpeechFeatureConfig](#agenttexttospeechfeatureconfig) | Text-to-speech config | No |
|
||||
|
||||
#### AgentAppListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| creator_ids | [ string ] | Filter by creator account IDs | No |
|
||||
| is_created_by_me | boolean | Filter by creator | No |
|
||||
| limit | integer, <br>**Default:** 20 | Page size (1-100) | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "all", "channel", "chat", "completion", "workflow", <br>**Default:** all | App mode filter<br>*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No |
|
||||
| name | string | Filter by app name | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number (1-99999) | No |
|
||||
| publication_status | string, <br>**Available values:** "drafts", "published" | Filter by published or draft Agent configuration status | No |
|
||||
| sort_by | string, <br>**Available values:** "earliest_created", "last_modified", "recently_created", <br>**Default:** last_modified | Sort apps by last modified, recently created, or earliest created<br>*Enum:* `"earliest_created"`, `"last_modified"`, `"recently_created"` | No |
|
||||
| tag_ids | [ string ] | Filter by tag IDs | No |
|
||||
|
||||
#### AgentAppPagination
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -15608,6 +15623,7 @@ default (the config form sends the full desired feature state on save).
|
||||
| has_more | boolean | | Yes |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
| publication_counts | [AgentPublicationCountsResponse](#agentpublicationcountsresponse) | | Yes |
|
||||
| total | integer | | Yes |
|
||||
|
||||
#### AgentAppPartial
|
||||
@ -16684,6 +16700,13 @@ section may be empty, which is how callers express "no knowledge layer".
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| AgentProviderResponse | object | | |
|
||||
|
||||
#### AgentPublicationCountsResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| drafts | integer | Draft Agent Apps in the current list scope, excluding the publication status filter | Yes |
|
||||
| published | integer | Published Agent Apps in the current list scope, excluding the publication status filter | Yes |
|
||||
|
||||
#### AgentPublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@ -17831,7 +17854,7 @@ AppMCPServer Status Enum
|
||||
| copyright | string | | No |
|
||||
| custom_disclaimer | string | | No |
|
||||
| customize_domain | string | | No |
|
||||
| customize_token_strategy | string | | No |
|
||||
| customize_token_strategy | string, <br>**Available values:** "allow", "must", "not_allow" | | No |
|
||||
| default_language | string | | No |
|
||||
| description | string | | No |
|
||||
| icon | string | | No |
|
||||
@ -18289,7 +18312,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -18310,7 +18333,7 @@ TEAM: Team collaboration paid plan
|
||||
| files | [ object ] | | No |
|
||||
| inputs | object | | Yes |
|
||||
| query | string | | No |
|
||||
| response_mode | string | | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | | No |
|
||||
| retriever_from | string, <br>**Default:** explore_app | | No |
|
||||
|
||||
#### ComplianceDownloadQuery
|
||||
@ -20352,9 +20375,9 @@ Flask blueprint initialization.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| end_date | string | End date (YYYY-MM-DD) | No |
|
||||
| format | string, <br>**Available values:** "csv", "json", <br>**Default:** csv | Export format<br>*Enum:* `"csv"`, `"json"` | No |
|
||||
| from_source | string | Filter by feedback source | No |
|
||||
| from_source | string, <br>**Available values:** "admin", "user" | Filter by feedback source | No |
|
||||
| has_comment | boolean | Only include feedback with comments | No |
|
||||
| rating | string | Filter by rating | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Filter by rating | No |
|
||||
| start_date | string | Start date (YYYY-MM-DD) | No |
|
||||
|
||||
#### FeedbackStat
|
||||
@ -20752,7 +20775,7 @@ Icon information model.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
| icon_type | string, <br>**Available values:** "emoji", "image" | | No |
|
||||
| icon_url | string | | No |
|
||||
|
||||
#### IconType
|
||||
@ -23864,7 +23887,7 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| message_id | string | Message ID | Yes |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
@ -23925,7 +23948,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -24058,7 +24081,7 @@ Enum class for model property key.
|
||||
| is_exhausted | boolean | | Yes |
|
||||
| is_unlimited | boolean | | Yes |
|
||||
| next_credit_reset_date | integer | | Yes |
|
||||
| pool_type | string | | Yes |
|
||||
| pool_type | string, <br>**Available values:** "paid", "trial" | | Yes |
|
||||
| quota_limit | integer | Credit limit for the effective pool; -1 means unlimited. | Yes |
|
||||
| quota_used | integer | | Yes |
|
||||
| remaining_credits | integer | Remaining credits; -1 means unlimited. | Yes |
|
||||
@ -26053,7 +26076,7 @@ Model class for provider quota configuration.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -26107,7 +26130,7 @@ Model class for provider quota configuration.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -27096,7 +27119,7 @@ Query parameters for listing snippet published workflows.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| action | string, <br>**Available values:** "complete_task", "disable_current_workspace", "enable_current_workspace", "skip", "uncomplete_task" | State update action<br>*Enum:* `"complete_task"`, `"disable_current_workspace"`, `"enable_current_workspace"`, `"skip"`, `"uncomplete_task"` | Yes |
|
||||
| task_id | string | Task ID for task actions | No |
|
||||
| task_id | string, <br>**Available values:** "home", "integration", "knowledge", "studio" | Task ID for task actions | No |
|
||||
|
||||
#### StepByStepTourStateResponse
|
||||
|
||||
@ -27563,7 +27586,7 @@ Tool label
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| visibility | string | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
| visibility | string, <br>**Available values:** "all_team_members", "only_me" | Visibility for the OAuth credential. Defaults to 'only_me'. | No |
|
||||
|
||||
#### ToolOAuthCustomClientPayload
|
||||
|
||||
@ -27695,7 +27718,7 @@ removes TOOLS_SELECTOR from PluginParameterType
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| type | string | | No |
|
||||
| type | string, <br>**Available values:** "api", "builtin", "mcp", "model", "workflow" | | No |
|
||||
|
||||
#### ToolProviderListResponse
|
||||
|
||||
@ -28313,7 +28336,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -28819,7 +28842,7 @@ can reuse its existing handler.
|
||||
| description | string | | No |
|
||||
| event | string | | No |
|
||||
| icon | string | | No |
|
||||
| mode | string | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | *Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| nodes | [ [WorkflowPlanNodeResponse](#workflowplannoderesponse) ] | | Yes |
|
||||
| start_inputs | [ [WorkflowPlanStartInputResponse](#workflowplanstartinputresponse) ] | | No |
|
||||
| title | string | | No |
|
||||
@ -28834,7 +28857,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateResultEventResponse
|
||||
|
||||
@ -28847,7 +28870,7 @@ can reuse its existing handler.
|
||||
| graph | [WorkflowGraph](#workflowgraph) | | Yes |
|
||||
| icon | string | | No |
|
||||
| message | string | | No |
|
||||
| mode | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "workflow" | | No |
|
||||
|
||||
#### WorkflowGenerateStreamEventResponse
|
||||
|
||||
@ -29147,9 +29170,9 @@ Lifecycle state for an asynchronous archive download request.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| time_range | string | Filter by time range (optional): e.g., 7d (7 days), 4h (4 hours), 30m (30 minutes), 30s (30 seconds). Filters by created_at field. | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunCountResponse
|
||||
|
||||
@ -29221,8 +29244,8 @@ Lifecycle state for an asynchronous archive download request.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| last_id | string | Last run ID for pagination | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page (1-100) | No |
|
||||
| status | string | Workflow run status filter | No |
|
||||
| triggered_from | string | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | Workflow run status filter | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | Filter by trigger source: debugging or app-run. Default: debugging | No |
|
||||
|
||||
#### WorkflowRunNodeExecutionListResponse
|
||||
|
||||
@ -29520,7 +29543,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
|
||||
#### _AccessPolicyList
|
||||
|
||||
@ -29579,7 +29602,7 @@ Workflow tool configuration
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| language | string | Localized policy label language | No |
|
||||
| language | string, <br>**Available values:** "en", "ja", "zh" | Localized policy label language | No |
|
||||
| limit | integer | | No |
|
||||
| page | integer | | No |
|
||||
| reverse | boolean | | No |
|
||||
|
||||
@ -2905,7 +2905,7 @@ Execute a workflow. Cannot be executed without a published workflow.
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)<br>**text/event-stream**: string<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Invalid parameter value. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `trigger_workflow_service_mode_unavailable` : Trigger-entry workflows cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | Workflow not found | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
@ -2981,7 +2981,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p
|
||||
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [WorkflowBlockingResponse](#workflowblockingresponse)<br>**text/event-stream**: string<br> |
|
||||
| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | |
|
||||
| 401 | Unauthorized - invalid API token | |
|
||||
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | |
|
||||
| 403 | - `forbidden` : Token scope, app, or workspace access denied. - `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. - `trigger_workflow_service_mode_unavailable` : The selected workflow version uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP. | |
|
||||
| 404 | `not_found` : Workflow not found. | |
|
||||
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
|
||||
| 500 | `internal_server_error` : Internal server error. | |
|
||||
@ -3281,7 +3281,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
#### ChatRequestPayloadWithUser
|
||||
@ -3293,7 +3293,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or question content. | Yes |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
| workflow_id | string | Published workflow version ID to execute for advanced chat. If omitted, the app's current published workflow is used. | No |
|
||||
|
||||
@ -3366,7 +3366,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### CompletionRequestPayloadWithUser
|
||||
|
||||
@ -3375,7 +3375,7 @@ Public pause reason emitted by a blocking Chatflow execution.
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for multimodal understanding, including images, documents, audio, and video. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Values for app-defined variables. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected variable names and types. | Yes |
|
||||
| query | string | User input or prompt content. | No |
|
||||
| response_mode | string | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### Condition
|
||||
@ -3491,7 +3491,7 @@ Enum class for custom configuration status.
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | Yes |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
| provider | string, <br>**Available values:** "external", "vendor", <br>**Default:** vendor | Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.<br>*Enum:* `"external"`, `"vendor"` | No |
|
||||
@ -3733,7 +3733,7 @@ Enum class for custom configuration status.
|
||||
| external_knowledge_api_id | string | ID of the external knowledge API. | No |
|
||||
| external_knowledge_id | string | ID of the external knowledge base. | No |
|
||||
| external_retrieval_model | object | Retrieval settings for external knowledge bases. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. | No |
|
||||
| name | string | Name of the knowledge base. | No |
|
||||
| partial_member_list | [ object ] | List of team members with access when `permission` is `partial_members`. | No |
|
||||
| permission | [PermissionEnum](#permissionenum) | Controls who can access this knowledge base. `only_me` restricts access to the creator, `all_team_members` grants workspace-wide access, and `partial_members` grants access to specified members. | No |
|
||||
@ -3861,7 +3861,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| keyword | string | Search keyword to filter by document name. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. Server caps at `100`. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number to retrieve. | No |
|
||||
| status | string | Filter by display status. | No |
|
||||
| status | string, <br>**Available values:** "archived", "available", "disabled", "error", "indexing", "paused", "queuing" | Filter by display status. | No |
|
||||
|
||||
#### DocumentListResponse
|
||||
|
||||
@ -3959,7 +3959,7 @@ Request payload for bulk downloading documents as a zip archive.
|
||||
| doc_language | string, <br>**Default:** English | Language of the document for processing optimization. | No |
|
||||
| embedding_model | string | Embedding model name. Use the `model` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| embedding_model_provider | string | Embedding model provider. Use the `provider` field from [Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`. | No |
|
||||
| indexing_technique | string | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| indexing_technique | string, <br>**Available values:** "economy", "high_quality" | `high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. Required when adding the first document to a knowledge base; subsequent documents inherit the knowledge base's indexing technique if omitted. | No |
|
||||
| name | string | Document name. | Yes |
|
||||
| original_document_id | string | Original document ID for replacement. | No |
|
||||
| process_rule | [ProcessRule](#processrule) | Processing rules for chunking. | No |
|
||||
@ -5249,14 +5249,14 @@ Model class for i18n object.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFeedbackPayloadWithUser
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### MessageFile
|
||||
@ -5336,7 +5336,7 @@ Metadata Filtering Condition.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conditions | [ [Condition](#condition) ] | List of metadata conditions to evaluate. | No |
|
||||
| logical_operator | string | How to combine multiple conditions. | No |
|
||||
| logical_operator | string, <br>**Available values:** "and", "or" | How to combine multiple conditions. | No |
|
||||
|
||||
#### MetadataOperationData
|
||||
|
||||
@ -5570,7 +5570,7 @@ Model class for provider with models response.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| metadata_filtering_conditions | [MetadataFilteringCondition](#metadatafilteringcondition) | Restrict retrieval to chunks whose document metadata matches the given conditions. Conditions are evaluated server-side against document metadata fields. | No |
|
||||
| reranking_enable | boolean | Whether reranking is enabled. | Yes |
|
||||
| reranking_mode | string | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_mode | string, <br>**Available values:** "reranking_model", "weighted_score" | Reranking mode. Required when `reranking_enable` is `true`. | No |
|
||||
| reranking_model | [RerankingModel](#rerankingmodel) | Reranking model configuration. | No |
|
||||
| score_threshold | number | Minimum similarity score for results. Only effective when score threshold filtering is enabled. | No |
|
||||
| score_threshold_enabled | boolean | Whether score threshold filtering is enabled. | Yes |
|
||||
@ -5604,7 +5604,7 @@ Model class for provider with models response.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| parent_mode | string | Parent-child segmentation mode. | No |
|
||||
| parent_mode | string, <br>**Available values:** "full-doc", "paragraph" | Parent-child segmentation mode. | No |
|
||||
| pre_processing_rules | [ [PreProcessingRule](#preprocessingrule) ] | Pre-processing rules to apply before segmentation. | No |
|
||||
| segmentation | [Segmentation](#segmentation) | Parent chunk segmentation settings. | No |
|
||||
| subchunk_segmentation | [Segmentation](#segmentation) | Child chunk segmentation settings. | No |
|
||||
@ -5935,7 +5935,7 @@ in form definition, or a variable while the workflow is running.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| keyword_setting | [WeightKeywordSetting](#weightkeywordsetting) | Keyword search weight settings. | No |
|
||||
| vector_setting | [WeightVectorSetting](#weightvectorsetting) | Semantic search weight settings. | No |
|
||||
| weight_type | string | Strategy for balancing semantic and keyword search weights. | No |
|
||||
| weight_type | string, <br>**Available values:** "customized", "keyword_first", "semantic_first" | Strategy for balancing semantic and keyword search weights. | No |
|
||||
|
||||
#### WeightVectorSetting
|
||||
|
||||
@ -6018,7 +6018,7 @@ Blocking workflow response for a finished or paused execution.
|
||||
| keyword | string | Keyword to search in logs. | No |
|
||||
| limit | integer, <br>**Default:** 20 | Number of items per page. | No |
|
||||
| page | integer, <br>**Default:** 1 | Page number for pagination. | No |
|
||||
| status | string | Filter by execution status. | No |
|
||||
| status | string, <br>**Available values:** "failed", "stopped", "succeeded" | Filter by execution status. | No |
|
||||
|
||||
#### WorkflowPauseReasonResponse
|
||||
|
||||
@ -6087,7 +6087,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
|
||||
#### WorkflowRunPayloadWithUser
|
||||
|
||||
@ -6095,7 +6095,7 @@ Public pause reason emitted by a blocking Workflow execution.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| files | [ object<br>object<br>object<br>object ] | File list for workflow system file inputs. Available when file upload is enabled for the workflow. To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned `id` as `upload_file_id` with `transfer_method: local_file`. | No |
|
||||
| inputs | object | Key-value pairs for workflow input variables. Values for file-type variables should be arrays of file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the `user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) response to discover the variable names and types expected by your app. | Yes |
|
||||
| response_mode | string | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. When omitted, the request runs in blocking mode. | No |
|
||||
| user | string | User identifier, unique within the application. This identifier scopes data access; resources created with one `user` value are only visible when queried with the same `user` value. | Yes |
|
||||
|
||||
#### WorkflowRunResponse
|
||||
|
||||
@ -1019,7 +1019,7 @@ Button styles for user actions.
|
||||
| inputs | object | Input variables for the chat | Yes |
|
||||
| parent_message_id | string | Parent message ID | No |
|
||||
| query | string | User query/message | Yes |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### CompletionMessagePayload
|
||||
@ -1029,7 +1029,7 @@ Button styles for user actions.
|
||||
| files | [ object ] | Files to be processed | No |
|
||||
| inputs | object | Input variables for the completion | Yes |
|
||||
| query | string | Query text for completion | No |
|
||||
| response_mode | string | Response mode: blocking or streaming | No |
|
||||
| response_mode | string, <br>**Available values:** "blocking", "streaming" | Response mode: blocking or streaming | No |
|
||||
| retriever_from | string, <br>**Default:** web_app | Source of retriever | No |
|
||||
|
||||
#### ConversationInfiniteScrollPagination
|
||||
@ -1322,7 +1322,7 @@ Parsed multipart form fields for HITL uploads.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| content | string | Optional text feedback providing additional detail. | No |
|
||||
| rating | string | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
| rating | string, <br>**Available values:** "dislike", "like" | Feedback rating. Set to `null` to revoke previously submitted feedback. | No |
|
||||
|
||||
#### MessageFile
|
||||
|
||||
|
||||
@ -31,6 +31,14 @@ class SQLAlchemyAccountRepository(AccountRepository):
|
||||
account = session.get(Account, account_id)
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None:
|
||||
with self._session_factory() as session:
|
||||
account = session.scalar(select(Account).where(Account.email == email).limit(1))
|
||||
if account is None and email != email.lower():
|
||||
account = session.scalar(select(Account).where(Account.email == email.lower()).limit(1))
|
||||
return self._to_snapshot(account) if account is not None else None
|
||||
|
||||
@override
|
||||
def get_credentials(self, account_id: str) -> AccountCredentials | None:
|
||||
with self._session_factory() as session:
|
||||
|
||||
189
api/repositories/step_by_step_tour_repository.py
Normal file
189
api/repositories/step_by_step_tour_repository.py
Normal file
@ -0,0 +1,189 @@
|
||||
"""SQLAlchemy repository for account Step-by-step Tour state."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Protocol, override, runtime_checkable
|
||||
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from services.entities.onboarding_entities import StepByStepTourState
|
||||
from services.step_by_step_tour_service import StepByStepTourStateRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MYSQL_RETRYABLE_LOCK_ERRNOS = frozenset({1205, 1213})
|
||||
_MAX_LOCK_ATTEMPTS = 3
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class _ErrorWithErrno(Protocol):
|
||||
@property
|
||||
def errno(self) -> object: ...
|
||||
|
||||
|
||||
class SQLAlchemyStepByStepTourStateRepository(StepByStepTourStateRepository):
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def get(self, account_id: str) -> StepByStepTourState | None:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
return self._to_state(model) if model is not None else None
|
||||
|
||||
@override
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
"""Create state with its first workspace, or atomically claim a legacy empty state."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._initialize_once(account_id, first_workspace_id),
|
||||
)
|
||||
|
||||
def _initialize_once(self, account_id: str, first_workspace_id: str) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(
|
||||
account_id=account_id,
|
||||
first_workspace_id=first_workspace_id,
|
||||
)
|
||||
session.add(model)
|
||||
try:
|
||||
session.commit()
|
||||
except IntegrityError:
|
||||
# A concurrent request inserted the account-owned row first.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
if model.first_workspace_id is None:
|
||||
stmt = (
|
||||
update(AccountStepByStepTourState)
|
||||
.where(
|
||||
AccountStepByStepTourState.account_id == account_id,
|
||||
AccountStepByStepTourState.first_workspace_id.is_(None),
|
||||
)
|
||||
.values(first_workspace_id=first_workspace_id)
|
||||
.execution_options(synchronize_session=False)
|
||||
)
|
||||
session.execute(stmt)
|
||||
session.commit()
|
||||
# A competing conditional update may have won while this request waited.
|
||||
session.refresh(model)
|
||||
|
||||
return self._to_state(model)
|
||||
|
||||
@override
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
"""Lock, create if needed, mutate, and persist account state in one transaction."""
|
||||
return self._run_with_lock_retry(
|
||||
lambda: self._mutate_once(account_id, mutation),
|
||||
)
|
||||
|
||||
def _mutate_once(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState:
|
||||
with self._session_factory() as session:
|
||||
# Probe without a locking read so a missing MySQL unique key does not
|
||||
# acquire a gap/next-key lock before the insert.
|
||||
model = self._get_model(account_id, session=session)
|
||||
if model is None:
|
||||
model = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(model)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# A concurrent mutation created the row. Start a new transaction,
|
||||
# lock its committed state, and replay the pure mutation on it.
|
||||
session.rollback()
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise
|
||||
else:
|
||||
model = self._get_model(account_id, session=session, lock_for_update=True)
|
||||
if model is None:
|
||||
raise RuntimeError("Step-by-step Tour state disappeared while acquiring its lock")
|
||||
|
||||
state = mutation(self._to_state(model))
|
||||
if state.account_id != account_id:
|
||||
raise ValueError("Step-by-step Tour mutation cannot change account ownership")
|
||||
# first_workspace_id is write-once and owned exclusively by initialize().
|
||||
model.skipped = state.skipped
|
||||
model.completed_task_ids = list(state.completed_task_ids)
|
||||
model.manually_enabled_workspace_ids = list(state.manually_enabled_workspace_ids)
|
||||
model.manually_disabled_workspace_ids = list(state.manually_disabled_workspace_ids)
|
||||
session.commit()
|
||||
session.refresh(model)
|
||||
return self._to_state(model)
|
||||
|
||||
@staticmethod
|
||||
def _run_with_lock_retry[T](operation: Callable[[], T]) -> T:
|
||||
for attempt in range(1, _MAX_LOCK_ATTEMPTS):
|
||||
try:
|
||||
return operation()
|
||||
except OperationalError as exc:
|
||||
if not _is_retryable_mysql_lock_error(exc):
|
||||
raise
|
||||
logger.warning(
|
||||
"Retrying Step-by-step Tour transaction after MySQL lock failure (attempt %s/%s)",
|
||||
attempt,
|
||||
_MAX_LOCK_ATTEMPTS,
|
||||
)
|
||||
return operation()
|
||||
|
||||
@staticmethod
|
||||
def _get_model(
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session,
|
||||
lock_for_update: bool = False,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
if lock_for_update:
|
||||
stmt = stmt.with_for_update().execution_options(populate_existing=True)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@staticmethod
|
||||
def _to_state(model: AccountStepByStepTourState) -> StepByStepTourState:
|
||||
return StepByStepTourState(
|
||||
account_id=model.account_id,
|
||||
first_workspace_id=model.first_workspace_id,
|
||||
skipped=model.skipped,
|
||||
completed_task_ids=tuple(model.completed_task_ids),
|
||||
manually_enabled_workspace_ids=tuple(model.manually_enabled_workspace_ids),
|
||||
manually_disabled_workspace_ids=tuple(model.manually_disabled_workspace_ids),
|
||||
updated_at=model.updated_at,
|
||||
)
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error(exc: OperationalError) -> bool:
|
||||
orig = exc.orig
|
||||
if isinstance(orig, _ErrorWithErrno) and _is_retryable_mysql_lock_error_code(orig.errno):
|
||||
return True
|
||||
if not isinstance(orig, BaseException) or not orig.args:
|
||||
return False
|
||||
return _is_retryable_mysql_lock_error_code(orig.args[0])
|
||||
|
||||
|
||||
def _is_retryable_mysql_lock_error_code(candidate: object) -> bool:
|
||||
if isinstance(candidate, bool):
|
||||
return False
|
||||
if isinstance(candidate, int):
|
||||
code = candidate
|
||||
elif isinstance(candidate, str) and candidate.isdecimal():
|
||||
code = int(candidate)
|
||||
else:
|
||||
return False
|
||||
return code in _MYSQL_RETRYABLE_LOCK_ERRNOS
|
||||
230
api/services/account_email_registration_adapters.py
Normal file
230
api/services/account_email_registration_adapters.py
Normal file
@ -0,0 +1,230 @@
|
||||
"""Infrastructure adapters for account email registration."""
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
from typing import override
|
||||
|
||||
from redis import RedisError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from extensions.ext_redis import RedisClientWrapper
|
||||
from libs.helper import RateLimiter, TokenManager
|
||||
from models.account import Account
|
||||
from services.account_email_registration_service import (
|
||||
AccountRegistrationGateway,
|
||||
AccountRegistrationPolicyGateway,
|
||||
EmailRegistrationCodeGenerator,
|
||||
EmailRegistrationNotificationGateway,
|
||||
EmailRegistrationSecurityGateway,
|
||||
EmailRegistrationSendLimiter,
|
||||
EmailRegistrationTokenGateway,
|
||||
)
|
||||
from services.account_errors import (
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
AccountNormalizedEmailAlreadyInUseError,
|
||||
EmailRegistrationSeatsLimitError,
|
||||
)
|
||||
from services.account_service import AccountService
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountSessionTokens,
|
||||
)
|
||||
from services.errors.account import (
|
||||
AccountNormalizedEmailAlreadyInUseError as AccountNormalizedEmailAlreadyInUseServiceError,
|
||||
)
|
||||
from services.errors.account import AccountRegisterError, EmailDomainSuspendedError, SeatsLimitExceededError
|
||||
from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TokenManagerEmailRegistrationTokenGateway(EmailRegistrationTokenGateway):
|
||||
@override
|
||||
def get(self, token: str) -> AccountEmailRegistrationToken | None:
|
||||
payload = TokenManager.get_token_data(token, "email_register")
|
||||
if payload is None:
|
||||
return None
|
||||
email = payload.get("email")
|
||||
code = payload.get("code")
|
||||
phase_value = payload.get("phase")
|
||||
if not isinstance(email, str) or not isinstance(code, str):
|
||||
return None
|
||||
if phase_value is None:
|
||||
phase = None
|
||||
else:
|
||||
try:
|
||||
phase = AccountEmailRegistrationPhase(phase_value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return AccountEmailRegistrationToken(email=email, code=code, phase=phase)
|
||||
|
||||
@override
|
||||
def issue(self, token_data: AccountEmailRegistrationToken) -> str:
|
||||
additional_data = {"code": token_data.code}
|
||||
if token_data.phase is not None:
|
||||
additional_data["phase"] = token_data.phase.value
|
||||
return TokenManager.generate_token(
|
||||
email=token_data.email,
|
||||
token_type="email_register",
|
||||
additional_data=additional_data,
|
||||
)
|
||||
|
||||
@override
|
||||
def revoke(self, token: str) -> None:
|
||||
TokenManager.revoke_token(token, "email_register")
|
||||
|
||||
|
||||
class SecureEmailRegistrationCodeGenerator(EmailRegistrationCodeGenerator):
|
||||
@override
|
||||
def generate(self) -> str:
|
||||
return "".join(str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6))
|
||||
|
||||
|
||||
class CeleryEmailRegistrationNotificationGateway(EmailRegistrationNotificationGateway):
|
||||
@override
|
||||
def send_code(self, *, email: str, code: str, language: str) -> None:
|
||||
send_email_register_mail_task.delay(language=language, to=email, code=code)
|
||||
|
||||
@override
|
||||
def send_account_exists(self, *, email: str, account_name: str, language: str) -> None:
|
||||
send_email_register_mail_task_when_account_exist.delay(
|
||||
language=language,
|
||||
to=email,
|
||||
account_name=account_name,
|
||||
)
|
||||
|
||||
|
||||
class RateLimiterEmailRegistrationSendLimiter(EmailRegistrationSendLimiter):
|
||||
def __init__(self, *, rate_limiter: RateLimiter) -> None:
|
||||
self._rate_limiter = rate_limiter
|
||||
|
||||
@override
|
||||
def is_limited(self, email: str) -> bool:
|
||||
return self._rate_limiter.is_rate_limited(email)
|
||||
|
||||
@override
|
||||
def record(self, email: str) -> None:
|
||||
self._rate_limiter.increment_rate_limit(email)
|
||||
|
||||
@property
|
||||
@override
|
||||
def retry_after_minutes(self) -> int:
|
||||
return int(self._rate_limiter.time_window / 60)
|
||||
|
||||
|
||||
class RedisEmailRegistrationSecurityGateway(EmailRegistrationSecurityGateway):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
redis: RedisClientWrapper,
|
||||
verification_failure_limit: int,
|
||||
verification_lockout_duration: int,
|
||||
) -> None:
|
||||
self._redis = redis
|
||||
self._verification_failure_limit = verification_failure_limit
|
||||
self._verification_lockout_duration = verification_lockout_duration
|
||||
|
||||
@override
|
||||
def is_ip_limited(self, ip_address: str) -> bool:
|
||||
return AccountService.is_email_send_ip_limit(ip_address) is True
|
||||
|
||||
@override
|
||||
def is_verification_limited(self, email: str) -> bool:
|
||||
try:
|
||||
count = self._redis.get(self._verification_key(email))
|
||||
return count is not None and int(count) > self._verification_failure_limit
|
||||
except RedisError:
|
||||
logger.warning("Failed to read email-registration verification limit", exc_info=True)
|
||||
return False
|
||||
|
||||
@override
|
||||
def record_verification_failure(self, email: str) -> None:
|
||||
try:
|
||||
key = self._verification_key(email)
|
||||
count = int(self._redis.get(key) or 0) + 1
|
||||
self._redis.setex(key, self._verification_lockout_duration, count)
|
||||
except RedisError:
|
||||
logger.warning("Failed to record email-registration verification failure", exc_info=True)
|
||||
return None
|
||||
|
||||
@override
|
||||
def reset_verification_failures(self, email: str) -> None:
|
||||
try:
|
||||
self._redis.delete(self._verification_key(email))
|
||||
except RedisError:
|
||||
logger.warning("Failed to reset email-registration verification failures", exc_info=True)
|
||||
return None
|
||||
|
||||
@override
|
||||
def reset_login_failures(self, email: str) -> None:
|
||||
AccountService.reset_login_error_rate_limit(email)
|
||||
|
||||
@staticmethod
|
||||
def _verification_key(email: str) -> str:
|
||||
return f"email_register_error_rate_limit:{email}"
|
||||
|
||||
|
||||
class BillingAccountRegistrationPolicyGateway(AccountRegistrationPolicyGateway):
|
||||
def __init__(self, *, enabled: bool) -> None:
|
||||
self._enabled = enabled
|
||||
|
||||
@override
|
||||
def get_freeze_type(self, email: str) -> str | None:
|
||||
if not self._enabled:
|
||||
return None
|
||||
return BillingService.get_email_freeze_type(email)
|
||||
|
||||
|
||||
class AccountServiceRegistrationGateway(AccountRegistrationGateway):
|
||||
"""Compatibility adapter around account provisioning and login internals."""
|
||||
|
||||
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
interface_language: str,
|
||||
timezone: str | None,
|
||||
ip_address: str,
|
||||
) -> str:
|
||||
with self._session_factory() as session:
|
||||
try:
|
||||
account = AccountService.create_account_and_tenant(
|
||||
email=email,
|
||||
name=email,
|
||||
password=password,
|
||||
interface_language=interface_language,
|
||||
timezone=timezone,
|
||||
ip_address=ip_address,
|
||||
check_normalized_email=True,
|
||||
session=session,
|
||||
)
|
||||
except SeatsLimitExceededError as exc:
|
||||
raise EmailRegistrationSeatsLimitError from exc
|
||||
except EmailDomainSuspendedError as exc:
|
||||
raise AccountEmailDomainSuspendedError from exc
|
||||
except AccountNormalizedEmailAlreadyInUseServiceError as exc:
|
||||
raise AccountNormalizedEmailAlreadyInUseError from exc
|
||||
except AccountRegisterError as exc:
|
||||
raise AccountEmailFrozenError from exc
|
||||
return account.id
|
||||
|
||||
@override
|
||||
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens:
|
||||
with self._session_factory() as session:
|
||||
account = session.get(Account, account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("newly registered account no longer exists")
|
||||
token_pair = AccountService.login(account=account, session=session, ip_address=ip_address)
|
||||
return AccountSessionTokens(
|
||||
access_token=token_pair.access_token,
|
||||
refresh_token=token_pair.refresh_token,
|
||||
csrf_token=token_pair.csrf_token,
|
||||
)
|
||||
207
api/services/account_email_registration_service.py
Normal file
207
api/services/account_email_registration_service.py
Normal file
@ -0,0 +1,207 @@
|
||||
"""Application service for the account email-registration use case."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from constants.languages import get_valid_language, languages
|
||||
from services.account_errors import (
|
||||
AccountEmailAlreadyInUseError,
|
||||
AccountEmailDomainSuspendedError,
|
||||
AccountEmailFrozenError,
|
||||
EmailRegistrationPasswordMismatchError,
|
||||
EmailRegistrationSendIPLimitedError,
|
||||
EmailRegistrationSendRateLimitError,
|
||||
EmailRegistrationVerificationLimitError,
|
||||
InvalidEmailRegistrationAddressError,
|
||||
InvalidEmailRegistrationCodeError,
|
||||
InvalidEmailRegistrationTokenError,
|
||||
)
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.account_entities import (
|
||||
AccountEmailRegistrationPhase,
|
||||
AccountEmailRegistrationToken,
|
||||
AccountEmailRegistrationVerification,
|
||||
AccountSessionTokens,
|
||||
)
|
||||
|
||||
|
||||
class EmailRegistrationTokenGateway(Protocol):
|
||||
def get(self, token: str) -> AccountEmailRegistrationToken | None: ...
|
||||
|
||||
def issue(self, token_data: AccountEmailRegistrationToken) -> str: ...
|
||||
|
||||
def revoke(self, token: str) -> None: ...
|
||||
|
||||
|
||||
class EmailRegistrationCodeGenerator(Protocol):
|
||||
def generate(self) -> str: ...
|
||||
|
||||
|
||||
class EmailRegistrationNotificationGateway(Protocol):
|
||||
def send_code(self, *, email: str, code: str, language: str) -> None: ...
|
||||
|
||||
def send_account_exists(self, *, email: str, account_name: str, language: str) -> None: ...
|
||||
|
||||
|
||||
class EmailRegistrationSendLimiter(Protocol):
|
||||
def is_limited(self, email: str) -> bool: ...
|
||||
|
||||
def record(self, email: str) -> None: ...
|
||||
|
||||
@property
|
||||
def retry_after_minutes(self) -> int: ...
|
||||
|
||||
|
||||
class EmailRegistrationSecurityGateway(Protocol):
|
||||
def is_ip_limited(self, ip_address: str) -> bool: ...
|
||||
|
||||
def is_verification_limited(self, email: str) -> bool: ...
|
||||
|
||||
def record_verification_failure(self, email: str) -> None: ...
|
||||
|
||||
def reset_verification_failures(self, email: str) -> None: ...
|
||||
|
||||
def reset_login_failures(self, email: str) -> None: ...
|
||||
|
||||
|
||||
class AccountRegistrationPolicyGateway(Protocol):
|
||||
def get_freeze_type(self, email: str) -> str | None: ...
|
||||
|
||||
|
||||
class AccountRegistrationGateway(Protocol):
|
||||
def create(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
password: str,
|
||||
interface_language: str,
|
||||
timezone: str | None,
|
||||
ip_address: str,
|
||||
) -> str: ...
|
||||
|
||||
def login(self, account_id: str, *, ip_address: str) -> AccountSessionTokens: ...
|
||||
|
||||
|
||||
class AccountEmailRegistrationService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
accounts: AccountRepository,
|
||||
tokens: EmailRegistrationTokenGateway,
|
||||
codes: EmailRegistrationCodeGenerator,
|
||||
notifications: EmailRegistrationNotificationGateway,
|
||||
send_limits: EmailRegistrationSendLimiter,
|
||||
security: EmailRegistrationSecurityGateway,
|
||||
account_policy: AccountRegistrationPolicyGateway,
|
||||
registration: AccountRegistrationGateway,
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._tokens = tokens
|
||||
self._codes = codes
|
||||
self._notifications = notifications
|
||||
self._send_limits = send_limits
|
||||
self._security = security
|
||||
self._account_policy = account_policy
|
||||
self._registration = registration
|
||||
|
||||
def send_code(
|
||||
self,
|
||||
*,
|
||||
remote_ip: str,
|
||||
requested_email: str,
|
||||
requested_language: str | None,
|
||||
) -> str:
|
||||
if self._security.is_ip_limited(remote_ip):
|
||||
raise EmailRegistrationSendIPLimitedError
|
||||
|
||||
normalized_email = requested_email.lower()
|
||||
self._ensure_email_allowed(normalized_email)
|
||||
account = self._accounts.find_by_email(requested_email)
|
||||
delivery_email = account.email if account is not None else normalized_email
|
||||
if self._send_limits.is_limited(delivery_email):
|
||||
raise EmailRegistrationSendRateLimitError(self._send_limits.retry_after_minutes)
|
||||
|
||||
language = requested_language if requested_language is not None and requested_language in languages else "en-US"
|
||||
code = self._codes.generate()
|
||||
token = self._tokens.issue(AccountEmailRegistrationToken(email=delivery_email, code=code))
|
||||
if account is None:
|
||||
self._notifications.send_code(email=delivery_email, code=code, language=language)
|
||||
else:
|
||||
self._notifications.send_account_exists(
|
||||
email=delivery_email,
|
||||
account_name=account.name,
|
||||
language=language,
|
||||
)
|
||||
self._send_limits.record(delivery_email)
|
||||
return token
|
||||
|
||||
def verify_code(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
code: str,
|
||||
token: str,
|
||||
) -> AccountEmailRegistrationVerification:
|
||||
normalized_email = email.lower()
|
||||
if self._security.is_verification_limited(normalized_email):
|
||||
raise EmailRegistrationVerificationLimitError
|
||||
|
||||
token_data = self._tokens.get(token)
|
||||
if token_data is None:
|
||||
raise InvalidEmailRegistrationTokenError
|
||||
normalized_token_email = token_data.email.lower()
|
||||
if normalized_email != normalized_token_email:
|
||||
raise InvalidEmailRegistrationAddressError
|
||||
if code != token_data.code:
|
||||
self._security.record_verification_failure(normalized_email)
|
||||
raise InvalidEmailRegistrationCodeError
|
||||
|
||||
self._tokens.revoke(token)
|
||||
verified_token = self._tokens.issue(
|
||||
AccountEmailRegistrationToken(
|
||||
email=normalized_email,
|
||||
code=code,
|
||||
phase=AccountEmailRegistrationPhase.REGISTER,
|
||||
)
|
||||
)
|
||||
self._security.reset_verification_failures(normalized_email)
|
||||
return AccountEmailRegistrationVerification(email=normalized_token_email, token=verified_token)
|
||||
|
||||
def register(
|
||||
self,
|
||||
*,
|
||||
remote_ip: str,
|
||||
token: str,
|
||||
new_password: str,
|
||||
password_confirm: str,
|
||||
language: str | None,
|
||||
timezone: str | None,
|
||||
) -> AccountSessionTokens:
|
||||
if new_password != password_confirm:
|
||||
raise EmailRegistrationPasswordMismatchError
|
||||
|
||||
token_data = self._tokens.get(token)
|
||||
if token_data is None or token_data.phase != AccountEmailRegistrationPhase.REGISTER:
|
||||
raise InvalidEmailRegistrationTokenError
|
||||
self._tokens.revoke(token)
|
||||
|
||||
normalized_email = token_data.email.lower()
|
||||
if self._accounts.find_by_email(token_data.email) is not None:
|
||||
raise AccountEmailAlreadyInUseError
|
||||
|
||||
account_id = self._registration.create(
|
||||
email=normalized_email,
|
||||
password=password_confirm,
|
||||
interface_language=get_valid_language(language),
|
||||
timezone=timezone,
|
||||
ip_address=remote_ip,
|
||||
)
|
||||
tokens = self._registration.login(account_id, ip_address=remote_ip)
|
||||
self._security.reset_login_failures(normalized_email)
|
||||
return tokens
|
||||
|
||||
def _ensure_email_allowed(self, email: str) -> None:
|
||||
freeze_type = self._account_policy.get_freeze_type(email)
|
||||
if freeze_type == "email_domain_suspended":
|
||||
raise AccountEmailDomainSuspendedError
|
||||
if freeze_type:
|
||||
raise AccountEmailFrozenError
|
||||
@ -85,6 +85,46 @@ class AccountEmailAlreadyInUseError(AccountApplicationError):
|
||||
"""The target email already belongs to an account."""
|
||||
|
||||
|
||||
class AccountNormalizedEmailAlreadyInUseError(AccountEmailAlreadyInUseError):
|
||||
"""A normalized equivalent of the target email already belongs to an account."""
|
||||
|
||||
|
||||
class EmailRegistrationSendIPLimitedError(AccountApplicationError):
|
||||
"""The caller IP exceeded the registration-email send policy."""
|
||||
|
||||
|
||||
class EmailRegistrationSendRateLimitError(AccountApplicationError):
|
||||
"""Too many registration messages were requested for the address."""
|
||||
|
||||
def __init__(self, retry_after_minutes: int) -> None:
|
||||
super().__init__(retry_after_minutes)
|
||||
self.retry_after_minutes = retry_after_minutes
|
||||
|
||||
|
||||
class EmailRegistrationVerificationLimitError(AccountApplicationError):
|
||||
"""Too many invalid registration-code attempts were made."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationTokenError(AccountApplicationError):
|
||||
"""The registration token is absent, malformed, or in the wrong phase."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationAddressError(AccountApplicationError):
|
||||
"""The request address does not match the registration token."""
|
||||
|
||||
|
||||
class InvalidEmailRegistrationCodeError(AccountApplicationError):
|
||||
"""The verification code does not match the registration token."""
|
||||
|
||||
|
||||
class EmailRegistrationPasswordMismatchError(AccountApplicationError):
|
||||
"""The registration password confirmation does not match."""
|
||||
|
||||
|
||||
class EmailRegistrationSeatsLimitError(AccountApplicationError):
|
||||
"""The deployment has no licensed seat available for another account."""
|
||||
|
||||
|
||||
class EducationDiscountPausedError(AccountApplicationError):
|
||||
"""Education discount activation is temporarily paused."""
|
||||
|
||||
|
||||
@ -19,6 +19,8 @@ from services.entities.account_entities import (
|
||||
class AccountRepository(Protocol):
|
||||
def get(self, account_id: str) -> AccountSnapshot | None: ...
|
||||
|
||||
def find_by_email(self, email: str) -> AccountSnapshot | None: ...
|
||||
|
||||
def get_credentials(self, account_id: str) -> AccountCredentials | None: ...
|
||||
|
||||
def update_profile(self, account_id: str, changes: AccountProfileChanges) -> AccountSnapshot | None: ...
|
||||
|
||||
@ -93,7 +93,6 @@ from tasks.mail_owner_transfer_task import (
|
||||
send_old_owner_transfer_notify_email_task,
|
||||
send_owner_transfer_confirm_task,
|
||||
)
|
||||
from tasks.mail_register_task import send_email_register_mail_task, send_email_register_mail_task_when_account_exist
|
||||
from tasks.mail_reset_password_task import (
|
||||
send_reset_password_mail_task,
|
||||
send_reset_password_mail_task_when_account_not_exist,
|
||||
@ -157,7 +156,6 @@ class AccountService:
|
||||
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
|
||||
|
||||
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
|
||||
email_code_login_rate_limiter = RateLimiter(
|
||||
prefix="email_code_login_rate_limit", max_attempts=3, time_window=300 * 1
|
||||
)
|
||||
@ -168,7 +166,16 @@ class AccountService:
|
||||
FORGOT_PASSWORD_MAX_ERROR_LIMITS = 5
|
||||
CHANGE_EMAIL_MAX_ERROR_LIMITS = 5
|
||||
OWNER_TRANSFER_MAX_ERROR_LIMITS = 5
|
||||
EMAIL_REGISTER_MAX_ERROR_LIMITS = 5
|
||||
|
||||
@staticmethod
|
||||
def _resolve_role_id_by_tag(tenant_id: str, account_id: str, tag: str) -> str:
|
||||
options = ListOption(page_number=1, results_per_page=100)
|
||||
roles = RBACService.Roles.list(tenant_id, account_id, options=options).data
|
||||
for rbac_role in roles:
|
||||
if rbac_role.is_builtin and rbac_role.category == "global_system_default" and rbac_role.role_tag == tag:
|
||||
return str(rbac_role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for tag {tag!r} in tenant {tenant_id}")
|
||||
|
||||
@staticmethod
|
||||
def _resolve_legacy_role_id(tenant_id: str, account_id: str, role: TenantAccountRole) -> str:
|
||||
@ -177,9 +184,6 @@ class AccountService:
|
||||
Looks up the builtin RBAC role whose tag matches the legacy role name
|
||||
(e.g. ``TenantAccountRole.ADMIN`` → builtin role with tag ``"admin"``).
|
||||
"""
|
||||
options = ListOption(page_number=1, results_per_page=100)
|
||||
roles = RBACService.Roles.list(tenant_id, account_id, options=options).data
|
||||
|
||||
expected_tag = {
|
||||
TenantAccountRole.OWNER: "owner",
|
||||
TenantAccountRole.ADMIN: "admin",
|
||||
@ -187,15 +191,7 @@ class AccountService:
|
||||
TenantAccountRole.NORMAL: "normal",
|
||||
TenantAccountRole.DATASET_OPERATOR: "dataset_operator",
|
||||
}[role]
|
||||
for rbac_role in roles:
|
||||
if (
|
||||
rbac_role.is_builtin
|
||||
and rbac_role.category == "global_system_default"
|
||||
and rbac_role.role_tag == expected_tag
|
||||
):
|
||||
return str(rbac_role.id)
|
||||
|
||||
raise ValueError(f"Builtin RBAC role not found for {role.value} in tenant {tenant_id}")
|
||||
return AccountService._resolve_role_id_by_tag(tenant_id, account_id, expected_tag)
|
||||
|
||||
@staticmethod
|
||||
def get_workspace_permission_keys(tenant_id: str, account_id: str, *, session: Session) -> set[str]:
|
||||
@ -680,40 +676,6 @@ class AccountService:
|
||||
cls.reset_password_rate_limiter.increment_rate_limit(account_email)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def send_email_register_email(
|
||||
cls,
|
||||
account: Account | None = None,
|
||||
email: str | None = None,
|
||||
language: str = "en-US",
|
||||
):
|
||||
account_email = account.email if account else email
|
||||
if account_email is None:
|
||||
raise ValueError("Email must be provided.")
|
||||
|
||||
if cls.email_register_rate_limiter.is_rate_limited(account_email):
|
||||
from controllers.console.auth.error import EmailRegisterRateLimitExceededError
|
||||
|
||||
raise EmailRegisterRateLimitExceededError(int(cls.email_register_rate_limiter.time_window / 60))
|
||||
|
||||
code, token = cls.generate_email_register_token(account_email)
|
||||
|
||||
if account:
|
||||
send_email_register_mail_task_when_account_exist.delay(
|
||||
language=language,
|
||||
to=account_email,
|
||||
account_name=account.name,
|
||||
)
|
||||
|
||||
else:
|
||||
send_email_register_mail_task.delay(
|
||||
language=language,
|
||||
to=account_email,
|
||||
code=code,
|
||||
)
|
||||
cls.email_register_rate_limiter.increment_rate_limit(account_email)
|
||||
return token
|
||||
|
||||
@classmethod
|
||||
def send_change_email_email(
|
||||
cls,
|
||||
@ -867,19 +829,6 @@ class AccountService:
|
||||
)
|
||||
return code, token
|
||||
|
||||
@classmethod
|
||||
def generate_email_register_token(
|
||||
cls,
|
||||
email: str,
|
||||
code: str | None = None,
|
||||
additional_data: dict[str, Any] = {},
|
||||
):
|
||||
if not code:
|
||||
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
|
||||
additional_data["code"] = code
|
||||
token = TokenManager.generate_token(email=email, token_type="email_register", additional_data=additional_data)
|
||||
return code, token
|
||||
|
||||
@classmethod
|
||||
def generate_change_email_token(
|
||||
cls,
|
||||
@ -917,10 +866,6 @@ class AccountService:
|
||||
def revoke_reset_password_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "reset_password")
|
||||
|
||||
@classmethod
|
||||
def revoke_email_register_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def revoke_change_email_token(cls, token: str):
|
||||
TokenManager.revoke_token(token, "change_email")
|
||||
@ -933,10 +878,6 @@ class AccountService:
|
||||
def get_reset_password_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "reset_password")
|
||||
|
||||
@classmethod
|
||||
def get_email_register_data(cls, token: str) -> dict[str, Any] | None:
|
||||
return TokenManager.get_token_data(token, "email_register")
|
||||
|
||||
@classmethod
|
||||
def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None:
|
||||
token_data = TokenManager.get_token_data(token, "change_email")
|
||||
@ -1067,16 +1008,6 @@ class AccountService:
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.FORGOT_PASSWORD_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def add_email_register_error_rate_limit(email: str) -> None:
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
count = 0
|
||||
count = int(count) + 1
|
||||
redis_client.setex(key, dify_config.EMAIL_REGISTER_LOCKOUT_DURATION, count)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=False)
|
||||
def is_forgot_password_error_rate_limit(email: str) -> bool:
|
||||
@ -1096,24 +1027,6 @@ class AccountService:
|
||||
key = f"forgot_password_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=False)
|
||||
def is_email_register_error_rate_limit(email: str) -> bool:
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
count = redis_client.get(key)
|
||||
if count is None:
|
||||
return False
|
||||
count = int(count)
|
||||
if count > AccountService.EMAIL_REGISTER_MAX_ERROR_LIMITS:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def reset_email_register_error_rate_limit(email: str):
|
||||
key = f"email_register_error_rate_limit:{email}"
|
||||
redis_client.delete(key)
|
||||
|
||||
@staticmethod
|
||||
@redis_fallback(default_return=None)
|
||||
def add_change_email_error_rate_limit(email: str):
|
||||
@ -1891,30 +1804,41 @@ class TenantService:
|
||||
|
||||
affected_account_ids = [str(member.id)]
|
||||
if new_role == "owner":
|
||||
# Find the current owner and change their role to 'admin'
|
||||
if dify_config.RBAC_ENABLED:
|
||||
old_owner_id = AccountService.get_rbac_workspace_owner_account_id(
|
||||
str(tenant.id), operator.id, session=session
|
||||
)
|
||||
owner_role_id = AccountService._resolve_legacy_role_id(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
)
|
||||
no_access_role_id = AccountService._resolve_role_id_by_tag(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
tag="no_access",
|
||||
)
|
||||
current_roles = RBACService.MemberRoles.get(
|
||||
str(tenant.id), operator.id, old_owner_id, session=session
|
||||
).roles
|
||||
remaining_role_ids = [str(r.id) for r in current_roles if str(r.id) != owner_role_id]
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
member_account_id=old_owner_id,
|
||||
role_ids=remaining_role_ids or [no_access_role_id],
|
||||
session=session,
|
||||
)
|
||||
|
||||
current_owner_join = session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.role == "owner")
|
||||
.limit(1)
|
||||
)
|
||||
if not dify_config.RBAC_ENABLED:
|
||||
if current_owner_join:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
elif current_owner_join:
|
||||
admin_role_id = AccountService._resolve_legacy_role_id(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
role=TenantAccountRole.ADMIN,
|
||||
)
|
||||
RBACService.MemberRoles.replace(
|
||||
tenant_id=str(tenant.id),
|
||||
account_id=operator.id,
|
||||
member_account_id=str(current_owner_join.account_id),
|
||||
role_ids=[admin_role_id],
|
||||
session=session,
|
||||
)
|
||||
if current_owner_join and str(current_owner_join.account_id) != str(member.id):
|
||||
affected_account_ids.append(str(current_owner_join.account_id))
|
||||
if current_owner_join:
|
||||
current_owner_join.role = TenantAccountRole.NORMAL
|
||||
|
||||
# Update the role of the target member
|
||||
if dify_config.RBAC_ENABLED:
|
||||
@ -1930,6 +1854,8 @@ class TenantService:
|
||||
role_ids=[resolved_role_id],
|
||||
session=session,
|
||||
)
|
||||
if new_tenant_role == TenantAccountRole.OWNER:
|
||||
target_member_join.role = new_tenant_role
|
||||
else:
|
||||
target_member_join.role = new_tenant_role
|
||||
from services.knowledge_fs.membership_changes import (
|
||||
|
||||
@ -120,7 +120,7 @@ class AppAnnotationService:
|
||||
|
||||
raw_message_id = args.get("message_id")
|
||||
if raw_message_id:
|
||||
message_id = str(raw_message_id)
|
||||
message_id = raw_message_id
|
||||
message = session.scalar(select(Message).where(Message.id == message_id, Message.app_id == app.id).limit(1))
|
||||
|
||||
if not message:
|
||||
@ -176,19 +176,19 @@ class AppAnnotationService:
|
||||
|
||||
@classmethod
|
||||
def enable_app_annotation(cls, args: EnableAnnotationArgs, app_id: str) -> AnnotationJobStatusDict:
|
||||
enable_app_annotation_key = f"enable_app_annotation_{str(app_id)}"
|
||||
enable_app_annotation_key = f"enable_app_annotation_{app_id}"
|
||||
cache_result = redis_client.get(enable_app_annotation_key)
|
||||
if cache_result is not None:
|
||||
return {"job_id": cache_result, "job_status": "processing"}
|
||||
|
||||
# async job
|
||||
job_id = str(uuid.uuid4())
|
||||
enable_app_annotation_job_key = f"enable_app_annotation_job_{str(job_id)}"
|
||||
enable_app_annotation_job_key = f"enable_app_annotation_job_{job_id}"
|
||||
# send batch add segments task
|
||||
redis_client.setnx(enable_app_annotation_job_key, "waiting")
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
enable_annotation_reply_task.delay(
|
||||
str(job_id),
|
||||
job_id,
|
||||
app_id,
|
||||
current_user.id,
|
||||
current_tenant_id,
|
||||
@ -201,17 +201,17 @@ class AppAnnotationService:
|
||||
@classmethod
|
||||
def disable_app_annotation(cls, app_id: str) -> AnnotationJobStatusDict:
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
disable_app_annotation_key = f"disable_app_annotation_{str(app_id)}"
|
||||
disable_app_annotation_key = f"disable_app_annotation_{app_id}"
|
||||
cache_result = redis_client.get(disable_app_annotation_key)
|
||||
if cache_result is not None:
|
||||
return {"job_id": cache_result, "job_status": "processing"}
|
||||
|
||||
# async job
|
||||
job_id = str(uuid.uuid4())
|
||||
disable_app_annotation_job_key = f"disable_app_annotation_job_{str(job_id)}"
|
||||
disable_app_annotation_job_key = f"disable_app_annotation_job_{job_id}"
|
||||
# send batch add segments task
|
||||
redis_client.setnx(disable_app_annotation_job_key, "waiting")
|
||||
disable_annotation_reply_task.delay(str(job_id), app_id, current_tenant_id)
|
||||
disable_annotation_reply_task.delay(job_id, app_id, current_tenant_id)
|
||||
return {"job_id": job_id, "job_status": "waiting"}
|
||||
|
||||
@classmethod
|
||||
@ -539,7 +539,7 @@ class AppAnnotationService:
|
||||
raise ValueError("The number of annotations exceeds the limit of your subscription.")
|
||||
# async job
|
||||
job_id = str(uuid.uuid4())
|
||||
indexing_cache_key = f"app_annotation_batch_import_{str(job_id)}"
|
||||
indexing_cache_key = f"app_annotation_batch_import_{job_id}"
|
||||
|
||||
# Register job in active tasks list for concurrency tracking
|
||||
current_time = int(naive_utc_now().timestamp() * 1000)
|
||||
@ -549,7 +549,7 @@ class AppAnnotationService:
|
||||
|
||||
# Set job status
|
||||
redis_client.setnx(indexing_cache_key, "waiting")
|
||||
batch_import_annotations_task.delay(str(job_id), result, app_id, current_tenant_id, current_user.id)
|
||||
batch_import_annotations_task.delay(job_id, result, app_id, current_tenant_id, current_user.id)
|
||||
|
||||
except ValueError as e:
|
||||
return {"error_msg": str(e)}
|
||||
|
||||
@ -21,11 +21,17 @@ from core.app.features.rate_limiting import RateLimit
|
||||
from core.app.features.rate_limiting.rate_limit import rate_limit_context
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
|
||||
from core.db import session_factory
|
||||
from core.trigger.constants import is_trigger_node_type
|
||||
from enums import DeploymentEdition, QuotaType
|
||||
from extensions.otel import AppGenerateHandler, trace_span
|
||||
from models.model import Account, App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.app import (
|
||||
QuotaExceededError,
|
||||
TriggerWorkflowServiceModeUnavailableError,
|
||||
WorkflowIdFormatError,
|
||||
WorkflowNotFoundError,
|
||||
)
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow_service import WorkflowService
|
||||
@ -34,6 +40,13 @@ from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflo
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SSE_TASK_START_FALLBACK_MS = 200
|
||||
_MANUAL_WORKFLOW_INVOKE_SOURCES = frozenset(
|
||||
{
|
||||
InvokeFrom.OPENAPI,
|
||||
InvokeFrom.SERVICE_API,
|
||||
InvokeFrom.WEB_APP,
|
||||
}
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from controllers.console.app.workflow import LoopNodeRunPayload
|
||||
@ -290,6 +303,7 @@ class AppGenerateService:
|
||||
case AppMode.WORKFLOW:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id, session=session)
|
||||
cls._ensure_workflow_service_mode_available(workflow=workflow, invoke_from=invoke_from)
|
||||
if streaming:
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
@ -343,6 +357,16 @@ class AppGenerateService:
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
|
||||
@staticmethod
|
||||
def _ensure_workflow_service_mode_available(*, workflow: Workflow, invoke_from: InvokeFrom) -> None:
|
||||
if invoke_from not in _MANUAL_WORKFLOW_INVOKE_SOURCES:
|
||||
return
|
||||
|
||||
for _, node_data in workflow.walk_nodes():
|
||||
node_type = node_data.get("type")
|
||||
if isinstance(node_type, str) and is_trigger_node_type(node_type):
|
||||
raise TriggerWorkflowServiceModeUnavailableError()
|
||||
|
||||
@staticmethod
|
||||
def _get_max_active_requests(app: App) -> int:
|
||||
"""
|
||||
|
||||
@ -90,12 +90,19 @@ class AppListBaseParams(BaseModel):
|
||||
class AppListParams(AppListBaseParams):
|
||||
status: str | None = None
|
||||
openapi_visible: bool = False
|
||||
agent_is_published: bool | None = None
|
||||
|
||||
|
||||
class StarredAppListParams(AppListBaseParams):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentAppPublicationCounts:
|
||||
published: int
|
||||
drafts: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecentAppListItem:
|
||||
id: str
|
||||
@ -188,6 +195,24 @@ class AppResponseView:
|
||||
|
||||
|
||||
class AppService:
|
||||
@staticmethod
|
||||
def _agent_app_exists_filter(tenant_id: str, *, is_published: bool | None = None) -> sa.Exists:
|
||||
agent_filters = [
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == App.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
]
|
||||
if is_published is not None:
|
||||
has_published_config = sa.and_(
|
||||
Agent.active_config_snapshot_id.is_not(None),
|
||||
Agent.active_config_is_published.is_(True),
|
||||
)
|
||||
agent_filters.append(has_published_config if is_published else sa.not_(has_published_config))
|
||||
|
||||
return sa.exists().where(*agent_filters).correlate(App)
|
||||
|
||||
@staticmethod
|
||||
def _build_app_list_filters(
|
||||
user_id: str, tenant_id: str, params: AppListBaseParams, session: Session
|
||||
@ -206,17 +231,8 @@ class AppService:
|
||||
filters.append(App.mode == AppMode.AGENT_CHAT)
|
||||
elif params.mode == "agent":
|
||||
filters.append(App.mode == AppMode.AGENT)
|
||||
filters.append(
|
||||
sa.exists()
|
||||
.where(
|
||||
Agent.tenant_id == tenant_id,
|
||||
Agent.app_id == App.id,
|
||||
Agent.scope == AgentScope.ROSTER,
|
||||
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
||||
Agent.status == AgentStatus.ACTIVE,
|
||||
)
|
||||
.correlate(App)
|
||||
)
|
||||
publication_filter = params.agent_is_published if isinstance(params, AppListParams) else None
|
||||
filters.append(AppService._agent_app_exists_filter(tenant_id, is_published=publication_filter))
|
||||
elif params.mode == "all":
|
||||
filters.append(App.mode != AppMode.AGENT)
|
||||
|
||||
@ -374,6 +390,31 @@ class AppService:
|
||||
|
||||
return app_models
|
||||
|
||||
def get_agent_publication_counts(
|
||||
self,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
params: AppListParams,
|
||||
session: Session,
|
||||
) -> AgentAppPublicationCounts:
|
||||
unfiltered_params = params.model_copy(update={"agent_is_published": None})
|
||||
filters = self._build_app_list_filters(user_id, tenant_id, unfiltered_params, session)
|
||||
if not filters:
|
||||
return AgentAppPublicationCounts(published=0, drafts=0)
|
||||
|
||||
published_filter = self._agent_app_exists_filter(tenant_id, is_published=True)
|
||||
draft_filter = self._agent_app_exists_filter(tenant_id, is_published=False)
|
||||
published_count, draft_count = session.execute(
|
||||
sa.select(
|
||||
sa.func.coalesce(sa.func.sum(sa.case((published_filter, 1), else_=0)), 0),
|
||||
sa.func.coalesce(sa.func.sum(sa.case((draft_filter, 1), else_=0)), 0),
|
||||
)
|
||||
.select_from(App)
|
||||
.where(*filters)
|
||||
).one()
|
||||
|
||||
return AgentAppPublicationCounts(published=int(published_count), drafts=int(draft_count))
|
||||
|
||||
def get_recent_apps(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@ -1668,7 +1668,7 @@ class DocumentService:
|
||||
"""Fetch documents for a dataset in a single batch query."""
|
||||
if not document_ids:
|
||||
return []
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
document_id_list: list[str] = list(document_ids)
|
||||
# Fetch all requested documents in one query to avoid N+1 lookups.
|
||||
documents: Sequence[Document] = session.scalars(
|
||||
select(Document).where(
|
||||
@ -1704,7 +1704,7 @@ class DocumentService:
|
||||
if not document_ids:
|
||||
return 0
|
||||
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
document_id_list: list[str] = list(document_ids)
|
||||
|
||||
result = session.execute(
|
||||
update(Document)
|
||||
@ -1865,7 +1865,7 @@ class DocumentService:
|
||||
"""
|
||||
Batch load upload files keyed by document id for ZIP downloads.
|
||||
"""
|
||||
document_id_list: list[str] = [str(document_id) for document_id in document_ids]
|
||||
document_id_list: list[str] = list(document_ids)
|
||||
|
||||
documents = DocumentService.get_documents_by_ids(
|
||||
DatasetRef(tenant_id=tenant_id, dataset_id=dataset_id), document_id_list, session
|
||||
|
||||
@ -405,6 +405,7 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
@ -436,6 +437,7 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
|
||||
"snippets.management",
|
||||
"tool.manage",
|
||||
"mcp.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
@ -454,6 +456,7 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
|
||||
"dataset.external.connect",
|
||||
"snippets.create_and_modify",
|
||||
"tool.manage",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
@ -462,6 +465,7 @@ _LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"credential.use",
|
||||
"app_library.access",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
@ -469,10 +473,12 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
|
||||
"plugin.install",
|
||||
"dataset.create_and_management",
|
||||
"dataset.external.connect",
|
||||
"agent.manage",
|
||||
]
|
||||
|
||||
_LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@ -488,6 +494,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
|
||||
_LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
"app.acl.import_export_dsl",
|
||||
@ -502,6 +509,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
|
||||
|
||||
_LEGACY_APP_EDITOR_KEYS: list[str] = [
|
||||
"app.acl.preview",
|
||||
"app.acl.access_point_manage",
|
||||
"app.acl.view_layout",
|
||||
"app.acl.test_and_run",
|
||||
"app.acl.edit",
|
||||
@ -1880,7 +1888,7 @@ class RBACService:
|
||||
)
|
||||
)
|
||||
if current_owner_join and current_owner_join.account_id != member_account_id:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
current_owner_join.role = TenantAccountRole.NORMAL
|
||||
|
||||
target_member_join.role = tenant_role
|
||||
session.commit()
|
||||
|
||||
@ -116,6 +116,30 @@ class AccountEmailResetResult:
|
||||
account: AccountSnapshot | None = None
|
||||
|
||||
|
||||
class AccountEmailRegistrationPhase(StrEnum):
|
||||
REGISTER = "register"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountEmailRegistrationToken:
|
||||
email: str
|
||||
code: str
|
||||
phase: AccountEmailRegistrationPhase | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountEmailRegistrationVerification:
|
||||
email: str
|
||||
token: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AccountSessionTokens:
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
csrf_token: str
|
||||
|
||||
|
||||
class AccountChangeEmailPhase(StrEnum):
|
||||
OLD_EMAIL = "old_email"
|
||||
OLD_EMAIL_VERIFIED = "old_email_verified"
|
||||
|
||||
38
api/services/entities/notification_entities.py
Normal file
38
api/services/entities/notification_entities.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""Framework-independent notification contracts."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import NamedTuple
|
||||
|
||||
|
||||
class NotificationContent(NamedTuple):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class AccountNotification(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
contents: Mapping[str, NotificationContent]
|
||||
|
||||
|
||||
class AccountNotificationBatch(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[AccountNotification, ...]
|
||||
|
||||
|
||||
class NotificationItem(NamedTuple):
|
||||
notification_id: str | None
|
||||
frequency: str | None
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationResult(NamedTuple):
|
||||
should_show: bool
|
||||
notifications: tuple[NotificationItem, ...]
|
||||
42
api/services/entities/onboarding_entities.py
Normal file
42
api/services/entities/onboarding_entities.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Framework-independent Step-by-step Tour contracts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
# Assignment-form aliases preserve Literal enum values in Pydantic-generated OpenAPI schemas.
|
||||
StepByStepTourAction: TypeAlias = Literal[ # noqa: UP040
|
||||
"skip",
|
||||
"complete_task",
|
||||
"uncomplete_task",
|
||||
"enable_current_workspace",
|
||||
"disable_current_workspace",
|
||||
]
|
||||
StepByStepTourTaskId: TypeAlias = Literal["home", "studio", "knowledge", "integration"] # noqa: UP040
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourPatch:
|
||||
action: StepByStepTourAction
|
||||
task_id: StepByStepTourTaskId | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourState:
|
||||
account_id: str
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StepByStepTourResult:
|
||||
first_workspace_id: str | None = None
|
||||
skipped: bool = False
|
||||
completed_task_ids: tuple[str, ...] = ()
|
||||
manually_enabled_workspace_ids: tuple[str, ...] = ()
|
||||
manually_disabled_workspace_ids: tuple[str, ...] = ()
|
||||
updated_at: datetime | None = None
|
||||
@ -18,6 +18,21 @@ class WorkflowIdFormatError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE = "trigger_workflow_service_mode_unavailable"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE = (
|
||||
"This workflow uses a trigger entry and cannot be invoked through Web App, Service API, OpenAPI, or MCP."
|
||||
)
|
||||
|
||||
|
||||
class TriggerWorkflowServiceModeUnavailableError(Exception):
|
||||
"""Raised when a trigger-entry Workflow is invoked through a manual service surface."""
|
||||
|
||||
error_code = TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__(TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_MESSAGE)
|
||||
|
||||
|
||||
class QuotaExceededError(ValueError):
|
||||
"""Raised when billing quota is exceeded for a feature."""
|
||||
|
||||
|
||||
48
api/services/notification_gateway.py
Normal file
48
api/services/notification_gateway.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""Billing-backed notification gateway."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any, override
|
||||
|
||||
from services.billing_service import BillingService
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
)
|
||||
from services.notification_service import NotificationGateway
|
||||
|
||||
|
||||
class BillingNotificationGateway(NotificationGateway):
|
||||
@override
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch:
|
||||
payload = BillingService.get_account_notification(account_id)
|
||||
notifications = tuple(self._map_notification(item) for item in payload.get("notifications") or ())
|
||||
return AccountNotificationBatch(
|
||||
should_show=bool(payload.get("shouldShow")),
|
||||
notifications=notifications,
|
||||
)
|
||||
|
||||
@override
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None:
|
||||
BillingService.dismiss_notification(notification_id=notification_id, account_id=account_id)
|
||||
|
||||
@classmethod
|
||||
def _map_notification(cls, payload: Mapping[str, Any]) -> AccountNotification:
|
||||
raw_contents = payload.get("contents") or {}
|
||||
contents = {language: cls._map_content(content) for language, content in raw_contents.items() if content}
|
||||
return AccountNotification(
|
||||
notification_id=payload.get("notificationId"),
|
||||
frequency=payload.get("frequency"),
|
||||
contents=contents,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _map_content(payload: Mapping[str, Any]) -> NotificationContent:
|
||||
return NotificationContent(
|
||||
# The application service owns the requested-language fallback.
|
||||
lang=payload.get("lang") or "",
|
||||
title=payload.get("title") or "",
|
||||
subtitle=payload.get("subtitle") or "",
|
||||
body=payload.get("body") or "",
|
||||
title_pic_url=payload.get("titlePicUrl") or "",
|
||||
)
|
||||
60
api/services/notification_service.py
Normal file
60
api/services/notification_service.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""Application service for Console account notifications."""
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.notification_entities import (
|
||||
AccountNotification,
|
||||
AccountNotificationBatch,
|
||||
NotificationContent,
|
||||
NotificationItem,
|
||||
NotificationResult,
|
||||
)
|
||||
|
||||
_FALLBACK_LANGUAGE = "en-US"
|
||||
|
||||
|
||||
class NotificationGateway(Protocol):
|
||||
def get_active(self, account_id: str) -> AccountNotificationBatch: ...
|
||||
|
||||
def dismiss(self, notification_id: str, account_id: str) -> None: ...
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, *, accounts: AccountRepository, notifications: NotificationGateway) -> None:
|
||||
self._accounts = accounts
|
||||
self._notifications = notifications
|
||||
|
||||
def get_active(self, context: RequestContext) -> NotificationResult:
|
||||
batch = self._notifications.get_active(context.account_id)
|
||||
if not batch.should_show:
|
||||
return NotificationResult(should_show=False, notifications=())
|
||||
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
language = account.interface_language or _FALLBACK_LANGUAGE
|
||||
|
||||
notifications = tuple(self._localize(notification, language) for notification in batch.notifications)
|
||||
return NotificationResult(should_show=bool(notifications), notifications=notifications)
|
||||
|
||||
def dismiss(self, context: RequestContext, notification_id: str) -> None:
|
||||
self._notifications.dismiss(notification_id, context.account_id)
|
||||
|
||||
@staticmethod
|
||||
def _localize(notification: AccountNotification, language: str) -> NotificationItem:
|
||||
content = (
|
||||
notification.contents.get(language)
|
||||
or notification.contents.get(_FALLBACK_LANGUAGE)
|
||||
or next(iter(notification.contents.values()), NotificationContent(language, "", "", "", ""))
|
||||
)
|
||||
return NotificationItem(
|
||||
notification_id=notification.notification_id,
|
||||
frequency=notification.frequency,
|
||||
lang=content.lang or language,
|
||||
title=content.title,
|
||||
subtitle=content.subtitle,
|
||||
body=content.body,
|
||||
title_pic_url=content.title_pic_url,
|
||||
)
|
||||
@ -1,221 +1,161 @@
|
||||
"""Account-level Step-by-step Tour persistence."""
|
||||
"""Application service for account-level Step-by-step Tour use cases."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from datetime import datetime
|
||||
from typing import NotRequired, TypedDict
|
||||
from typing import Protocol, get_args
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session, scoped_session
|
||||
|
||||
from configs import dify_config
|
||||
from libs.datetime_utils import ensure_naive_utc
|
||||
from models.account import Account
|
||||
from models.onboarding import AccountStepByStepTourState
|
||||
from machinery.context import RequestContext
|
||||
from services.account_ports import AccountRepository
|
||||
from services.entities.onboarding_entities import (
|
||||
StepByStepTourPatch,
|
||||
StepByStepTourResult,
|
||||
StepByStepTourState,
|
||||
StepByStepTourTaskId,
|
||||
)
|
||||
|
||||
STEP_BY_STEP_TOUR_TASK_IDS = frozenset(("home", "studio", "knowledge", "integration"))
|
||||
_TASK_IDS: frozenset[str] = frozenset(get_args(StepByStepTourTaskId))
|
||||
|
||||
|
||||
class StepByStepTourStateResponse(TypedDict):
|
||||
first_workspace_id: str | None
|
||||
skipped: bool
|
||||
completed_task_ids: list[str]
|
||||
manually_enabled_workspace_ids: list[str]
|
||||
manually_disabled_workspace_ids: list[str]
|
||||
updated_at: datetime | None
|
||||
class StepByStepTourStateRepository(Protocol):
|
||||
def get(self, account_id: str) -> StepByStepTourState | None: ...
|
||||
|
||||
def initialize(self, account_id: str, first_workspace_id: str) -> StepByStepTourState: ...
|
||||
|
||||
class StepByStepTourPatch(TypedDict):
|
||||
action: str
|
||||
task_id: NotRequired[str | None]
|
||||
def mutate(
|
||||
self,
|
||||
account_id: str,
|
||||
mutation: Callable[[StepByStepTourState], StepByStepTourState],
|
||||
) -> StepByStepTourState: ...
|
||||
|
||||
|
||||
class StepByStepTourService:
|
||||
"""Coordinate persisted tour state with account eligibility rules."""
|
||||
|
||||
@classmethod
|
||||
def get_state(
|
||||
cls,
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
eligible = cls.is_eligible(account)
|
||||
state = cls._get_state(account.id, session=session)
|
||||
accounts: AccountRepository,
|
||||
states: StepByStepTourStateRepository,
|
||||
enabled: bool,
|
||||
rollout_started_at: datetime | None,
|
||||
) -> None:
|
||||
self._accounts = accounts
|
||||
self._states = states
|
||||
self._enabled = enabled
|
||||
self._rollout_started_at = rollout_started_at
|
||||
|
||||
if eligible:
|
||||
state = cls._ensure_state(account.id, session=session, state=state)
|
||||
if state.first_workspace_id is None:
|
||||
state.first_workspace_id = current_tenant_id
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
def get_state(self, context: RequestContext) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
account = self._accounts.get(context.account_id)
|
||||
if account is None:
|
||||
raise RuntimeError("Console account admission resolved an unknown account")
|
||||
|
||||
return cls._build_response(state=state)
|
||||
if not self._is_eligible(account.initialized_at or account.created_at):
|
||||
return self._to_result(self._states.get(context.account_id))
|
||||
|
||||
@classmethod
|
||||
def patch_state(
|
||||
cls,
|
||||
*,
|
||||
account: Account,
|
||||
current_tenant_id: str,
|
||||
patch: StepByStepTourPatch,
|
||||
session: Session | scoped_session,
|
||||
) -> StepByStepTourStateResponse:
|
||||
state = cls._ensure_state(account.id, session=session, state=None)
|
||||
cls._apply_action(
|
||||
state=state,
|
||||
action=patch["action"],
|
||||
task_id=patch.get("task_id"),
|
||||
current_tenant_id=current_tenant_id,
|
||||
return self._to_result(self._states.initialize(context.account_id, workspace_id))
|
||||
|
||||
def patch_state(self, context: RequestContext, patch: StepByStepTourPatch) -> StepByStepTourResult:
|
||||
workspace_id = self._require_workspace(context)
|
||||
state = self._states.mutate(
|
||||
context.account_id,
|
||||
lambda current: self._apply_action(current, patch=patch, workspace_id=workspace_id),
|
||||
)
|
||||
return self._to_result(state)
|
||||
|
||||
session.commit()
|
||||
session.refresh(state)
|
||||
return cls._build_response(state=state)
|
||||
|
||||
@classmethod
|
||||
def is_eligible(cls, account: Account) -> bool:
|
||||
if not dify_config.ENABLE_STEP_BY_STEP_TOUR:
|
||||
def _is_eligible(self, account_started_at: datetime) -> bool:
|
||||
if not self._enabled or self._rollout_started_at is None:
|
||||
return False
|
||||
|
||||
rollout_started_at = dify_config.STEP_BY_STEP_TOUR_ROLLOUT_STARTED_AT
|
||||
if rollout_started_at is None:
|
||||
return False
|
||||
|
||||
account_started_at = account.initialized_at or account.created_at
|
||||
if account_started_at is None:
|
||||
return False
|
||||
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _get_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
) -> AccountStepByStepTourState | None:
|
||||
stmt = select(AccountStepByStepTourState).where(AccountStepByStepTourState.account_id == account_id).limit(1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
@classmethod
|
||||
def _ensure_state(
|
||||
cls,
|
||||
account_id: str,
|
||||
*,
|
||||
session: Session | scoped_session,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> AccountStepByStepTourState:
|
||||
if state is None:
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is not None:
|
||||
return state
|
||||
|
||||
state = AccountStepByStepTourState(account_id=account_id)
|
||||
session.add(state)
|
||||
try:
|
||||
session.flush()
|
||||
except IntegrityError:
|
||||
# Another tab/device can create the account row between our read and insert.
|
||||
session.rollback()
|
||||
state = cls._get_state(account_id, session=session)
|
||||
if state is None:
|
||||
raise
|
||||
return state
|
||||
return ensure_naive_utc(account_started_at) >= ensure_naive_utc(self._rollout_started_at)
|
||||
|
||||
@classmethod
|
||||
def _apply_action(
|
||||
cls,
|
||||
state: StepByStepTourState,
|
||||
*,
|
||||
state: AccountStepByStepTourState,
|
||||
action: str,
|
||||
task_id: str | None,
|
||||
current_tenant_id: str,
|
||||
) -> None:
|
||||
match action:
|
||||
patch: StepByStepTourPatch,
|
||||
workspace_id: str,
|
||||
) -> StepByStepTourState:
|
||||
match patch.action:
|
||||
case "skip":
|
||||
state.skipped = True
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=True,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "complete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._add_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._add_id(state.completed_task_ids, task_id))
|
||||
case "uncomplete_task":
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
cls._validate_task_id(task_id)
|
||||
state.completed_task_ids = cls._remove_id(state.completed_task_ids, task_id)
|
||||
task_id = cls._require_task_id(patch.task_id)
|
||||
return replace(state, completed_task_ids=cls._remove_id(state.completed_task_ids, task_id))
|
||||
case "enable_current_workspace":
|
||||
state.skipped = False
|
||||
state.manually_enabled_workspace_ids = cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
skipped=False,
|
||||
manually_enabled_workspace_ids=cls._add_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._remove_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case "disable_current_workspace":
|
||||
state.manually_enabled_workspace_ids = cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
)
|
||||
state.manually_disabled_workspace_ids = cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
current_tenant_id,
|
||||
return replace(
|
||||
state,
|
||||
manually_enabled_workspace_ids=cls._remove_id(
|
||||
state.manually_enabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
manually_disabled_workspace_ids=cls._add_id(
|
||||
state.manually_disabled_workspace_ids,
|
||||
workspace_id,
|
||||
),
|
||||
)
|
||||
case _:
|
||||
raise ValueError(f"Unsupported action: {action}")
|
||||
|
||||
@classmethod
|
||||
def _build_response(
|
||||
cls,
|
||||
*,
|
||||
state: AccountStepByStepTourState | None,
|
||||
) -> StepByStepTourStateResponse:
|
||||
if state is None:
|
||||
return {
|
||||
"first_workspace_id": None,
|
||||
"skipped": False,
|
||||
"completed_task_ids": [],
|
||||
"manually_enabled_workspace_ids": [],
|
||||
"manually_disabled_workspace_ids": [],
|
||||
"updated_at": None,
|
||||
}
|
||||
|
||||
return {
|
||||
"first_workspace_id": state.first_workspace_id,
|
||||
"skipped": state.skipped,
|
||||
"completed_task_ids": cls._normalize_ids(state.completed_task_ids),
|
||||
"manually_enabled_workspace_ids": cls._normalize_ids(state.manually_enabled_workspace_ids),
|
||||
"manually_disabled_workspace_ids": cls._normalize_ids(state.manually_disabled_workspace_ids),
|
||||
"updated_at": state.updated_at,
|
||||
}
|
||||
raise ValueError(f"Unsupported action: {patch.action}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_task_id(task_id: str) -> None:
|
||||
if task_id not in STEP_BY_STEP_TOUR_TASK_IDS:
|
||||
def _require_workspace(context: RequestContext) -> str:
|
||||
if context.active_workspace_id is None:
|
||||
raise RuntimeError("Console account admission did not resolve an active workspace")
|
||||
return context.active_workspace_id
|
||||
|
||||
@staticmethod
|
||||
def _require_task_id(task_id: str | None) -> str:
|
||||
if task_id is None:
|
||||
raise ValueError("task_id is required")
|
||||
if task_id not in _TASK_IDS:
|
||||
raise ValueError(f"Unsupported task_id: {task_id}")
|
||||
return task_id
|
||||
|
||||
@classmethod
|
||||
def _add_id(cls, values: list[str], value: str) -> list[str]:
|
||||
def _add_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
normalized = cls._normalize_ids(values)
|
||||
if value in normalized:
|
||||
return normalized
|
||||
return [*normalized, value]
|
||||
return normalized if value in normalized else (*normalized, value)
|
||||
|
||||
@classmethod
|
||||
def _remove_id(cls, values: list[str], value: str) -> list[str]:
|
||||
return [item for item in cls._normalize_ids(values) if item != value]
|
||||
def _remove_id(cls, values: tuple[str, ...], value: str) -> tuple[str, ...]:
|
||||
return tuple(item for item in cls._normalize_ids(values) if item != value)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_ids(values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
if value not in normalized:
|
||||
normalized.append(value)
|
||||
return normalized
|
||||
def _normalize_ids(values: tuple[str, ...]) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(values))
|
||||
|
||||
@staticmethod
|
||||
def _to_result(state: StepByStepTourState | None) -> StepByStepTourResult:
|
||||
if state is None:
|
||||
return StepByStepTourResult()
|
||||
return StepByStepTourResult(
|
||||
first_workspace_id=state.first_workspace_id,
|
||||
skipped=state.skipped,
|
||||
completed_task_ids=tuple(dict.fromkeys(state.completed_task_ids)),
|
||||
manually_enabled_workspace_ids=tuple(dict.fromkeys(state.manually_enabled_workspace_ids)),
|
||||
manually_disabled_workspace_ids=tuple(dict.fromkeys(state.manually_disabled_workspace_ids)),
|
||||
updated_at=state.updated_at,
|
||||
)
|
||||
|
||||
@ -23,17 +23,27 @@ class LogView:
|
||||
"""Lightweight wrapper for WorkflowAppLog with computed details.
|
||||
|
||||
- Exposes `details_` for marshalling to `details` in API response
|
||||
- Resolves the account/end-user accessors through the session it was built with
|
||||
- Proxies all other attributes to the underlying `WorkflowAppLog`
|
||||
"""
|
||||
|
||||
def __init__(self, log: WorkflowAppLog, details: LogViewDetails | None):
|
||||
def __init__(self, log: WorkflowAppLog, details: LogViewDetails | None, session: Session):
|
||||
self.log = log
|
||||
self.details_ = details
|
||||
self._session = session
|
||||
|
||||
@property
|
||||
def details(self) -> LogViewDetails | None:
|
||||
return self.details_
|
||||
|
||||
@property
|
||||
def created_by_account(self) -> Account | None:
|
||||
return self.log.created_by_account(self._session)
|
||||
|
||||
@property
|
||||
def created_by_end_user(self) -> EndUser | None:
|
||||
return self.log.created_by_end_user(self._session)
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self.log, name)
|
||||
|
||||
@ -171,11 +181,11 @@ class WorkflowAppService:
|
||||
if detail:
|
||||
rows = session.execute(offset_stmt).all()
|
||||
items = [
|
||||
LogView(log, {"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, meta_val)})
|
||||
LogView(log, {"trigger_metadata": self.handle_trigger_metadata(app_model.tenant_id, meta_val)}, session)
|
||||
for log, meta_val in rows
|
||||
]
|
||||
else:
|
||||
items = [LogView(log, None) for log in session.scalars(offset_stmt).all()]
|
||||
items = [LogView(log, None, session) for log in session.scalars(offset_stmt).all()]
|
||||
return {
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
|
||||
@ -300,7 +300,7 @@ class TestOwnerTransferApiWithContainers:
|
||||
)
|
||||
assert (
|
||||
factory.get_join(db_session_with_containers, tenant=tenant, account=current_user).role
|
||||
== TenantAccountRole.ADMIN
|
||||
== TenantAccountRole.NORMAL
|
||||
)
|
||||
mock_new_owner_email.assert_called_once()
|
||||
mock_old_owner_email.assert_called_once()
|
||||
|
||||
@ -102,10 +102,8 @@ class TestConversationRenameApi:
|
||||
ConversationRenameApi().post(_completion_app(), _end_user(), uuid4())
|
||||
|
||||
@patch("controllers.web.conversation.ConversationService.rename")
|
||||
@patch("controllers.web.conversation.web_ns")
|
||||
def test_rename_success(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
|
||||
def test_rename_success(self, mock_rename: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
mock_ns.payload = {"name": "New Name", "auto_generate": False}
|
||||
conv = SimpleNamespace(
|
||||
id=str(c_id),
|
||||
name="New Name",
|
||||
@ -126,10 +124,8 @@ class TestConversationRenameApi:
|
||||
"controllers.web.conversation.ConversationService.rename",
|
||||
side_effect=ConversationNotExistsError(),
|
||||
)
|
||||
@patch("controllers.web.conversation.web_ns")
|
||||
def test_rename_not_found(self, mock_ns: MagicMock, mock_rename: MagicMock, app: Flask) -> None:
|
||||
def test_rename_not_found(self, mock_rename: MagicMock, app: Flask) -> None:
|
||||
c_id = uuid4()
|
||||
mock_ns.payload = {"name": "X", "auto_generate": False}
|
||||
|
||||
with app.test_request_context(f"/conversations/{c_id}/name", method="POST", json={"name": "X"}):
|
||||
with pytest.raises(NotFound, match="Conversation Not Exists"):
|
||||
|
||||
@ -1671,7 +1671,7 @@ class TestTenantService:
|
||||
|
||||
def test_update_member_role_to_owner(self, db_session_with_containers: Session, mock_external_service_dependencies):
|
||||
"""
|
||||
Test updating member role to owner (should change current owner to admin).
|
||||
Test updating member role to owner (should change current owner to normal).
|
||||
"""
|
||||
fake = Faker()
|
||||
tenant_name = fake.company()
|
||||
@ -1723,7 +1723,7 @@ class TestTenantService:
|
||||
.filter_by(tenant_id=tenant.id, account_id=member_account.id)
|
||||
.first()
|
||||
)
|
||||
assert owner_join.role == "admin"
|
||||
assert owner_join.role == "normal"
|
||||
assert member_join.role == "owner"
|
||||
|
||||
def test_update_member_role_already_assigned(
|
||||
|
||||
@ -9,6 +9,7 @@ from clients.agent_backend.factory import create_agent_backend_client, create_ag
|
||||
from configs import dify_config
|
||||
from services import agent_app_sandbox_service
|
||||
from services.agent import home_snapshot_service, workspace_service
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -78,9 +79,12 @@ def test_default_agent_backend_clients_forward_authentication(
|
||||
module: ModuleType,
|
||||
extra_kwargs: dict[str, float],
|
||||
) -> None:
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_BASE_URL", "http://agent-backend")
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_API_TOKEN", "secret-token")
|
||||
monkeypatch.setattr(dify_config, "AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS", 123.5)
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
AGENT_BACKEND_BASE_URL="http://agent-backend",
|
||||
AGENT_BACKEND_API_TOKEN="secret-token",
|
||||
AGENT_BACKEND_BINDING_FILE_DOWNLOAD_TIMEOUT_SECONDS=123.5,
|
||||
)
|
||||
create_client = MagicMock()
|
||||
monkeypatch.setattr(module, "create_agent_backend_client", create_client)
|
||||
|
||||
|
||||
@ -238,6 +238,42 @@ def test_patch_union_schema_markdown_fills_regular_schema_union_property(tmp_pat
|
||||
assert "| value | string<br>integer<br>number<br>boolean | | No |" in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_preserves_nullable_enum_values(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
spec_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"components": {
|
||||
"schemas": {
|
||||
"StepByStepTourStatePatchPayload": {
|
||||
"properties": {
|
||||
"task_id": {
|
||||
"anyOf": [
|
||||
{"enum": ["home", "studio"], "type": "string"},
|
||||
{"type": "null"},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
markdown = """#### StepByStepTourStatePatchPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| task_id | string | Task ID | No |
|
||||
"""
|
||||
|
||||
patched = module._patch_union_schema_markdown(markdown, spec_path)
|
||||
|
||||
assert '| task_id | string, <br>**Available values:** "home", "studio" | Task ID | No |' in patched
|
||||
|
||||
|
||||
def test_patch_union_schema_markdown_fills_array_item_union_property(tmp_path: Path):
|
||||
module = _load_generate_swagger_markdown_docs_module()
|
||||
spec_path = tmp_path / "console-openapi.json"
|
||||
|
||||
@ -9,6 +9,8 @@ from pathlib import Path
|
||||
|
||||
from jsonschema import Draft202012Validator
|
||||
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _walk_values(value):
|
||||
yield value
|
||||
@ -162,7 +164,7 @@ def test_apply_runtime_defaults_forces_swagger_routes_on(monkeypatch):
|
||||
from configs import dify_config
|
||||
|
||||
monkeypatch.setenv("SWAGGER_UI_ENABLED", "false")
|
||||
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", False)
|
||||
apply_config_overrides(monkeypatch, SWAGGER_UI_ENABLED=False)
|
||||
|
||||
module.apply_runtime_defaults()
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Tenant
|
||||
from models.provider import Provider, ProviderModel, ProviderType
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _invoke_reset() -> int:
|
||||
@ -88,7 +89,7 @@ def _bind_command_to_sqlite(monkeypatch: pytest.MonkeyPatch, session: Session) -
|
||||
|
||||
|
||||
def test_reset_aborts_when_not_self_hosted(monkeypatch, capsys):
|
||||
monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
|
||||
exit_code = _invoke_reset()
|
||||
captured = capsys.readouterr()
|
||||
@ -107,7 +108,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
|
||||
) -> None:
|
||||
"""The command must purge LLM provider rows AND every tool provider table
|
||||
that stores ciphertext encrypted under the tenant key (#35396)."""
|
||||
monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
_bind_command_to_sqlite(monkeypatch, sqlite_session)
|
||||
|
||||
@ -147,7 +148,7 @@ def test_reset_purges_provider_and_tool_tables_for_each_tenant(
|
||||
)
|
||||
def test_reset_iterates_all_tenants(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
"""Multi-tenant deployments must purge every tenant, not just the first."""
|
||||
monkeypatch.setattr(system_commands.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.COMMUNITY)
|
||||
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY)
|
||||
monkeypatch.setattr(system_commands, "generate_key_pair", lambda tenant_id: f"new-key-{tenant_id}")
|
||||
|
||||
_bind_command_to_sqlite(monkeypatch, sqlite_session)
|
||||
|
||||
26
api/tests/unit_tests/config_override.py
Normal file
26
api/tests/unit_tests/config_override.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""Typed config override support shared by unit-test fixtures and helpers."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import pytest
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
|
||||
def apply_config_overrides(monkeypatch: pytest.MonkeyPatch, **values: object) -> None:
|
||||
"""Override known DifyConfig fields for the lifetime of ``monkeypatch``."""
|
||||
unknown_fields = values.keys() - type(dify_config).model_fields.keys()
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unknown DifyConfig fields: {sorted(unknown_fields)}")
|
||||
|
||||
for name, value in values.items():
|
||||
monkeypatch.setattr(dify_config, name, value)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def config_overrides_context(**values: object) -> Generator[None]:
|
||||
"""Apply validated config overrides as a context manager or decorator."""
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
apply_config_overrides(monkeypatch, **values)
|
||||
yield
|
||||
@ -41,6 +41,7 @@ import core.db.session_factory as session_factory_module
|
||||
from extensions import ext_redis
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _patch_redis_clients_on_loaded_modules() -> None:
|
||||
@ -99,17 +100,9 @@ def reset_redis_mock() -> None:
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_secret_key() -> Iterator[None]:
|
||||
def reset_secret_key(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ensure SECRET_KEY-dependent logic sees an empty config value by default."""
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
original = dify_config.SECRET_KEY
|
||||
dify_config.SECRET_KEY = ""
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
dify_config.SECRET_KEY = original
|
||||
apply_config_overrides(monkeypatch, SECRET_KEY="")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@ -120,14 +113,9 @@ def config_overrides(monkeypatch: pytest.MonkeyPatch) -> Callable[..., None]:
|
||||
field names keeps tests scoped without replacing that instance with an
|
||||
unconstrained mock. ``monkeypatch`` restores every value after the test.
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
def apply(**values: object) -> None:
|
||||
unknown_fields = values.keys() - type(dify_config).model_fields.keys()
|
||||
if unknown_fields:
|
||||
raise ValueError(f"Unknown DifyConfig fields: {sorted(unknown_fields)}")
|
||||
for name, value in values.items():
|
||||
monkeypatch.setattr(dify_config, name, value)
|
||||
apply_config_overrides(monkeypatch, **values)
|
||||
|
||||
return apply
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from inspect import getsource, unwrap
|
||||
from types import SimpleNamespace
|
||||
@ -55,7 +56,7 @@ from controllers.console.agent.roster import (
|
||||
from controllers.console.app import completion as completion_controller
|
||||
from controllers.console.app import message as message_controller
|
||||
from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from controllers.console.app.error import AgentSessionConfigurationChangedError, CompletionRequestError
|
||||
from controllers.console.app.message import (
|
||||
AgentChatMessageListApi,
|
||||
AgentMessageApi,
|
||||
@ -74,6 +75,7 @@ from services.entities.agent_entities import (
|
||||
WorkflowAgentComposerQuery,
|
||||
WorkflowComposerCopyFromRosterPayload,
|
||||
)
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _persist_conversation_message(
|
||||
@ -307,9 +309,15 @@ def account_id() -> str:
|
||||
|
||||
|
||||
def test_agent_app_list_and_create_use_agent_route(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account_id: str,
|
||||
sqlite_session: Session,
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
captured: dict[str, object] = {}
|
||||
replace_whitelist = MagicMock()
|
||||
initialize_access = MagicMock()
|
||||
|
||||
class FakeAppService:
|
||||
def get_app(self, app_obj: object, *, session: object) -> object:
|
||||
@ -325,6 +333,11 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
items=[_app_detail_obj(id="app-list", bound_agent_id="agent-list")],
|
||||
)
|
||||
|
||||
def get_agent_publication_counts(self, user_id: str, tenant_id: str, params, session):
|
||||
del session
|
||||
captured["counts"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params}
|
||||
return roster_controller.AgentAppPublicationCounts(published=1, drafts=0)
|
||||
|
||||
def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object:
|
||||
captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user}
|
||||
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
|
||||
@ -390,7 +403,9 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
||||
roster_controller.AgentRosterService,
|
||||
"count_agent_app_debug_conversation_messages",
|
||||
lambda _self, **kwargs: 0,
|
||||
)
|
||||
|
||||
def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str:
|
||||
@ -407,8 +422,16 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
"get_system_features",
|
||||
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
||||
)
|
||||
config_overrides(RBAC_ENABLED=True)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_whitelist",
|
||||
replace_whitelist,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access)
|
||||
with app.test_request_context(
|
||||
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created&is_created_by_me=true"
|
||||
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created"
|
||||
"&is_created_by_me=true&publication_status=published"
|
||||
):
|
||||
listed = unwrap(AgentAppListApi.get)(
|
||||
AgentAppListApi(), sqlite_session, "tenant-1", _account(account_id=account_id)
|
||||
@ -416,6 +439,7 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
assert listed["page"] == 1
|
||||
assert listed["limit"] == 10
|
||||
assert listed["total"] == 1
|
||||
assert listed["publication_counts"] == {"published": 1, "drafts": 0}
|
||||
assert listed["data"][0]["id"] == "agent-list"
|
||||
assert listed["data"][0]["app_id"] == "app-list"
|
||||
assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list"
|
||||
@ -438,15 +462,29 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
assert list_params.mode == "agent"
|
||||
assert list_params.sort_by == "recently_created"
|
||||
assert list_params.is_created_by_me is True
|
||||
assert list_params.agent_is_published is True
|
||||
assert list_params.status == "normal"
|
||||
count_call = cast(dict[str, object], captured["counts"])
|
||||
count_params = cast(Any, count_call["params"])
|
||||
assert count_params.agent_is_published is True
|
||||
with app.test_request_context(
|
||||
"/console/api/agent",
|
||||
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
|
||||
json={
|
||||
"name": "Iris",
|
||||
"description": "Agent app",
|
||||
"role": "Coordinator",
|
||||
"icon_type": "emoji",
|
||||
"icon": "robot",
|
||||
},
|
||||
):
|
||||
created, status = unwrap(AgentAppListApi.post)(
|
||||
AgentAppListApi(),
|
||||
AgentAppCreatePayload(
|
||||
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
|
||||
name="Iris",
|
||||
description="Agent app",
|
||||
role="Coordinator",
|
||||
icon_type="emoji",
|
||||
icon="robot",
|
||||
),
|
||||
sqlite_session,
|
||||
"tenant-1",
|
||||
@ -469,6 +507,81 @@ def test_agent_app_list_and_create_use_agent_route(
|
||||
"account_id": account_id,
|
||||
"commit": False,
|
||||
}
|
||||
replace_whitelist.assert_called_once()
|
||||
assert replace_whitelist.call_args.args[:3] == ("tenant-1", account_id, "app-created")
|
||||
replace_payload = replace_whitelist.call_args.args[3]
|
||||
assert replace_payload.automatic_include_workspace_members is True
|
||||
initialize_access.assert_called_once_with("tenant-1", account_id, app_id="app-created")
|
||||
|
||||
|
||||
def test_agent_app_create_skips_rbac_access_initialization_when_rbac_is_disabled(
|
||||
app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
account_id: str,
|
||||
sqlite_session: Session,
|
||||
config_overrides: Callable[..., None],
|
||||
) -> None:
|
||||
replace_whitelist = MagicMock()
|
||||
initialize_access = MagicMock()
|
||||
|
||||
class FakeAppService:
|
||||
def get_app(self, app_obj: object, *, session: object) -> object:
|
||||
return app_obj
|
||||
|
||||
def create_app(self, tenant_id: str, params, current_user: object, *, session: object) -> object:
|
||||
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
|
||||
|
||||
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_app_backing_agent",
|
||||
lambda _self, **kwargs: Agent(
|
||||
id="agent-created",
|
||||
app_id="app-created",
|
||||
backing_app_id=None,
|
||||
role="Created role",
|
||||
active_config_snapshot_id=None,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService,
|
||||
"get_or_create_build_conversation",
|
||||
lambda _self, **kwargs: "debug-conversation-created",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.FeatureService,
|
||||
"get_system_features",
|
||||
lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)),
|
||||
)
|
||||
config_overrides(RBAC_ENABLED=False)
|
||||
monkeypatch.setattr(
|
||||
roster_controller.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"replace_whitelist",
|
||||
replace_whitelist,
|
||||
)
|
||||
monkeypatch.setattr(roster_controller.initialize_created_app_rbac_access_task, "delay", initialize_access)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/agent",
|
||||
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
|
||||
):
|
||||
created, status = unwrap(AgentAppListApi.post)(
|
||||
AgentAppListApi(),
|
||||
AgentAppCreatePayload(
|
||||
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
|
||||
),
|
||||
sqlite_session,
|
||||
"tenant-1",
|
||||
_account(account_id=account_id),
|
||||
)
|
||||
|
||||
assert status == 201
|
||||
assert created["id"] == "agent-created"
|
||||
replace_whitelist.assert_not_called()
|
||||
initialize_access.assert_not_called()
|
||||
|
||||
|
||||
def test_agent_app_create_payload_allows_optional_role() -> None:
|
||||
@ -853,7 +966,7 @@ def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeyp
|
||||
monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model)
|
||||
monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, _app: 2)
|
||||
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
||||
monkeypatch.setattr("models.model.dify_config.SERVICE_API_URL", "https://api.example.test/v1")
|
||||
apply_config_overrides(monkeypatch, SERVICE_API_URL="https://api.example.test/v1")
|
||||
response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id)
|
||||
assert response == {
|
||||
"access_ready": True,
|
||||
@ -1621,6 +1734,38 @@ def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_session_configuration_error() -> None:
|
||||
class ClosableStream:
|
||||
def __init__(self) -> None:
|
||||
self.closed = False
|
||||
self._chunks = iter(
|
||||
[
|
||||
"event: ping\n\n",
|
||||
(
|
||||
'data: {"event":"error","message":"Start a new conversation to continue.",'
|
||||
'"code":"agent_session_configuration_changed","status":409}\n\n'
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
def __iter__(self):
|
||||
return self
|
||||
|
||||
def __next__(self) -> str:
|
||||
return next(self._chunks)
|
||||
|
||||
def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
stream = ClosableStream()
|
||||
with pytest.raises(AgentSessionConfigurationChangedError) as exc_info:
|
||||
completion_controller._raise_agent_stream_error_before_response(stream)
|
||||
assert exc_info.value.code == 409
|
||||
assert exc_info.value.error_code == "agent_session_configuration_changed"
|
||||
assert "Start a new conversation" in exc_info.value.description
|
||||
assert stream.closed is True
|
||||
|
||||
|
||||
def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
|
||||
stream = iter(
|
||||
["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n']
|
||||
|
||||
@ -12,6 +12,7 @@ from core.rbac import RBACPermission, RBACResourceScope
|
||||
from models import Account
|
||||
from models.agent import Agent, AgentScope, AgentSource, AgentStatus
|
||||
from models.model import App, AppMode
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
TENANT_ID = "tenant-1"
|
||||
|
||||
@ -60,7 +61,7 @@ def _persist_app(
|
||||
def _patch_guard(account: Account, rbac_enabled: bool):
|
||||
return (
|
||||
patch("controllers.console.app.wraps.current_account_with_tenant", return_value=(account, TENANT_ID)),
|
||||
patch("controllers.console.app.wraps.dify_config.RBAC_ENABLED", rbac_enabled),
|
||||
config_overrides_context(RBAC_ENABLED=rbac_enabled),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -77,6 +77,7 @@ from services.app_site_service import (
|
||||
AppSiteCommandResult,
|
||||
AppSiteNotFoundError,
|
||||
)
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
APP_ID = "11111111-1111-1111-1111-111111111111"
|
||||
TENANT_ID = "22222222-2222-2222-2222-222222222222"
|
||||
@ -261,8 +262,11 @@ class TestAppEndpoints:
|
||||
oauth_server.issue_authorization_code.return_value = MagicMock(code="oauth-code-1")
|
||||
services = MagicMock(oauth_server=oauth_server)
|
||||
|
||||
monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_FEATURES_ENABLED", True)
|
||||
monkeypatch.setattr(app_module.dify_config, "CREATORS_PLATFORM_OAUTH_CLIENT_ID", "client-1")
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
CREATORS_PLATFORM_FEATURES_ENABLED=True,
|
||||
CREATORS_PLATFORM_OAUTH_CLIENT_ID="client-1",
|
||||
)
|
||||
monkeypatch.setattr(app_module, "application_services", lambda: services)
|
||||
monkeypatch.setattr(app_module.AppDslService, "export_dsl", MagicMock(return_value="app: demo"))
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ from models.model import App, AppMode
|
||||
from services.app_dsl_service import ImportStatus
|
||||
from services.entities.dsl_entities import CheckDependenciesResult
|
||||
from services.entities.feature_entities import SystemFeatureModel, WebAppAuthModel
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _unwrap(func):
|
||||
@ -240,7 +241,7 @@ class TestAppImportApi:
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
app_id = _install_persisting_service_result(
|
||||
monkeypatch,
|
||||
method_name="import_app",
|
||||
@ -276,7 +277,7 @@ class TestAppImportApi:
|
||||
"current_account_with_tenant",
|
||||
lambda: (_make_account(), "tenant-1"),
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
app_id = _install_persisting_service_result(
|
||||
monkeypatch,
|
||||
method_name="import_app",
|
||||
@ -353,7 +354,7 @@ class TestAppImportConfirmApi:
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.redis_client, "get", redis_get)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
app_id = _install_persisting_service_result(
|
||||
monkeypatch,
|
||||
method_name="confirm_import",
|
||||
@ -397,7 +398,7 @@ class TestAppImportConfirmApi:
|
||||
b'"name":null,"description":null,"icon_type":null,"icon":null,"icon_background":null}'
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(app_import_module.dify_config, "RBAC_ENABLED", True)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
app_id = _install_persisting_service_result(
|
||||
monkeypatch,
|
||||
method_name="confirm_import",
|
||||
|
||||
@ -21,6 +21,7 @@ from models import Account
|
||||
from models.account import AccountStatus
|
||||
from models.enums import AppMCPServerStatus
|
||||
from models.model import App, AppMCPServer, AppMode, IconType
|
||||
from tests.unit_tests.config_override import config_overrides_context
|
||||
|
||||
|
||||
def _app(
|
||||
@ -309,7 +310,7 @@ class TestAppMCPServerRefreshController:
|
||||
current_user = Account(name="Current user", email="user@example.com", status=AccountStatus.ACTIVE)
|
||||
current_user.id = "account-1"
|
||||
with (
|
||||
patch("controllers.common.wraps.dify_config.RBAC_ENABLED", True),
|
||||
config_overrides_context(RBAC_ENABLED=True),
|
||||
patch(
|
||||
"controllers.common.wraps.current_account_with_tenant",
|
||||
return_value=(current_user, "tenant-1"),
|
||||
|
||||
@ -18,6 +18,7 @@ from libs import login as login_lib
|
||||
from models import Tenant
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import App, AppMode, IconType
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _make_account(role: TenantAccountRole) -> Account:
|
||||
@ -55,9 +56,12 @@ def _patch_console_guards(
|
||||
*,
|
||||
rbac_enabled: bool = False,
|
||||
) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
monkeypatch.setattr(login_lib.dify_config, "RBAC_ENABLED", rbac_enabled)
|
||||
monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
LOGIN_DISABLED=True,
|
||||
RBAC_ENABLED=rbac_enabled,
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
|
||||
@ -22,6 +22,7 @@ from core.workflow.llm_environment_variable import LLMEnvironmentVariable
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.variables import SecretVariable, StringVariable
|
||||
from graphon.variables.variables import RAGPipelineVariable
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _make_workflow(**overrides):
|
||||
@ -77,6 +78,9 @@ def _make_workflow(**overrides):
|
||||
)
|
||||
for key, value in overrides.items():
|
||||
setattr(workflow, key, value)
|
||||
workflow.get_created_by_account = Mock(return_value=workflow.created_by_account)
|
||||
workflow.get_updated_by_account = Mock(return_value=workflow.updated_by_account)
|
||||
workflow.get_tool_published = Mock(return_value=workflow.tool_published)
|
||||
return workflow
|
||||
|
||||
|
||||
@ -103,6 +107,7 @@ def test_publish_workflow_returns_success(
|
||||
with app.test_request_context("/apps/app-1/workflows/publish", method="POST", json={}):
|
||||
response = inspect.unwrap(workflow_module.PublishedWorkflowApi.post)(
|
||||
workflow_module.PublishedWorkflowApi(),
|
||||
workflow_module.PublishWorkflowPayload.model_validate({}),
|
||||
current_user,
|
||||
app_model,
|
||||
)
|
||||
@ -547,7 +552,10 @@ def test_get_published_workflows_serializes_items_before_session_closes(
|
||||
method="GET",
|
||||
query_string={"page": 1, "limit": 10, "user_id": "", "named_only": "false"},
|
||||
):
|
||||
response = handler(api, "t1", app_model=SimpleNamespace(id="app", workflow_id="wf-1"))
|
||||
query = workflow_module.WorkflowListQuery.model_validate(
|
||||
{"page": "1", "limit": "10", "user_id": "", "named_only": "false"}
|
||||
)
|
||||
response = handler(api, query, "t1", app_model=SimpleNamespace(id="app", workflow_id="wf-1"))
|
||||
|
||||
assert response["items"][0]["id"] == "w1"
|
||||
assert response["page"] == 1
|
||||
@ -612,6 +620,25 @@ def test_draft_workflow_get_serializes_response_model(monkeypatch: pytest.Monkey
|
||||
]
|
||||
|
||||
|
||||
def test_published_workflow_get_uses_session_aware_response_source(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow = _make_workflow()
|
||||
session = Mock(spec=Session)
|
||||
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(session=Mock(return_value=session)))
|
||||
monkeypatch.setattr(
|
||||
workflow_module, "WorkflowService", lambda: SimpleNamespace(get_published_workflow=lambda **_kwargs: workflow)
|
||||
)
|
||||
|
||||
api = workflow_module.PublishedWorkflowApi()
|
||||
handler = inspect.unwrap(api.get)
|
||||
|
||||
response = handler(api, app_model=SimpleNamespace(id="app"))
|
||||
|
||||
assert response["id"] == "workflow-1"
|
||||
workflow.get_created_by_account.assert_called_once_with(session=session)
|
||||
workflow.get_updated_by_account.assert_called_once_with(session=session)
|
||||
workflow.get_tool_published.assert_called_once_with(session=session)
|
||||
|
||||
|
||||
def test_pipeline_variable_response_accepts_legacy_file_field_names() -> None:
|
||||
response = workflow_module.PipelineVariableResponse.model_validate(
|
||||
{
|
||||
@ -806,15 +833,24 @@ def test_advanced_chat_run_conversation_not_exists(app: Flask, monkeypatch: pyte
|
||||
method="POST",
|
||||
json={"inputs": {}},
|
||||
):
|
||||
payload = workflow_module.AdvancedChatWorkflowRunPayload.model_validate({"inputs": {}})
|
||||
with pytest.raises(NotFound):
|
||||
handler(api, Mock(), "t1", app_model=SimpleNamespace(id="app"))
|
||||
handler(api, payload, Mock(), "t1", app_model=SimpleNamespace(id="app"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("resource", "payload"),
|
||||
("resource", "payload_model", "payload"),
|
||||
[
|
||||
(workflow_module.DraftWorkflowTriggerRunApi, {"node_id": "node-1"}),
|
||||
(workflow_module.DraftWorkflowTriggerRunAllApi, {"node_ids": ["node-1"]}),
|
||||
(
|
||||
workflow_module.DraftWorkflowTriggerRunApi,
|
||||
workflow_module.DraftWorkflowTriggerRunPayload,
|
||||
{"node_id": "node-1"},
|
||||
),
|
||||
(
|
||||
workflow_module.DraftWorkflowTriggerRunAllApi,
|
||||
workflow_module.DraftWorkflowTriggerRunAllPayload,
|
||||
{"node_ids": ["node-1"]},
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_trigger_run_loads_draft_with_request_session(
|
||||
@ -822,6 +858,7 @@ def test_trigger_run_loads_draft_with_request_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
unbound_session: Session,
|
||||
resource: type,
|
||||
payload_model: type,
|
||||
payload: dict[str, object],
|
||||
) -> None:
|
||||
get_draft_workflow = Mock(return_value=None)
|
||||
@ -836,7 +873,13 @@ def test_trigger_run_loads_draft_with_request_session(
|
||||
|
||||
with app.test_request_context("/", method="POST", json=payload):
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
handler(resource(), session, SimpleNamespace(id="account-1"), app_model)
|
||||
handler(
|
||||
resource(),
|
||||
payload_model.model_validate(payload),
|
||||
session,
|
||||
SimpleNamespace(id="account-1"),
|
||||
app_model,
|
||||
)
|
||||
|
||||
get_draft_workflow.assert_called_once_with(app_model, session=session)
|
||||
|
||||
@ -855,7 +898,7 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp
|
||||
access_filter = SimpleNamespace(is_app_accessible=lambda app_id, _maintainer, _account_id: app_id == app_id_1)
|
||||
resolve_access = Mock(return_value=access_filter)
|
||||
monkeypatch.setattr(workflow_module, "resolve_app_access_filter", resolve_access)
|
||||
monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", True)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
||||
monkeypatch.setattr(workflow_module.file_helpers, "get_signed_file_url", sign_avatar)
|
||||
short_session = Mock()
|
||||
monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(short_session))
|
||||
@ -906,7 +949,8 @@ def test_workflow_online_users_filters_inaccessible_workflow(app: Flask, monkeyp
|
||||
method="POST",
|
||||
json={"app_ids": [app_id_1, app_id_2]},
|
||||
):
|
||||
response = handler(api, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": [app_id_1, app_id_2]})
|
||||
response = handler(api, args, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
|
||||
assert response == {
|
||||
"data": [
|
||||
@ -945,7 +989,7 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte
|
||||
"WorkflowService",
|
||||
lambda: SimpleNamespace(get_tenant_app_maintainers=lambda app_ids, tenant_id, session: dict.fromkeys(app_ids)),
|
||||
)
|
||||
monkeypatch.setattr(workflow_module.dify_config, "RBAC_ENABLED", False)
|
||||
apply_config_overrides(monkeypatch, RBAC_ENABLED=False)
|
||||
monkeypatch.setattr(workflow_module.session_factory, "create_session", lambda: nullcontext(Mock()))
|
||||
|
||||
first_pipeline = Mock()
|
||||
@ -963,7 +1007,8 @@ def test_workflow_online_users_batches_redis_reads(app: Flask, monkeypatch: pyte
|
||||
method="POST",
|
||||
json={"app_ids": app_ids},
|
||||
):
|
||||
response = handler(api, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": app_ids})
|
||||
response = handler(api, args, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
|
||||
assert len(response["data"]) == len(app_ids)
|
||||
assert redis_pipeline_factory.call_count == 2
|
||||
@ -989,8 +1034,9 @@ def test_workflow_online_users_rejects_excessive_workflow_ids(app: Flask, monkey
|
||||
method="POST",
|
||||
json={"app_ids": excessive_ids},
|
||||
):
|
||||
args = workflow_module.WorkflowOnlineUsersPayload.model_validate({"app_ids": excessive_ids})
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
handler(api, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
handler(api, args, "tenant-1", SimpleNamespace(id="account-1"))
|
||||
|
||||
assert exc.value.code == 400
|
||||
assert exc.value.description is not None
|
||||
|
||||
@ -18,6 +18,7 @@ from libs import login as login_lib
|
||||
from models import App, Tenant, WorkflowComment, WorkflowCommentMention, WorkflowCommentReply
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import AppMode, IconType
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
JAN_1_2024_NOON = datetime(2024, 1, 1, 12, 0, 0)
|
||||
JAN_1_2024_NOON_TS = int(JAN_1_2024_NOON.timestamp())
|
||||
@ -58,12 +59,11 @@ def _make_app() -> App:
|
||||
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None:
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
apply_config_overrides(monkeypatch, LOGIN_DISABLED=True, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps.dify_config, "DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model)
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@ class TestConvertToWorkflowApi:
|
||||
current_user.id = "u1"
|
||||
response = method(
|
||||
api,
|
||||
workflow_module.ConvertToWorkflowPayload.model_validate({}),
|
||||
current_tenant_id="tenant-1",
|
||||
current_user=current_user,
|
||||
app_model=_app("app-1"),
|
||||
|
||||
@ -5,7 +5,7 @@ from unittest.mock import ANY, MagicMock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.console import wraps as console_wraps
|
||||
from controllers.console.app import workflow as workflow_module
|
||||
@ -15,6 +15,7 @@ from libs import login as login_lib
|
||||
from models import App, Tenant
|
||||
from models.account import Account, AccountStatus, TenantAccountRole
|
||||
from models.model import AppMode, IconType
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
def _make_account() -> Account:
|
||||
@ -47,14 +48,17 @@ def _make_app(mode: AppMode) -> App:
|
||||
|
||||
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: App) -> None:
|
||||
# Skip setup and auth guardrails
|
||||
monkeypatch.setattr("configs.dify_config.DEPLOYMENT_EDITION", DeploymentEdition.CLOUD)
|
||||
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
|
||||
apply_config_overrides(
|
||||
monkeypatch,
|
||||
DEPLOYMENT_EDITION=DeploymentEdition.CLOUD,
|
||||
LOGIN_DISABLED=True,
|
||||
INIT_PASSWORD="",
|
||||
)
|
||||
monkeypatch.setattr(login_lib, "current_user", account)
|
||||
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
|
||||
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
|
||||
monkeypatch.setattr(console_wraps.dify_config, "INIT_PASSWORD", "")
|
||||
|
||||
# Avoid hitting the database when resolving the app model
|
||||
monkeypatch.setattr(app_wraps, "_load_app_model_from_scoped_session", lambda _app_id: app_model)
|
||||
@ -241,5 +245,5 @@ def test_human_input_preview_rejects_non_mapping(app: Flask, monkeypatch: pytest
|
||||
method="POST",
|
||||
json={"inputs": ["not-a-dict"]},
|
||||
):
|
||||
with pytest.raises(ValidationError):
|
||||
with pytest.raises(UnprocessableEntity):
|
||||
workflow_module.AdvancedChatDraftHumanInputFormPreviewApi().post(app_id=app_model.id, node_id="node-1")
|
||||
|
||||
@ -20,6 +20,7 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowPause, WorkflowRun, WorkflowType
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@ -77,7 +78,7 @@ class _PauseEntity:
|
||||
def test_pause_details_returns_backstage_input_url(
|
||||
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
||||
) -> None:
|
||||
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
|
||||
apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com")
|
||||
|
||||
tenant_id = str(uuid4())
|
||||
run_id = str(uuid4())
|
||||
@ -137,7 +138,7 @@ def test_pause_details_returns_backstage_input_url(
|
||||
|
||||
|
||||
def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
|
||||
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
|
||||
apply_config_overrides(monkeypatch, APP_WEB_URL="https://web.example.com")
|
||||
|
||||
run_id = str(uuid4())
|
||||
_persist_run(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user