Compare commits

..
Author SHA1 Message Date
twwu 15c1b63474 feat(tools): support tool-generated A2UI parts
Stream, validate, persist, and render constrained UI parts from tools, including the current-time component. Preserve markdown following think blocks as normal answer content.
2026-07-24 10:46:12 +08:00
1040 changed files with 16835 additions and 37420 deletions
+152 -24
View File
@@ -1,40 +1,168 @@
---
name: backend-code-review
description: Use only when the user explicitly requests a review or audit of backend code under `api/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, frontend code, or backend code outside `api/`.
description: Review backend code for quality, security, maintainability, and best practices based on established checklist rules. Use when the user requests a review, analysis, or improvement of backend files (e.g., `.py`) under the `api/` directory. Do NOT use for frontend files (e.g., `.tsx`, `.ts`, `.js`). Supports pending-change review, code snippets review, and file-focused review.
---
# Backend Code Review
Review the requested scope for concrete, reproducible defects. The nearest `AGENTS.md` owns package facts and commands; this skill owns the review workflow and routes to its bundled rule packs.
## When to use this skill
## Evidence First
Use this skill whenever the user asks to **review, analyze, or improve** backend code (e.g., `.py`) under the `api/` directory. Supports the following review modes:
1. Establish the requested review scope and inspect the relevant diff or files.
2. Read the changed lines, their behavior owner, nearby tests, and local docstrings or comments that define contracts.
3. Trace callers, persistence boundaries, authorization, generated schemas, or external I/O only when they decide correctness.
4. Report only findings tied to an observable failure, violated contract, security boundary, data integrity risk, or demonstrated maintenance problem.
- **Pending-change review**: when the user asks to review current changes (inspect staged/working-tree files slated for commit to get the changes).
- **Code snippets review**: when the user pastes code snippets (e.g., a function/class/module excerpt) into the chat and asks for a review.
- **File-focused review**: when the user points to specific files and asks for a review of those files (one file or a small, explicit set of files, e.g., `api/...`, `api/app.py`).
## Rule Routing
Do NOT use this skill when:
Read only the packs matched by the diff:
- The request is about frontend code or UI (e.g., `.tsx`, `.ts`, `.js`, `web/`).
- The user is not asking for a review/analysis/improvement of backend code.
- The scope is not under `api/` (unless the user explicitly asks to review backend-related changes outside `api/`).
- Models or migrations: [`references/db-schema-rule.md`][db-schema]
- Controller, service, core/domain, library, or model dependency direction: [`references/architecture-rule.md`][architecture]
- Table access outside an established repository boundary: [`references/repositories-rule.md`][repositories]
- SQLAlchemy sessions, queries, transactions, CRUD, concurrency, or raw SQL: [`references/sqlalchemy-rule.md`][sqlalchemy]
## How to use this skill
When no pack applies, review correctness, security, behavior changes, and test evidence directly. Check current official documentation only when local code and contracts do not settle framework or library behavior.
Follow these steps when using this skill:
## Severity And Output
1. **Identify the review mode** (pending-change vs snippet vs file-focused) based on the users input. Keep the scope tight: review only what the user provided or explicitly referenced.
2. Follow the rules defined in **Checklist** to perform the review. If no Checklist rule matches, apply **General Review Rules** as a fallback to perform the best-effort review.
3. Compose the final output strictly follow the **Required Output Format**.
- **P0**: security or privacy exposure, data loss, or a production-wide outage.
- **P1**: user-visible regression, broken authorization or tenant isolation, invalid public contract, or failed primary workflow.
- **P2**: concrete correctness, performance, maintainability, or test defect likely to cause incorrect behavior.
- **P3**: minor actionable cleanup; omit unless the user requested a thorough audit.
Notes when using this skill:
- Always include actionable fixes or suggestions (including possible code snippets).
- Use best-effort `File:Line` references when a file path and line numbers are available; otherwise, use the most specific identifier you can.
Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes.
## Checklist
[architecture]: references/architecture-rule.md
[db-schema]: references/db-schema-rule.md
[repositories]: references/repositories-rule.md
[sqlalchemy]: references/sqlalchemy-rule.md
- db schema design: if the review scope includes code/files under `api/models/` or `api/migrations/`, follow [references/db-schema-rule.md](references/db-schema-rule.md) to perform the review
- architecture: if the review scope involves controller/service/core-domain/libs/model layering, dependency direction, or moving responsibilities across modules, follow [references/architecture-rule.md](references/architecture-rule.md) to perform the review
- repositories abstraction: if the review scope contains table/model operations (e.g., `select(...)`, `session.execute(...)`, joins, CRUD) and is not under `api/repositories`, `api/core/repositories`, or `api/extensions/*/repositories/`, follow [references/repositories-rule.md](references/repositories-rule.md) to perform the review
- sqlalchemy patterns: if the review scope involves SQLAlchemy session/query usage, db transaction/crud usage, or raw SQL usage, follow [references/sqlalchemy-rule.md](references/sqlalchemy-rule.md) to perform the review
## General Review Rules
### 1. Security Review
Check for:
- SQL injection vulnerabilities
- Server-Side Request Forgery (SSRF)
- Command injection
- Insecure deserialization
- Hardcoded secrets/credentials
- Improper authentication/authorization
- Insecure direct object references
### 2. Performance Review
Check for:
- N+1 queries
- Missing database indexes
- Memory leaks
- Blocking operations in async code
- Missing caching opportunities
### 3. Code Quality Review
Check for:
- Code forward compatibility
- Code duplication (DRY violations)
- Functions doing too much (SRP violations)
- Deep nesting / complex conditionals
- Magic numbers/strings
- Poor naming
- Missing error handling
- Incomplete type coverage
### 4. Testing Review
Check for:
- Missing test coverage for new code
- Tests that don't test behavior
- Flaky test patterns
- Missing edge cases
## Required Output Format
When this skill invoked, the response must exactly follow one of the two templates:
### Template A (any findings)
```markdown
# Code Review Summary
Found <X> critical issues need to be fixed:
## 🔴 Critical (Must Fix)
### 1. <brief description of the issue>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<detailed explanation and references of the issue>
#### Suggested Fix
1. <brief description of suggested fix>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each critical issue) ...
Found <Y> suggestions for improvement:
## 🟡 Suggestions (Should Consider)
### 1. <brief description of the suggestion>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<detailed explanation and references of the suggestion>
#### Suggested Fix
1. <brief description of suggested fix>
2. <code example> (optional, omit if not applicable)
---
... (repeat for each suggestion) ...
Found <Z> optional nits:
## 🟢 Nits (Optional)
### 1. <brief description of the nit>
FilePath: <path> line <line>
<relevant code snippet or pointer>
#### Explanation
<explanation and references of the optional nit>
#### Suggested Fix
- <minor suggestions>
---
... (repeat for each nits) ...
## ✅ What's Good
- <Positive feedback on good patterns>
```
- If there are no critical issues or suggestions or option nits or good points, just omit that section.
- If the issue number is more than 10, summarize as "Found 10+ critical issues/suggestions/optional nits" and only output the first 10 items.
- Don't compress the blank lines between sections; keep them as-is for readability.
- If there is any issue requires code changes, append a brief follow-up question to ask whether the user wants to apply the fix(es) after the structured output. For example: "Would you like me to use the Suggested fix(es) to address these issues?"
### Template B (no issues)
```markdown
## Code Review Summary
✅ No issues found.
```
@@ -7,6 +7,7 @@
### Keep business logic out of controllers
- Category: maintainability
- Severity: critical
- Description: Controllers should parse input, call services, and return serialized responses. Business decisions inside controllers make behavior hard to reuse and test.
- Suggested fix: Move domain/business logic into the service or core/domain layer. Keep controller handlers thin and orchestration-focused.
- Example:
@@ -33,6 +34,7 @@
### Preserve layer dependency direction
- Category: best practices
- Severity: critical
- Description: Controllers may depend on services, and services may depend on core/domain abstractions. Reversing this direction (for example, core importing controller/web modules) creates cycles and leaks transport concerns into domain code.
- Suggested fix: Extract shared contracts into core/domain or service-level modules and make upper layers depend on lower, not the reverse.
- Example:
@@ -56,6 +58,7 @@
### Keep libs business-agnostic
- Category: maintainability
- Severity: critical
- Description: Modules under `api/libs/` should remain reusable, business-agnostic building blocks. They must not encode product/domain-specific rules, workflow orchestration, or business decisions.
- Suggested fix:
- If business logic appears in `api/libs/`, extract it into the appropriate `services/` or `core/` module and keep `libs` focused on generic, cross-cutting helpers.
@@ -85,4 +88,4 @@
def should_archive_conversation(conversation, tenant_id: str) -> bool:
threshold_days = 90 if has_paid_plan(tenant_id) else 30
return older_than_days(conversation.idle_days, threshold_days)
```
```
@@ -8,6 +8,7 @@
### Do not query other tables inside `@property`
- Category: [maintainability, performance]
- Severity: critical
- Description: A model `@property` must not open sessions or query other tables. This hides dependencies across models, tightly couples schema objects to data access, and can cause N+1 query explosions when iterating collections.
- Suggested fix:
- Keep model properties pure and local to already-loaded fields.
@@ -40,6 +41,7 @@
### Prefer including `tenant_id` in model definitions
- Category: maintainability
- Severity: suggestion
- Description: In multi-tenant domains, include `tenant_id` in schema definitions whenever the entity belongs to tenant-owned data. This improves data isolation safety and keeps future partitioning/sharding strategies practical as data volume grows.
- Suggested fix:
- Add a `tenant_id` column and ensure related unique/index constraints include tenant dimension when applicable.
@@ -68,6 +70,7 @@
### Detect and avoid duplicate/redundant indexes
- Category: performance
- Severity: suggestion
- Description: Review index definitions for leftmost-prefix redundancy. For example, index `(a, b, c)` can safely cover most lookups for `(a, b)`. Keeping both may increase write overhead and can mislead the optimizer into suboptimal execution plans.
- Suggested fix:
- Before adding an index, compare against existing composite indexes by leftmost-prefix rules.
@@ -91,6 +94,7 @@
### Avoid PostgreSQL-only dialect usage in models; wrap in `models.types`
- Category: maintainability
- Severity: critical
- Description: Model/schema definitions should avoid PostgreSQL-only constructs directly in business models. When database-specific behavior is required, encapsulate it in `api/models/types.py` using both PostgreSQL and MySQL dialect implementations, then consume that abstraction from model code.
- Suggested fix:
- Do not directly place dialect-only types/operators in model columns when a portable wrapper can be used.
@@ -118,6 +122,7 @@
### Guard migration incompatibilities with dialect checks and shared types
- Category: maintainability
- Severity: critical
- Description: Migration scripts under `api/migrations/versions/` must account for PostgreSQL/MySQL incompatibilities explicitly. For dialect-sensitive DDL or defaults, branch on the active dialect (for example, `conn.dialect.name == "postgresql"`), and prefer reusable compatibility abstractions from `models.types` where applicable.
- Suggested fix:
- In migration upgrades/downgrades, bind connection and branch by dialect for incompatible SQL fragments.
@@ -8,6 +8,7 @@
### Introduce repositories abstraction
- Category: maintainability
- Severity: suggestion
- Description: If a table/model already has a repository abstraction, all reads/writes/queries for that table should use the existing repository. If no repository exists, introduce one only when complexity justifies it, such as large/high-volume tables, repeated complex query logic, or likely storage-strategy variation.
- Suggested fix:
- First check `api/repositories`, `api/core/repositories`, and `api/extensions/*/repositories/` to verify whether the table/model already has a repository abstraction. If it exists, route all operations through it and add missing repository methods instead of bypassing it with ad-hoc SQLAlchemy access.
@@ -8,6 +8,7 @@
### Use Session context manager with explicit transaction control behavior
- Category: best practices
- Severity: critical
- Description: Session and transaction lifecycle must be explicit and bounded on write paths. Missing commits can silently drop intended updates, while ad-hoc or long-lived transactions increase contention, lock duration, and deadlock risk.
- Suggested fix:
- Use **explicit `session.commit()`** after completing a related write unit.
@@ -46,6 +47,7 @@
### Enforce tenant_id scoping on shared-resource queries
- Category: security
- Severity: critical
- Description: Reads and writes against shared tables must be scoped by `tenant_id` to prevent cross-tenant data leakage or corruption.
- Suggested fix: Add `tenant_id` predicate to all tenant-owned entity queries and propagate tenant context through service/repository interfaces.
- Example:
@@ -65,6 +67,7 @@
### Prefer SQLAlchemy expressions over raw SQL by default
- Category: maintainability
- Severity: suggestion
- Description: Raw SQL should be exceptional. ORM/Core expressions are easier to evolve, safer to compose, and more consistent with the codebase.
- Suggested fix: Rewrite straightforward raw SQL into SQLAlchemy `select/update/delete` expressions; keep raw SQL only when required by clear technical constraints.
- Example:
@@ -86,6 +89,7 @@
### Protect write paths with concurrency safeguards
- Category: quality
- Severity: critical
- Description: Multi-writer paths without explicit concurrency control can silently overwrite data. Choose the safeguard based on contention level, lock scope, and throughput cost instead of defaulting to one strategy.
- Suggested fix:
- **Optimistic locking**: Use when contention is usually low and retries are acceptable. Add a version (or updated_at) guard in `WHERE` and treat `rowcount == 0` as a conflict.
@@ -132,4 +136,4 @@
).scalar_one()
run.status = "cancelled"
session.commit()
```
```
+73 -17
View File
@@ -1,31 +1,87 @@
---
name: e2e-cucumber-playwright
description: Use when writing, changing, or reviewing Cucumber and Playwright tests under `e2e/`, including feature files, step definitions, support code, scenario tags, locators, and assertions. Do not use for Vitest, React Testing Library, backend tests, or generic browser automation outside the E2E suite.
description: Write, update, or review Dify end-to-end tests under `e2e/` that use Cucumber, Gherkin, and Playwright. Use when the task involves `.feature` files, `features/step-definitions/`, `features/support/`, `DifyWorld`, scenario tags, locator/assertion choices, or E2E testing best practices for this repository.
---
# E2E Cucumber And Playwright
# Dify E2E Cucumber + Playwright
`e2e/AGENTS.md` owns the suite architecture, lifecycle, commands, tags, generated-client boundaries, fixtures, and cleanup contracts. Read the nearest feature-scoped `AGENTS.md` when one exists. This skill adds no parallel package policy.
Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical package guide for local architecture and conventions, then read any feature-scoped `AGENTS.md` that owns the target area. Apply Playwright/Cucumber best practices only where they fit the current suite.
## Topic Routing
## Scope
Read only the bundled reference required by the change:
- Use this skill for `.feature` files, Cucumber step definitions, `DifyWorld`, hooks, tags, and E2E review work under `e2e/`.
- Do not use this skill for Vitest or React Testing Library work under `web/`; use `frontend-testing` instead.
- Do not use this skill for backend test or API review tasks under `api/`.
- Locator, assertion, isolation, or waiting decisions: [`references/playwright-best-practices.md`][playwright]
- Scenario wording, step granularity, expressions, or tag design: [`references/cucumber-best-practices.md`][cucumber]
## Read Order
Check current official Playwright or Cucumber documentation before introducing a framework pattern that local code and references do not already establish.
1. Read [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) first.
2. Read only the files directly involved in the task:
- target `.feature` files under `e2e/features/`
- related step files under `e2e/features/step-definitions/`
- `e2e/features/support/hooks.ts` and `e2e/features/support/world.ts` when session lifecycle or shared state matters
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern.
Keep this skill focused on Cucumber, Playwright, and package-level E2E guidance. Put feature-specific conventions in the owning feature's `AGENTS.md` instead of adding them here.
## Local Rules
- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer.
- `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions.
- Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps.
- Treat `e2e/AGENTS.md`, `features/support/hooks.ts`, and the Cucumber configuration as the owners of current session and tag semantics. Verify them when behavior depends on session state instead of copying a tag inventory into this skill.
- Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture.
- Perform the behavior under test through Playwright. APIs are allowed for setup, seed preparation, persistence polling, and cleanup, but ordinary Console JSON and representable multipart operations must use the scenario- or process-owned generated oRPC client with request and response validation enabled. Keep the setup/cleanup API identity independent from an unauthenticated or logged-out behavior browser.
- Consume generated operations directly. Do not add one-to-one API wrappers, handwritten endpoint URLs, response DTO casts, duplicate schemas, global mutable clients, or TanStack Query caching in Cucumber. Keep helpers only for real fixture construction, multi-operation orchestration, invariants, polling, derived test views, or protocol adapters.
- Keep SSE, binary, redirect-only, external-service, and readiness exceptions centralized under their protocol owner. A contract mismatch must fail and be fixed at the backend schema owner followed by regeneration; never weaken validation to make E2E pass.
## Workflow
1. Add E2E coverage only for a critical user journey with a cross-boundary outcome that cheaper owner-level tests do not already prove.
2. Identify the user-visible behavior and its feature owner. Start from real product defaults and actor roles; setup may establish preconditions but must not manufacture the opposite state to make the scenario meaningful.
3. Read the target scenario, matching step definitions, and lifecycle files only when session or shared state matters.
4. Reuse an existing step when wording and behavior match; add one coherent scenario or step when they do not.
5. Keep browser actions and assertions at the public user boundary; keep setup, seed, polling, and cleanup at their package-defined owners.
6. Run the narrowest tagged scenario and package checks documented in `e2e/AGENTS.md`; broaden only for shared hooks, tags, or support changes.
1. Rebuild local context.
- Inspect the target feature area.
- Reuse an existing step when wording and behavior already match.
- Add a new step only for a genuinely new user action or assertion.
- Before adding several similar steps, scan the target capability for an existing domain noun that can be parameterized without hiding behavior.
- Keep edits close to the current capability folder unless the step is broadly reusable.
2. Write behavior-first scenarios.
- Describe user-observable behavior, not DOM mechanics.
- Keep each scenario focused on one workflow or outcome.
- Keep scenarios independent and re-runnable.
3. Write step definitions in the local style.
- Keep one step to one user-visible action or one assertion.
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
- Use a bounded regex only when the accepted values are a small explicit domain set and Cucumber Expressions would make the Gherkin less natural.
- Do not create one-off steps for each case variant when the same domain action or outcome applies to named surfaces, modes, or resources.
- Scope locators to stable containers when the page has repeated elements.
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
4. Use Playwright in the local style.
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
- Use web-first `expect(...)` assertions.
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
- Use `expect.poll` for API persistence, backend eventual consistency, captured browser events, or other non-DOM state; prefer locator assertions for DOM readiness and visible UI state.
- If a product element has real user-facing semantics but no accessible name, prefer fixing that accessible contract over adding a test id.
5. Validate narrowly.
- Run the narrowest tagged scenario or flow that exercises the change.
- Run the package-required static checks documented in `e2e/AGENTS.md`.
- Broaden verification only when the change affects hooks, tags, setup, or shared step semantics.
For review requests, lead with reproducible correctness failures, flake sources, or demonstrated architecture drift. Report the behavior verified and any external-runtime, browser, or environment gap.
## Review Checklist
[cucumber]: references/cucumber-best-practices.md
[playwright]: references/playwright-best-practices.md
- Does the scenario describe behavior rather than implementation?
- Does it fit the current session model, tags, and `DifyWorld` usage?
- Should an existing step be reused instead of adding a new one?
- Are locators user-facing and assertions web-first?
- Does the change introduce hidden coupling across scenarios, tags, or instance state?
- Does it document or implement behavior that differs from the real hooks or configuration?
- Does setup/cleanup use the generated client directly, with any remaining helper owning more than a one-to-one endpoint forward?
- Is every raw HTTP call a documented protocol or infrastructure exception rather than an ordinary Console operation?
Lead findings with correctness, flake risk, and architecture drift.
## References
- [`references/playwright-best-practices.md`](references/playwright-best-practices.md)
- [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md)
@@ -1,12 +1,12 @@
# Cucumber Best Practices
# Cucumber Best Practices For Dify E2E
Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse.
Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse in Dify's `e2e/` suite.
Official sources:
- https://cucumber.io/docs/guides/10-minute-tutorial
- https://cucumber.io/docs/cucumber/step-definitions
- https://cucumber.io/docs/cucumber/cucumber-expressions
- https://cucumber.io/docs/guides/10-minute-tutorial/
- https://cucumber.io/docs/cucumber/step-definitions/
- https://cucumber.io/docs/cucumber/cucumber-expressions/
## What Matters Most
@@ -24,7 +24,11 @@ Apply it like this:
A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it.
Keep each scenario centered on one coherent outcome. Avoid hidden dependencies on another scenario's side effects, and keep unavoidable setup outside the behavior narrative unless the precondition matters to the specification.
In Dify's suite, this means:
- one capability-focused scenario per feature path
- no long setup chains when existing bootstrap or reusable steps already cover them
- no hidden dependency on another scenario's side effects
### 3. Reuse steps, but only when behavior really matches
@@ -64,11 +68,26 @@ Use regex for a bounded natural-language alternative only when it keeps Gherkin
Step definitions are glue between Gherkin and automation, not a second abstraction language.
Keep each step to one user-visible action or assertion. In JavaScript and TypeScript, use `async function` when the step reads Cucumber World state because Cucumber binds `this`; do not leak state across scenarios through module globals.
For Dify:
- type `this` as `DifyWorld`
- use `async function`
- keep each step to one user-visible action or assertion
- rely on `DifyWorld` and existing support code for shared context
- avoid leaking cross-scenario state
### 6. Use tags intentionally
Tags should communicate selection or execution intent, not become ad hoc metadata. A tag does not change runtime behavior unless configuration or hooks implement it.
Tags should communicate run scope or session semantics, not become ad hoc metadata.
In Dify's current suite:
- capability tags group related scenarios
- `@unauthenticated` changes session behavior
- `@authenticated` is descriptive/selective, not a behavior switch by itself
- `@fresh` belongs to reset/full-install flows only
If a proposed tag implies behavior, verify that hooks or runner configuration actually implement it.
## Review Questions
@@ -1,6 +1,6 @@
# Playwright Best Practices
# Playwright Best Practices For Dify E2E
Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic.
Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic for Dify's Cucumber-based E2E suite.
Official sources:
@@ -13,19 +13,20 @@ Official sources:
### 1. Keep scenarios isolated
Playwright's model is built around clean browser contexts so one test does not leak into another.
Playwright's model is built around clean browser contexts so one test does not leak into another. In Dify's suite, that principle maps to per-scenario session setup in `features/support/hooks.ts` and `DifyWorld`.
Apply it like this:
- do not depend on another scenario having run first
- keep scenario state in the runner's scenario-owned context rather than module globals
- model special authentication or session setup through explicit per-scenario fixtures rather than shared mutable state
- do not persist ad hoc scenario state outside `DifyWorld`
- do not couple ordinary scenarios to `@fresh` behavior
- when a flow needs special auth/session semantics, express that through the existing tag model or explicit hook changes
### 2. Prefer user-facing locators
Playwright recommends built-in locators that reflect what users perceive on the page.
Preferred order:
Preferred order in this repository:
1. `getByRole`
2. `getByLabel`
@@ -78,9 +79,16 @@ Bad pattern:
- stack arbitrary waits before every action
- wait on unstable implementation details instead of the visible state the user cares about
### 5. Match debugging to the active harness
### 5. Match debugging to the current suite
Playwright supports traces, screenshots, page snapshots, and browser logs. Configure artifact capture at the runner boundary instead of adding parallel diagnostics to individual scenarios.
Playwright's wider ecosystem supports traces and rich debugging tools. Dify's current suite already captures:
- full-page screenshots
- page HTML
- console errors
- page errors
Use the existing artifact flow by default. If a task is specifically about improving diagnostics, confirm the change fits the current Cucumber architecture before importing broader Playwright tooling.
## Review Questions
@@ -88,4 +96,4 @@ Playwright supports traces, screenshots, page snapshots, and browser logs. Confi
- Is this assertion using Playwright's retrying semantics?
- Is any explicit wait masking a real readiness problem?
- Does this code preserve per-scenario isolation?
- Is a new abstraction really needed, or does it bypass the runner's scenario-owned context and lifecycle?
- Is a new abstraction really needed, or does it bypass the existing `DifyWorld` + step-definition model?
+78 -32
View File
@@ -1,48 +1,94 @@
---
name: frontend-code-review
description: Use only when the user explicitly requests a review or audit of frontend code under `web/` or `packages/dify-ui/`. Supports pending-change, file-focused, and pasted-diff reviews. Do not use for implementation-only requests, diagnosis without review intent, or backend-only code.
description: Review Dify frontend code for correctness, accessibility, component design, dify-ui usage, data/query boundaries, performance, and tests. Trigger for `.tsx`, `.ts`, `.js`, UI, React, Next.js, pending-change, or focused frontend review requests.
---
# Frontend Code Review
Review the requested scope for concrete, reproducible regressions. This skill owns the review phase and routes directly to its bundled rule packs. For a combined review-and-fix request, establish findings before applying implementation or testing guidance.
## When To Use
## Evidence First
Use this skill when the user asks to review, audit, analyze, or sanity-check frontend code under `web/`, `packages/dify-ui/`, or frontend-adjacent TypeScript files.
1. Establish the review scope from the requested files or current diff.
2. Read the changed lines, their behavior owner, and the nearest scoped `AGENTS.md`.
3. Trace public consumers, generated contracts, primitive APIs, or runtime configuration only when they decide correctness.
4. Report only findings tied to an observable failure, violated contract, security boundary, or demonstrated maintenance risk.
Supported modes:
## Rule Routing
- **Pending-change review**: inspect staged and working-tree changes.
- **File-focused review**: inspect explicitly named files or paths.
- **Diff/snippet review**: review pasted diffs or snippets using best-effort references.
Read only the packs matched by the diff:
Do not use this skill for backend-only code under `api/`; use `backend-code-review` instead.
- DOM semantics, focus, keyboard, forms, disabled state, or visible interaction: [`references/accessibility-ui.md`][accessibility]
- Dify UI imports, Base UI wrappers, overlays, tokens, or primitive contracts: [`references/dify-ui.md`][dify-ui]
- Component ownership, props, state, Effects, navigation, or module boundaries: [`references/component-architecture.md`][component-architecture]
- Generated clients, Query, mutations, auth, SSR, URL state, or persistence: [`references/data-query-contracts.md`][data-query]
- Test files or a concrete missing-regression-test finding: [`references/testing.md`][testing]
- Bundle, waterfall, rendering, or subscription cost supported by evidence: [`references/performance.md`][performance]
- Stable Dify runtime invariants in the named paths: [`references/dify-invariants.md`][dify-invariants]
- General TypeScript or styling quality not owned above: [`references/code-quality.md`][code-quality]
## Required Context
Read `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, `web/docs/overlay.md`, or `web/docs/test.md` only when the reviewed code falls under that contract. Check current official documentation when local code and bundled references do not settle a framework, browser, or accessibility behavior.
Before reviewing, read the relevant local contracts:
## Severity And Output
- `web/AGENTS.md` for Dify frontend workflow, overlays, design tokens, state, and tests.
- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`.
- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI.
- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability.
- `karpathy-guidelines` for scope control and focused, verifiable changes.
- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization.
- **P0**: security or privacy leak, data loss, production crash, or inaccessible critical workflow.
- **P1**: user-visible regression, invalid API or authorization contract, hydration failure, or broken primary interaction.
- **P2**: concrete maintainability, performance, test, or accessibility defect likely to cause incorrect behavior.
- **P3**: minor actionable cleanup; omit unless the user requested a thorough audit.
For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth:
Lead with findings ordered by severity. Include a tight file and line reference, the failing contract or reproduction path, impact, and a concrete fix direction. If there are no findings, say `No issues found.` and state any material verification gap. Do not add praise sections, speculative risks, or an unsolicited offer to implement fixes.
```text
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
[accessibility]: references/accessibility-ui.md
[code-quality]: references/code-quality.md
[component-architecture]: references/component-architecture.md
[data-query]: references/data-query-contracts.md
[dify-invariants]: references/dify-invariants.md
[dify-ui]: references/dify-ui.md
[performance]: references/performance.md
[testing]: references/testing.md
If the review depends on a current framework, SDK, browser API, or accessibility behavior and local code does not settle it, check the current official docs first. For browser compatibility, deprecation, or behavior-sensitive frontend APIs, verify MDN or the relevant standard.
## Rule Packs
Apply every relevant rule pack:
- [references/accessibility-ui.md](references/accessibility-ui.md) — accessibility, semantic HTML, focus, forms, keyboard, disabled states, copy, and long-content behavior. Combines Web Interface Guidelines with Dify UI, Base UI, MDN, and local primitive contracts.
- [references/dify-ui.md](references/dify-ui.md) — Dify UI primitive usage, Base UI semantics, overlays, forms, tokens, radius mapping, and primitive boundaries.
- [references/component-architecture.md](references/component-architecture.md) — component ownership, props, state, effects, exports, wrappers, and feature organization.
- [references/data-query-contracts.md](references/data-query-contracts.md) — generated contracts, TanStack Query, mutations, workspace/auth/SSR boundaries, URL/local storage state.
- [references/performance.md](references/performance.md) — React/Next performance review rules from Vercel guidance, scoped to real risk.
- [references/testing.md](references/testing.md) — frontend test review rules.
- [references/dify-invariants.md](references/dify-invariants.md) — stable Dify-specific runtime invariants that generic React/a11y rules will not catch.
- [references/code-quality.md](references/code-quality.md) — general TypeScript, styling, naming, and maintainability rules.
## Review Process
1. Identify the review scope. For pending changes, inspect `git diff --stat`, `git diff`, and staged diff if relevant. For file-focused reviews, stay within the named files unless a referenced owner/contract must be read.
2. Read code around the changed lines and the owning module. Do not review by isolated snippets when nearby ownership, labels, query inputs, or overlay structure decide correctness.
3. Check user-visible regressions first: accessibility, broken interaction, auth/permission leaks, query/hydration errors, data loss, navigation mistakes, and impossible states.
4. Then check maintainability and performance: ownership, effects, wrappers, memoization, bundle/waterfall risks, tests, and design-system drift.
5. Report only actionable findings. Do not list speculative risks, style preferences, or broad refactors unless they are directly tied to a reproducible issue in scope.
## Severity
- **P0**: security/privacy/auth leak, data loss, production crash, inaccessible critical flow, or broken primary workflow.
- **P1**: user-visible regression, hydration/SSR failure, invalid API/query contract, broken keyboard/focus behavior, or serious design-system/a11y violation.
- **P2**: maintainability or performance issue likely to cause bugs, duplicated state, incorrect ownership, missing tests for risky behavior, or non-critical a11y issue.
- **P3**: minor cleanup with clear value. Omit unless the user asked for a thorough audit.
## Output Format
Lead with findings, ordered by severity. Use this structure:
```markdown
## Findings
- [P1] Short issue title
File: `path/to/file.tsx:123`
Why it matters and how to reproduce or reason about it.
Suggested fix: concrete fix direction.
## Open Questions
- Question or assumption, if any.
## Summary
Brief secondary context. Mention tests not run or residual risk.
```
Rules:
- If there are no findings, say `No issues found.` and mention any test gaps or residual risk.
- Always include file and line when available.
- Keep findings concrete and reproducible.
- Do not include praise sections by default.
- Do not ask to apply fixes unless the user explicitly wants review plus implementation.
@@ -46,13 +46,14 @@ When existing components already own interaction logic, prefer reusing or extend
Flag:
- Declaration or export rewrites made only for stylistic uniformity, without changing an owned behavior or contract.
- `React.FC` / `FC`.
- Default exports outside framework-required files.
- Named `Props` types for trivial one-off props where inline typing is clearer.
- Props named by UI implementation instead of domain/API role.
- API data converted too early or under a generic name that breaks traceability.
- Callers duplicating fallback checks that the lowest rendering component already handles.
Do not flag `FC`, `React.FC`, function declarations, arrow functions, named exports, or default exports by syntax alone. Report them only when the chosen form causes a concrete type, lifecycle, export, framework, or enforced package-contract defect.
Prefer top-level `function` declarations for components and module helpers. Use arrow functions for callbacks and local lambdas.
## Effects
@@ -12,7 +12,7 @@ Flag:
- Re-declaring API DTOs in components.
- Adding compatibility layers instead of migrating the pointed line and deleting the old layer.
Backend Pydantic and OpenAPI schemas own API shape. Generated clients and schemas under `packages/contracts/generated/*` are authoritative at frontend boundaries and use the `{ params, query?, body? }` input shape.
Use `web/contract/*` as the API shape source of truth. Follow existing `{ params, query?, body? }` input shape.
## Queries
@@ -122,7 +122,7 @@ Flag:
- Manual class strings that duplicate primitive variants.
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
Use the Figma radius mapping from `packages/dify-ui/README.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
Use the Figma radius mapping from `packages/dify-ui/AGENTS.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
+28 -9
View File
@@ -1,16 +1,35 @@
---
name: frontend-testing
description: Use when writing or changing Vitest or React Testing Library tests under `web/` or `packages/dify-ui/`, or when the user explicitly requests frontend test strategy, including evaluation of an existing strategy. Do not use for frontend code-review-only requests, general testability discussion, Python tests, or Cucumber/Playwright E2E.
description: Write, update, or review Dify frontend tests using Vitest and Testing Library. Trigger for frontend specs, test coverage requests, regressions, testability, or testing strategy under web/ or packages/dify-ui/.
---
# Frontend Testing
# Dify Frontend Testing
`web/docs/test.md` is the single policy owner. Read it before changing frontend tests; this skill adds no separate requirements.
Use this skill for Vitest work under `web/` and `packages/dify-ui/`. Do not use it for Python tests or Cucumber/Playwright tests under `e2e/`.
1. Identify the observable contract and regression risk.
2. Choose the smallest boundary that includes the behavior owner.
3. Establish the failing case first when practical, then implement one coherent scenario.
4. Run the focused spec before the affected suite and relevant static checks.
5. Report the behavior verified and any remaining browser, visual, or end-to-end risk.
## Required Source
Recommend deleting low-value tests as readily as adding missing behavior coverage. Use `web/docs/test.md` for policy and Web commands; use the `packages/dify-ui/README.md` Development section for Dify UI commands.
Before writing, changing, or reviewing frontend tests, read `web/docs/test.md` completely. It is the single source of truth. This skill provides an execution checklist and must not redefine or extend that policy.
## Workflow
1. Read the source, its behavior owner, nearby specs, and relevant public dependencies.
1. Apply the canonical guide to decide whether a test is needed and choose its boundary.
1. For a behavior change or bug fix, write or identify the failing scenario first when practical.
1. Implement one coherent scenario at a time and run the focused spec before expanding scope.
1. Finish with the affected suite and relevant repository checks.
1. Report what behavior was verified and any risk that still requires browser, visual, or end-to-end validation.
When reviewing existing tests, recommend deleting low-value tests as readily as adding missing behavior coverage.
Run focused tests from the owning workspace:
```bash
# web/
vp test run path/to/spec-or-directory
# packages/dify-ui/
vp test run --project unit src/path/to/spec
```
Run Dify UI Storybook tests with `vp test --project storybook --run`. Run broader checks only after the focused behavior passes.
+129 -26
View File
@@ -1,41 +1,144 @@
---
name: how-to-write-component
description: Use when implementing or refactoring React/TypeScript components and the task requires decisions about component ownership, feature boundaries, state, data flow, effects, or interaction ownership. Do not use for review-only requests, test-only work, copy-only edits, or styling-only changes.
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this skill to route component architecture decisions to its bundled references. Read only the references required by the current change.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
## First Decisions
| Question | Default | Promote only when |
| Question | Default | Promote or extract only 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. |
| 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. |
| Is a wrapper needed? | Use the primitive or direct code. | The wrapper owns behavior, validation, state, or semantics. |
| Is an Effect needed? | Derive during render or handle the user action. | A named external system must be synchronized. |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Where should a hotkey live? | Keep a single-owner hotkey constant in its component. | Multiple production files share one command, or the feature owns a real command registry with shared metadata and behavior. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Topic Routing
## Core Defaults
- 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.
- Effects, navigation, memoization, preloading, or render cost: read [`references/runtime.md`][runtime].
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
## Workflow
## Layout And Ownership
1. Identify the behavior owner and the public contract being changed.
2. Read the nearby implementation, tests, and only the routed skill references.
3. Implement one coherent vertical slice. Do not expand into equivalent patterns elsewhere unless the current contract cannot be completed without them.
4. Verify observable behavior at the narrowest sufficient boundary, then run the checks documented by the owning package: `web/docs/test.md` or `web/docs/lint.md` for Web, and the `packages/dify-ui/README.md` Development section for Dify UI.
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow.
- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action.
[data]: references/data.md
[interactions]: references/interactions.md
[ownership]: references/ownership.md
[runtime]: references/runtime.md
[state]: references/state.md
## Feature-Scoped Jotai
- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state.
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- `jotai-tanstack-query` query atoms do not support TanStack Query tracked properties. A component that reads `useAtomValue(queryAtom)` subscribes to the whole query result, even if it only accesses `data`, `isLoading`, or `isError`. Export field-specific derived atoms and have components read the exact fields they render; use `selectAtom(queryAtom, result => result.field)` for query-result fields so unchanged selections do not notify subscribers. Keep direct `useAtomValue(queryAtom)` only when the component or hook genuinely needs the full observer result.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.
- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`.
- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local.
- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Preserve domain value types for selection components. Do not widen enum, union, boolean, numeric, object, or nullable select/radio values to `string`; keep wrappers and option value carriers typed from their feature option collection.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
## Keyboard Shortcuts
- Distinguish application commands from local keyboard semantics before choosing an API. Use `@tanstack/react-hotkeys` for application commands. Keep menu navigation, dialog Escape handling owned by a primitive, editor commands, and other widget-scoped ARIA interactions in their local component or primitive.
- Use `useHotkey` or `useHotkeys` for registered commands. For a command intentionally owned by an existing `onKeyDown`, use `matchesKeyboardEvent` instead of hand-written `metaKey` / `ctrlKey` parsing or a second global listener.
- Define a reusable string command with `satisfies Hotkey` and an object-form command with `satisfies RawHotkey`. Reserve `RegisterableHotkey` for API boundaries that intentionally accept either form. A one-time inline literal passed directly to TanStack is already type-checked; extract it when registration, display, metadata, or another production consumer needs the same source.
- Keep registered hotkeys distinct from held keys and display-only accelerators. Use `IndividualKey` with `useKeyHold` for held-key interactions, and use an explicitly named `displayKey` for local widget accelerators that are not registered `Hotkey` values.
- Keep registration and keycap/menu display derived from one canonical command. Do not maintain a hotkey string beside a separate `['Mod', ...]` display array.
- Keep a single-owner command constant in its owning component. Create a feature-local `hotkeys.ts` only when multiple production files consume the same command. Keep a dedicated definitions/registry module when a feature owns a real command system with IDs, metadata, alternate bindings, and centralized registration. Tests do not count as another production owner, and file-name uniformity alone is not a reason to extract.
- Make scope and availability explicit. Use `enabled` for business or surface lifecycle, `ignoreInputs` for whether input-like elements may trigger the command, and `target` when the command belongs to a concrete DOM subtree. Global application commands may use the document target; inline editors and composed overlays should prefer the actual editor or Base UI Popup ref when that owner is exposed.
- Put a scoped ref on the real behavior owner. Do not add a wrapper DOM element solely to obtain a hotkey target. If a shared overlay convenience component hides the Popup ref, either rely on its modal lifecycle/focus boundary when that is sufficient or design the primitive API separately; do not create a fake owner at the call site.
- Set `preventDefault` and `stopPropagation` according to the existing product behavior and browser interaction. Do not silently accept TanStack defaults when migrating from another listener if that changes typing, submission, or propagation semantics.
- Test observable command behavior, disabled/input/target scope, and the shared registration/display contract at the owning feature boundary. Prefer partial mocks that retain TanStack formatting and matching behavior when a registration boundary must be isolated.
## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums.
- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string.
- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings.
- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys.
- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters.
- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields.
## Queries And Mutations
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment.
- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content.
- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens.
- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling.
- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary.
## Effects, Navigation, And Performance
- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data.
- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching.
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
@@ -1,42 +0,0 @@
# Component Data And Queries
Read this document when a component consumes generated contracts, nullable API values, TanStack Query, mutations, prefetching, authentication, or workspace state.
## Generated Contracts
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. Enterprise APIs use `packages/contracts/generated/enterprise/*`.
- Backend Pydantic and OpenAPI schemas own API shape. Follow the generated `{ params, query?, body? }` input shape; when it is wrong, fix the backend schema and regenerate `packages/contracts/generated/*`.
- Do not hand-write DTO mirrors, widen generated fields or enums, edit generated output, or add a parallel frontend status layer unless it models product state absent from the API.
- Check deprecated markers, schema shape, and the actual consumer before assuming that a generated operation is ready to use.
- Normalize only at real boundaries such as user input, search, URL params, filenames, DOM IDs, or a required legacy adapter.
- Preserve `null`, `undefined`, and intentional empty strings until the final boundary. Do not use `value || undefined` when an empty string means clearing a field.
- Build required values in the branch that proves them. Avoid `filter(Boolean)`, truthiness filters, non-null assertions after filters, and placeholder values used only to satisfy types.
## Queries
- Use generated options directly with `useQuery(consoleQuery.xxx.queryOptions(...))`, `marketplaceQuery`, or the equivalent generated client.
- If query input comes from atom state, keep it in `atomWithQuery`; do not unwrap the atom in a component solely to call `useQuery`.
- For missing required input, branch the whole generated input with `skipToken`. Add `enabled` only for an independent execution condition; do not put `skipToken` inside a placeholder payload or coerce IDs to empty strings.
- Return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly from TanStack Query atoms. Pass supported options into the generated call instead of spreading into a parallel object.
- Share the exact options between prefetch and render when they represent the same request. Do not extract option helpers merely to reuse input construction.
- Avoid pass-through service hooks that only rename generated options. Keep feature hooks for actual orchestration or shared domain behavior.
## Mutations And Cache
- Use generated `mutationOptions()` directly for owner-local mutations.
- Put shared invalidation, retries, and cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Local callbacks may own toast, close, and navigation effects but must not replace shared cache policy.
- Prefer `mutate(...)`. Use `mutateAsync(...)` only when Promise composition is required, and catch awaited failures.
- Preserve intentional empty values and current list/detail ownership when updating data. Do not add optimistic updates without a verified owner contract.
## Prefetch And Hidden Surfaces
- Prefetch expensive secondary content from the trigger or menu-open event when it benefits the visible path. Do not mount hidden subscribers solely to warm the cache.
- `prefetchQuery` is cache warmup, not an authorization or availability gate. Use a hard fetch boundary when the server must decide whether rendering may proceed.
## SSR, Authentication, And Workspace
- Static configuration owns path-invariant routing. Request-dependent authentication, setup, role, and tenant decisions belong to SSR or runtime decision boundaries.
- Distinguish soft SSR cache warming from authoritative decisions. Prefetched or placeholder data must not grant access or represent successful availability.
- Never reuse tenant-scoped state after switching workspaces. Discard it at the switch boundary or isolate it by workspace identity.
- Do not make product or authorization decisions from bootstrap defaults. Wait for authoritative data, or render an explicit loading or error state.
- Keep loading and Suspense behavior inside the feature that owns the request. Do not add fake global data merely to bypass that boundary.
@@ -1,31 +0,0 @@
# Component Interactions And Overlays
Read this document when a change involves application hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces. Overlay primitive selection and layering are owned by the [overlay guide].
## Focus And Semantics
- Preserve a visible focus indicator on the final focusable element. Styled Dify UI controls usually provide it; headless anatomy parts and direct trigger exports may not.
- Native buttons, links, custom trigger renderers, clickable rows, icon controls, and menu-like items must retain their correct native semantics and accessible name.
- Do not hide an outline without an equivalent visible `focus-visible` treatment. Follow an existing Dify UI pattern rather than inventing a call-site style.
## Keyboard Commands
- Distinguish application commands from widget-local keyboard semantics. Use `@tanstack/react-hotkeys` for application commands; keep menu navigation, dialog Escape handling, editor behavior, and ARIA widget keys in their local primitive or owner.
- Use `useHotkey` or `useHotkeys` for registered commands. When an existing `onKeyDown` intentionally owns the command, use `matchesKeyboardEvent` rather than duplicating modifier parsing or adding another global listener.
- Keep registration and keycap or menu display derived from one canonical command. Distinguish registered commands, held keys, and display-only accelerators.
- Keep a single-owner command beside its component. Create a feature-local hotkey module only when several production files share it; tests alone do not justify extraction.
- Make availability and scope explicit with `enabled`, `ignoreInputs`, and `target`. Put a target ref on the actual behavior owner rather than creating wrapper DOM solely for hotkey scope.
- Preserve existing `preventDefault` and propagation behavior when migrating command APIs.
- Test observable command behavior, disabled and input scope, target scope, and the registration/display contract at the owning feature boundary.
## Secondary Surfaces
- Follow `web/docs/overlay.md` for primitive choice. Dify UI primitives are the default, with package-approved Web wrappers such as `Infotip` where the overlay guide allows them.
- Separate behavior ownership from placement ownership: the action may own trigger, open state, and menu content while the caller owns slots, offsets, and alignment.
- Keep menu and dialog surfaces as siblings when a menu command opens a dialog. Mount the dialog outside popup content.
- Mount controlled overlays unconditionally unless unmounting is required for performance or reset semantics. Prefer keyed or owner-local reset over conditional wrappers.
- Put query and mutation work inside dialog or alert-dialog content when it should mount only after opening.
- Prefer uncontrolled roots when the primitive can own open state. Use controlled state only for business coordination, analytics, cleanup, or explicit reset behavior.
- Do not add manual portals or call-site z-index escalation. Fix ownership and stacking structure at the shared boundary.
[overlay guide]: ../../../../web/docs/overlay.md
@@ -1,39 +0,0 @@
# Component Ownership And Modules
Read this document when adding, moving, splitting, or refactoring React components or feature modules.
## Vertical Modules
- Organize code by product workflow, route, or behavior owner. Keep components, hooks, local types, atoms, query helpers, tests, and small utilities beside the code that changes with them.
- Name page and tab folders after the current route, tab, or user-visible surface. Do not preserve stale parent groupings that no longer own multiple surfaces.
- Split a growing page or tab by product or visual owners. Keep the feature root for its public entrypoint and genuinely cross-owner coordination.
- Import other features only through explicit public entrypoints. Avoid barrels that merely re-export secondary owners.
- Promote code outside a feature only when multiple verticals use the same stable contract. Possible future reuse is not sufficient.
## 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.
- 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.
- Do not replace prop drilling with one large view-model hook. Move each query, derived value, and handler to the concrete owner that consumes it.
- Keep source selection, defaults, validation, dirty checks, and payload shaping beside the workflow that owns submission.
## 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.
- 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.
- Loading states for page sections, cards, lists, tables, forms, and drawers should use skeletons scoped to the loaded content. Reserve spinners for small inline busy indicators.
## Components And Types
- Choose component declaration and export forms from the actual component contract, framework requirements, and enforced package rules. Existing style is context, not authority; do not rewrite unaffected code solely to normalize `FC`, `function`, arrow-function, named-export, or default-export forms.
- Type simple one-off props inline. Name a `Props` type when it is reused, exported, complex, or materially clearer.
- Use API-generated or API-returned types at component boundaries. Keep one-off UI refinements and conversions beside their owner.
- Preserve domain value types for selections. Do not widen enums, unions, booleans, numbers, objects, or nullable values to `string` before a real boundary requires it.
- Avoid generic `common.tsx` buckets and aliases that only rename another type. Name files, values, and public types after their domain role.
- Put fallback and invariant checks in the lowest component that already renders that state. Do not extract helpers whose only purpose is hiding missing display data.
@@ -1,23 +0,0 @@
# Component Effects, Navigation, And Runtime Cost
Read this document when a change introduces Effects, navigation side effects, memoization, preloading, or render-cost optimizations.
## Effects
- 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.
## Navigation
- Use `Link` for ordinary navigation.
- Use router APIs for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Keep shareable navigation state in the URL rather than hidden component state.
## Runtime Cost
- Move changing state to the smallest consumer before considering memoization. Stable parent content can be lifted and passed as children.
- Avoid `memo`, `useMemo`, and `useCallback` unless identity or computation has a demonstrated consumer or measurable cost.
- Start independent remote work together and await it near the branch that consumes it. Avoid introducing request waterfalls.
- Load heavy optional surfaces on demand when they sit behind a dialog, tab, command, or feature activation.
- Use narrow selectors or field-level atoms for broad stores and subscriptions. Do not optimize simple primitive expressions merely for stylistic consistency.
@@ -1,38 +0,0 @@
# Component State And URL Ownership
Read this document when a change involves Jotai, form drafts, route identity, shared client state, or local persistence.
## Choose The Owner
- Keep synchronous state local when one component owns it: dialog and menu state, confirmations, field drafts, and local selections usually belong to the component or DOM.
- Use feature-scoped Jotai when siblings need one source of truth, values drive other atoms, or a scoped workflow must preserve state across hidden or unmounted steps.
- 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
- 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 validation, source priority, fallback behavior, dirty checks, and payload assembly in the workflow that owns submission.
## Route And URL State
- Treat `useParams`, route arguments, and `nuqs` as the owners of URL identity and updates.
- Hydrate a primitive atom at the route or surface boundary only when query atoms or shared derived atoms require route identity. Keep URL writes in route and query-state APIs.
- Within one route-owned feature, choose one route-identity source. Do not hydrate route identity into atoms while also threading the same ID through multiple component layers.
- Put shareable filters, tabs, pagination, and search state in the URL. Keep one-shot navigation signals and transient UI state out of persistent subscriptions.
## Jotai And Query
- A Jotai-backed feature may keep one feature-local state module ordered by dependency: types and constants, primitives, query atoms, query-data derivations, business facts, commands, mutations, submission orchestration, and provider exports.
- Use `atomWithQuery` or `atomWithMutation` for async work driven by atom state. Do not hand-roll loading, error, or in-flight state for atom-orchestrated work.
- Use field-specific derived atoms for query results. `jotai-tanstack-query` does not provide TanStack Query tracked properties, so reading a whole query atom subscribes to the entire observer result.
- Leave query and mutation atoms unscoped so they retain the shared QueryClient cache. Scope resettable primitives and hydration tuples; scope a derived atom only when all dependencies should be private to the surface.
- Use non-null lazy primitives for values always hydrated by a scope provider. Name derived atoms as business facts and write atoms as user or workflow commands.
- Keep independent dialog lifecycles separate. A scoped open-state atom is acceptable only when composed sibling surfaces would otherwise pass confusing lifecycle props through unrelated owners.
## Persistence
- Use feature-owned storage modules built on `createLocalStorageState`; callers should not scatter direct storage access or raw keys.
- Persist high-frequency interaction state only on commit or after updates settle.
- Do not add ad hoc global event listeners for shared state. Centralize subscriptions through the owning atom, store, or subscription hook.
@@ -0,0 +1,33 @@
---
name: karpathy-guidelines
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
---
# Karpathy Guidelines
Use this skill whenever you touch code in this repository.
## Principles
- Keep the change small and directly tied to the user request.
- Prefer the simplest implementation that fits the existing codebase.
- Read the nearby code first, then match its patterns.
- Avoid unrelated refactors, broad rewrites, or style churn.
- Preserve existing behavior unless the user explicitly asked to change it.
- Treat regressions as a signal to narrow the change, not to add workaround layers.
## Workflow
1. Inspect the current implementation and tests around the change.
2. Make the smallest coherent edit.
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
4. Run the narrowest relevant verification first.
5. Report exactly what was verified and anything left unverified.
## Review Checklist
- Does this change solve the stated problem without expanding scope?
- Did it preserve existing route/component/data-flow semantics?
- Are new abstractions justified by real complexity?
- Are tests focused on the behavior that could regress?
- Are unrelated files and generated artifacts left alone?
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/component-refactoring
+1
View File
@@ -0,0 +1 @@
../../.agents/skills/karpathy-guidelines
-17
View File
@@ -250,22 +250,5 @@
# Frontend - Workspace
/web/app/components/header/account-dropdown/workplace-selector/ @iamjoel @zxhlyh
# Frontend - App Shell and Console Bootstrap
/web/app/layout.tsx @iamjoel @lyzno1
/web/app/error.tsx @iamjoel @lyzno1
/web/app/(commonLayout)/layout.tsx @iamjoel @lyzno1
/web/app/(commonLayout)/providers.tsx @iamjoel @lyzno1
/web/app/(commonLayout)/hydration-boundary.tsx @iamjoel @lyzno1
/web/app/(commonLayout)/profile-bootstrap-gate.tsx @iamjoel @lyzno1
/web/app/(commonLayout)/error.tsx @iamjoel @lyzno1
/web/app/account/(commonLayout)/layout.tsx @iamjoel @lyzno1
/web/app/components/main-nav/* @iamjoel @lyzno1
/web/app/components/main-nav/components/* @iamjoel @lyzno1
/web/context/query-client.tsx @iamjoel @lyzno1
/web/context/query-client-server.ts @iamjoel @lyzno1
/web/proxy.ts @iamjoel @lyzno1
/web/app/auth/refresh/route.ts @iamjoel @lyzno1
/web/service/server.ts @iamjoel @lyzno1
# Docker
/docker/* @laipz8200
-28
View File
@@ -1,28 +0,0 @@
name: Deploy Knowledge
permissions:
contents: read
on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/konwledge"
types:
- completed
jobs:
deploy:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/konwledge'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.SSH_NEW_RAG_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT || secrets.SSH_SCRIPT }}
+42 -4
View File
@@ -1,8 +1,46 @@
# AGENTS.md
Dify is an open-source platform for building LLM applications, agentic workflows, and RAG pipelines. This monorepo contains the backend API (`api/`), frontend application (`web/`), deployment assets (`docker/`), standalone agent backend (`dify-agent/`), CLI (`cli/`), and end-to-end suite (`e2e/`). Follow the nearest scoped `AGENTS.md` for the files being changed.
## Project Overview
## Repository Gotchas
Dify is an open-source platform for developing LLM applications with an intuitive interface combining agentic AI workflows, RAG pipelines, agent capabilities, and model management.
- Run backend commands through `uv run --project api <command>`.
- Backend integration tests are CI-only and are not expected to run locally.
The codebase is split into:
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
- **Docker deployment** (`/docker`): Containerized deployment configurations
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
## Backend Workflow
- Read `api/AGENTS.md` for details
- Run backend CLI commands through `uv run --project api <command>`.
- Integration tests are CI-only and are not expected to run in the local environment.
## Frontend Workflow
- Read `web/AGENTS.md` for details
## Testing & Quality Practices
- Follow TDD: red → green → refactor.
- Use `pytest` for backend tests with Arrange-Act-Assert structure.
- Enforce strong typing; avoid `Any` and prefer explicit type annotations.
- Write self-documenting code; only add comments that explain intent.
## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, run `pnpm check` for formatting, Oxlint, ESLint non-code checks, and type checking, and avoid `any` types.
## General Practices
- Prefer editing existing files; add new documentation only when requested.
- Inject dependencies through constructors and preserve clean architecture boundaries.
- Handle errors with domain-specific exceptions at the correct layer.
## Project Conventions
- Backend architecture adheres to DDD and Clean Architecture principles.
- Async work runs through Celery with Redis as the broker.
- Frontend user-facing strings must use `web/i18n/en-US/`; avoid hardcoded text.
+211 -15
View File
@@ -1,25 +1,221 @@
# API Agent Guide
Read surrounding module, class, and function docstrings plus non-obvious comments before changing backend behavior. They are local contracts; update them only when their owned behavior changes, and keep them aligned with the current code.
## Notes for Agent (must-check)
## Commands
Before changing any backend code under `api/`, you MUST read the surrounding docstrings and comments. These notes contain required context (invariants, edge cases, trade-offs) and are treated as part of the spec.
Run backend checks from the repository root:
Look for:
- Format and lint: `make lint`
- The module (file) docstring at the top of a source code file
- Docstrings on classes and functions/methods
- Paragraph/block comments for non-obvious logic
### What to write where
- Keep notes scoped: module notes cover module-wide context, class notes cover class-wide context, function/method notes cover behavioural contracts, and paragraph/block comments cover local “why”. Avoid duplicating the same content across scopes unless repetition prevents misuse.
- **Module (file) docstring**: purpose, boundaries, key invariants, and “gotchas” that a new reader must know before editing.
- Include cross-links to the key collaborators (modules/services) when discovery is otherwise hard.
- Prefer stable facts (invariants, contracts) over ephemeral “today we…” notes.
- **Class docstring**: responsibility, lifecycle, invariants, and how it should be used (or not used).
- If the class is intentionally stateful, note what state exists and what methods mutate it.
- If concurrency/async assumptions matter, state them explicitly.
- **Function/method docstring**: behavioural contract.
- Document arguments, return shape, side effects (DB writes, external I/O, task dispatch), and raised domain exceptions.
- Add examples only when they prevent misuse.
- **Paragraph/block comments**: explain *why* (trade-offs, historical constraints, surprising edge cases), not what the code already states.
- Keep comments adjacent to the logic they justify; delete or rewrite comments that no longer match reality.
### Rules (must follow)
In this section, “notes” means module/class/function docstrings plus any relevant paragraph/block comments.
- **Before working**
- Read the notes in the area youll touch; treat them as part of the spec.
- If a docstring or comment conflicts with the current code, treat the **code as the single source of truth** and update the docstring or comment to match reality.
- If important intent/invariants/edge cases are missing, add them in the closest docstring or comment (module for overall scope, function for behaviour).
- **During working**
- Keep the notes in sync as you discover constraints, make decisions, or change approach.
- If you move/rename responsibilities across modules/classes, update the affected docstrings and comments so readers can still find the “why” and the invariants.
- Record non-obvious edge cases, trade-offs, and the test/verification plan in the nearest docstring or comment that will stay correct.
- Keep the notes **coherent**: integrate new findings into the relevant docstrings and comments; avoid append-only “recent fix” / changelog-style additions.
- **When finishing**
- Update the notes to reflect what changed, why, and any new edge cases/tests.
- Remove or rewrite any comments that could be mistaken as current guidance but no longer apply.
- Keep docstrings and comments concise and accurate; they are meant to prevent repeated rediscovery.
## Coding Style
This is the default standard for backend code in this repo. Follow it for new code and use it as the checklist when reviewing changes.
### Linting & Formatting
- Use Ruff for formatting and linting (follow `.ruff.toml`).
- Keep each line under 120 characters (including spaces).
### Naming Conventions
- Use `snake_case` for variables and functions.
- Use `PascalCase` for classes.
- Use `UPPER_CASE` for constants.
### Typing & Class Layout
- Code should usually include type annotations that match the repos current Python version (avoid untyped public APIs and “mystery” values).
- Prefer modern typing forms (e.g. `list[str]`, `dict[str, int]`) and avoid `Any` unless theres a strong reason.
- For dictionary-like data with known keys and value types, prefer `TypedDict` over `dict[...]` or `Mapping[...]`.
- For optional keys in typed payloads, use `NotRequired[...]` (or `total=False` when most fields are optional).
- Keep `dict[...]` / `Mapping[...]` for truly dynamic key spaces where the key set is unknown.
```python
from datetime import datetime
from typing import NotRequired, TypedDict
class UserProfile(TypedDict):
user_id: str
email: str
created_at: datetime
nickname: NotRequired[str]
```
- For classes, declare all member variables explicitly with types at the top of the class body (before `__init__`), even when the class is not a dataclass or Pydantic model, so the class shape is obvious at a glance:
```python
from datetime import datetime
class Example:
user_id: str
created_at: datetime
def __init__(self, user_id: str, created_at: datetime) -> None:
self.user_id = user_id
self.created_at = created_at
```
### General Rules
- Use Pydantic v2 conventions.
- Use `uv` for Python package management in this repo (usually with `--project api`).
- Prefer simple functions over small “utility classes” for lightweight helpers.
- Avoid implementing dunder methods unless its clearly needed and matches existing patterns.
- Never start long-running services as part of agent work (`uv run app.py`, `flask run`, etc.); running tests is allowed.
- Keep files below ~800 lines; split when necessary.
- Keep code readable and explicit—avoid clever hacks.
### Architecture & Boundaries
- Mirror the layered architecture: controller → service → core/domain.
- Reuse existing helpers in `core/`, `services/`, and `libs/` before creating new abstractions.
- Optimise for observability: deterministic control flow, clear logging, actionable errors.
### Owner-Bound Resource References
- Resolve and validate the outer owner before binding a nested resource ID.
- For stable single-parent chains, use immutable nested `NamedTuple` refs.
- Root refs carry tenant plus root ID; child refs carry the parent ref.
- In production, construct refs through the domain ref service.
- Python allowing direct construction does not grant authorization.
- Scope every consuming query with complete owner predicates; refs are not security tokens.
- Keep polymorphic owners flat until explicit nominal owner types exist.
- Do not add generic ref bases or compatibility fields only for uniformity.
- Reconstruct internal refs from validated database state after payload or async boundaries.
### Logging & Errors
- Never use `print`; use a module-level logger:
- `logger = logging.getLogger(__name__)`
- Include tenant/app/workflow identifiers in log context when relevant.
- Raise domain-specific exceptions (`services/errors`, `core/errors`) and translate them into HTTP responses in controllers.
- Log retryable events at `warning`, terminal failures at `error`.
### SQLAlchemy Patterns
- Models inherit from `models.base.TypeBase`; do not create ad-hoc metadata or engines.
- Open sessions with context managers:
```python
from sqlalchemy.orm import Session
with Session(db.engine, expire_on_commit=False) as session:
stmt = select(Workflow).where(
Workflow.id == workflow_id,
Workflow.tenant_id == tenant_id,
)
workflow = session.execute(stmt).scalar_one_or_none()
```
- Prefer SQLAlchemy expressions; avoid raw SQL unless necessary.
- Always scope queries by `tenant_id` and protect write paths with safeguards (`FOR UPDATE`, row counts, etc.).
- Introduce repository abstractions only for very large tables (e.g., workflow executions) or when alternative storage strategies are required.
### Storage & External I/O
- Access storage via `extensions.ext_storage.storage`.
- Use `core.helper.ssrf_proxy` for outbound HTTP fetches.
- Background tasks that touch storage must be idempotent, and should log relevant object identifiers.
### Pydantic Usage
- Define DTOs with Pydantic v2 models and forbid extras by default.
- Use `@field_validator` / `@model_validator` for domain rules.
Example:
```python
from pydantic import BaseModel, ConfigDict, HttpUrl, field_validator
class TriggerConfig(BaseModel):
endpoint: HttpUrl
secret: str
model_config = ConfigDict(extra="forbid")
@field_validator("secret")
def ensure_secret_prefix(cls, value: str) -> str:
if not value.startswith("dify_"):
raise ValueError("secret must start with dify_")
return value
```
### Generics & Protocols
- Use `typing.Protocol` to define behavioural contracts (e.g., cache interfaces).
- Apply generics (`TypeVar`, `Generic`) for reusable utilities like caches or providers.
- Validate dynamic inputs at runtime when generics cannot enforce safety alone.
### Tooling & Checks
Quick checks while iterating:
- Format: `make format`
- Lint (includes auto-fix): `make lint`
- Type check: `make type-check`
- Unit tests: `make test`
- Targeted tests: `make test TARGET_TESTS=./api/tests/<path>`
- Full backend tests, including Docker-backed suites: `make test-all`
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
Run direct Python commands through `uv run --project api`. Docker-backed integration suites are normally CI-owned. Do not start long-running services as part of routine agent work.
Before opening a PR / submitting:
## Architecture And Boundaries
- `make lint`
- `make type-check`
- `make test`
- Keep transport parsing and serialization in controllers, orchestration in services, and domain policy in `core/` or its domain owner. Keep `libs/` business-agnostic and reuse existing owners before adding abstractions.
- Before changing controller schemas, generated API contracts, or `SystemFeatureModel`, read `controllers/API_SCHEMA_GUIDE.md`. Treat `/system-features` as a minimal unauthenticated bootstrap allowlist, not a general configuration registry.
- Scope tenant-owned reads and writes by the complete owner chain, and propagate `tenant_id` across every affected layer. Reconstruct trusted internal references from validated database state after payload or async boundaries.
- Keep write transactions explicit and bounded. Do not perform external I/O inside an open transaction unless a documented consistency contract requires it.
- Read configuration through `configs.dify_config`, access storage through `extensions.ext_storage.storage`, and route outbound HTTP through the existing SSRF-safe owner in `core.helper.ssrf_proxy`.
- Use Pydantic v2 for request and response models. Reuse domain-specific exceptions and translate them at the controller boundary.
- Use existing Celery task and queue owners for asynchronous work; do not route unrelated jobs through workflow-specific services.
- Celery tasks that may be retried or redelivered must keep side effects idempotent and log affected resource identifiers.
### Controllers & Services
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
- Document non-obvious behaviour with concise docstrings and comments.
- For `204 No Content` responses, return an empty body only; never return a dict, model, or other payload.
- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`.
In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response
DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`,
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
### Miscellaneous
- Use `configs.dify_config` for configuration—never read environment variables directly.
- Maintain tenant awareness end-to-end; `tenant_id` must flow through every layer touching shared resources.
- Queue async work through `services/async_workflow_service`; implement tasks under `tasks/` with explicit queue selection.
- Keep experimental scripts under `dev/`; do not ship them in production builds.
+1 -2
View File
@@ -6,7 +6,6 @@ from sqlalchemy import delete, select, update
from sqlalchemy.orm import sessionmaker
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from events.app_event import app_was_created
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -43,7 +42,7 @@ def reset_encrypt_key_pair():
After the reset, all LLM credentials will become invalid, requiring re-entry.
Only support SELF_HOSTED mode.
"""
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION != "SELF_HOSTED":
click.echo(click.style("This command is only for SELF_HOSTED installations.", fg="red"))
return
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
-9
View File
@@ -5,7 +5,6 @@ from typing import Any, override
from pydantic.fields import FieldInfo
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
from enums.deployment_edition import DeploymentEdition
from libs.file_utils import search_file_upwards
from .deploy import DeploymentConfig
@@ -117,11 +116,3 @@ class DifyConfig(
),
),
)
@property
def DEPLOYMENT_EDITION(self) -> DeploymentEdition:
if self.EDITION == "CLOUD":
return DeploymentEdition.CLOUD
if self.ENTERPRISE_ENABLED:
return DeploymentEdition.ENTERPRISE
return DeploymentEdition.COMMUNITY
-5
View File
@@ -1497,11 +1497,6 @@ class LoginConfig(BaseSettings):
class AccountConfig(BaseSettings):
ENABLE_CHANGE_EMAIL: bool = Field(
description="whether users can change their email address",
default=True,
)
ACCOUNT_DELETION_TOKEN_EXPIRY_MINUTES: PositiveInt = Field(
description="Duration in minutes for which a account deletion token remains valid",
default=5,
-44
View File
@@ -12,47 +12,6 @@ parameters, response schemas, and Swagger documentation.
- Do not add new Flask-RESTX `fields.*` dictionaries, `Namespace.model(...)` exports, or `@marshal_with(...)` for migrated or new endpoints.
- Do not use `@ns.expect(...)` for GET query parameters. Flask-RESTX documents that as a request body.
## Public System Features Contract
The Console and Web `/system-features` endpoints share `SystemFeatureModel`. They are unauthenticated and may be
requested during root SSR, so treat this response as a minimal public bootstrap allowlist. It is not a general
configuration endpoint, a feature registry, or a mirror of environment and Enterprise settings. Existing fields are
legacy inventory and do not establish precedent for new fields.
A new field is eligible only when all of the following are true:
1. Both Console and Web have named production consumers for the field.
2. Both consumers need the value before authentication and tenant/workspace bootstrap to render initial state or
choose an authentication flow.
3. The value varies at runtime or by deployment and cannot be safely derived from an existing public contract.
4. The value is non-sensitive, safe to disclose without authentication, and has stable public API semantics.
5. Sending the value on every root bootstrap is demonstrably clearer and cheaper than a consumer-owned query.
Do not add:
- Backend-only policy or enforcement inputs, including security decisions, upload limits, or integration toggles.
- Console-only or Web-only configuration.
- Tenant, workspace, account, permission, billing-detail, or other post-authentication state.
- Provider payloads, operational diagnostics, large nested objects, or values without active consumers.
- Speculative fields added for possible future use.
Route excluded values to their actual owner:
- Keep backend enforcement behind a narrow service method or domain policy.
- Serve post-authentication state from an authenticated domain endpoint.
- Serve surface-specific bootstrap state from a Console- or Web-specific endpoint and account for its SSR, caching,
and failure cost explicitly.
- Load large, slow, or page-specific data lazily through a consumer-owned query.
Every pull request that adds a System Features field must:
- Name both production consumer paths and explain why they require the value before authentication.
- Document the root SSR request, payload, caching, and failure-mode impact.
- Update the Pydantic owner, regenerate OpenAPI Markdown and TypeScript/Zod contracts, and update shared fixtures.
- Add Console and Web schema regression coverage. Do not hand-edit generated contracts or add compatibility defaults.
Reviewers should reject a field when its owner, pre-authentication need, or consumers are unclear.
## Naming
- Request body models: use a `Payload` suffix.
@@ -162,9 +121,6 @@ That documents a GET request body and is not the expected contract.
## Responses
`204 No Content` responses must not serialize a response body. Return the status using the established controller pattern;
do not return a dictionary, response model, or other payload.
Response models should inherit from `ResponseModel`:
```python
@@ -198,6 +198,6 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account, session=db.session())
and FeatureService.is_workspace_creation_allowed()
and FeatureService.get_system_features().is_allow_create_workspace
):
TenantService.create_owner_tenant(account, session=db.session())
+5 -6
View File
@@ -162,10 +162,9 @@ class LoginApi(Resource):
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account, session=db.session())
if len(tenants) == 0:
if (
FeatureService.is_workspace_creation_allowed()
and not FeatureService.get_license().workspaces.is_available()
):
system_features = FeatureService.get_system_features()
if system_features.is_allow_create_workspace and not system_features.license.workspaces.is_available():
raise WorkspacesLimitExceeded()
else:
return SimpleResultOptionalDataResponse(
@@ -311,10 +310,10 @@ class EmailCodeLoginApi(Resource):
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
workspaces = FeatureService.get_license().workspaces
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceeded()
if not FeatureService.is_workspace_creation_allowed():
if not FeatureService.get_system_features().is_allow_create_workspace:
raise NotAllowedCreateWorkspace()
else:
TenantService.create_owner_tenant(account, session=db.session())
+1 -1
View File
@@ -292,7 +292,7 @@ def _generate_account(
if account:
tenants = TenantService.get_join_tenants(account, session=db.session())
if not tenants:
if not FeatureService.is_workspace_creation_allowed():
if not FeatureService.get_system_features().is_allow_create_workspace:
raise WorkSpaceNotAllowedCreateError()
else:
TenantService.create_owner_tenant(account, session=db.session())
@@ -11,7 +11,7 @@ from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import build_icon_url, dump_response
from libs.helper import build_icon_url
from libs.login import login_required
from models import Account
from services.recommended_app_service import RecommendedAppService
@@ -58,7 +58,7 @@ class RecommendedAppResponse(ResponseModel):
categories: list[str] = Field(default_factory=list)
position: int | None = None
is_listed: bool | None = None
can_trial: bool
can_trial: bool | None = None
class RecommendedAppListResponse(ResponseModel):
@@ -77,7 +77,7 @@ class RecommendedAppDetailResponse(ResponseModel):
icon_background: str | None = None
mode: str
export_data: str
can_trial: bool
can_trial: bool | None = None
class RecommendedAppDetailNullableResponse(RootModel[RecommendedAppDetailResponse | None]):
@@ -119,10 +119,10 @@ class RecommendedAppListApi(Resource):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return dump_response(
RecommendedAppListResponse,
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(language_prefix, session=db.session()),
)
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
@@ -136,10 +136,10 @@ class LearnDifyAppListApi(Resource):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return dump_response(
LearnDifyAppListResponse,
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(language_prefix, session=db.session()),
)
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/<uuid:app_id>")
@@ -148,5 +148,4 @@ class RecommendedAppApi(Resource):
@login_required
@account_initialization_required
def get(self, app_id: UUID):
result = RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
return RecommendedAppDetailNullableResponse.model_validate(result).model_dump(mode="json")
return RecommendedAppService.get_recommend_app_detail(str(app_id), session=db.session())
+9 -1
View File
@@ -46,7 +46,7 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.wraps import TrialAppResource
from controllers.console.explore.wraps import TrialAppResource, trial_feature_enable
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request
from controllers.console.wraps import cloud_edition_billing_resource_check, with_current_user
@@ -432,6 +432,7 @@ simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppFileUploadApi(TrialAppResource):
@trial_feature_enable
@cloud_edition_billing_resource_check("documents")
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@@ -446,6 +447,7 @@ class TrialAppFileUploadApi(TrialAppResource):
class TrialAppRemoteFileUploadApi(TrialAppResource):
@trial_feature_enable
@cloud_edition_billing_resource_check("documents")
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@@ -460,6 +462,7 @@ class TrialAppRemoteFileUploadApi(TrialAppResource):
class TrialAppWorkflowRunApi(TrialAppResource):
@trial_feature_enable
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@@ -510,6 +513,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
class TrialAppWorkflowTaskStopApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@trial_feature_enable
def post(self, trial_app, task_id: str):
"""
Stop workflow task
@@ -534,6 +538,7 @@ class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
@@ -635,6 +640,7 @@ class TrialMessageSuggestedQuestionApi(TrialAppResource):
class TrialChatAudioApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[AudioTranscriptResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
app_model = trial_app
@@ -685,6 +691,7 @@ class TrialChatAudioApi(TrialAppResource):
class TrialChatTextApi(TrialAppResource):
@console_ns.expect(console_ns.models[TextToSpeechRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AudioBinaryResponse.__name__])
@trial_feature_enable
@with_current_user
def post(self, current_user: Account, trial_app):
app_model = trial_app
@@ -745,6 +752,7 @@ class TrialChatTextApi(TrialAppResource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@trial_feature_enable
@with_current_user
@with_session
def post(self, session: Session, current_user: Account, trial_app):
+2 -3
View File
@@ -14,7 +14,6 @@ from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import FeatureService
from services.recommended_app_service import RecommendedAppService
def installed_app_required[**P, R](view: Callable[Concatenate[InstalledApp, P], R] | None = None):
@@ -107,7 +106,8 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not RecommendedAppService.is_trial_app_enabled():
features = FeatureService.get_system_features()
if not features.enable_trial_app:
abort(403, "Trial app feature is not enabled.")
return view(*args, **kwargs)
@@ -141,7 +141,6 @@ class TrialAppResource(Resource):
method_decorators = [
trial_app_required,
trial_feature_enable,
account_initialization_required,
login_required,
]
+12 -32
View File
@@ -3,11 +3,10 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from fields.base import ResponseModel
from libs.helper import dump_response
from libs.login import login_required
from libs.login import current_account_with_tenant_optional, login_required
from services.feature_service import (
FeatureModel,
FeatureService,
LicenseModel,
LimitationModel,
SystemFeatureModel,
)
@@ -33,7 +32,6 @@ register_response_schema_models(
console_ns,
AppDslVersionResponse,
FeatureModel,
LicenseModel,
LimitationModel,
SystemFeatureModel,
TrialModelsResponse,
@@ -123,40 +121,22 @@ class AppDslVersionApi(Resource):
@console_ns.route("/system-features")
class SystemFeatureApi(Resource):
@console_ns.doc("get_system_features")
@console_ns.doc(
description="Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. "
"This is not a general feature registry."
)
@console_ns.doc(description="Get system-wide feature configuration")
@console_ns.response(
200,
"Success",
console_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get the non-sensitive bootstrap snapshot exposed before authentication.
"""Get system-wide feature configuration
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for dashboard initialization.
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint.
"""
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
@console_ns.route("/system-features/license")
class SystemFeatureLicenseApi(Resource):
@console_ns.doc("get_system_license")
@console_ns.doc(description="Get license status and usage detail")
@console_ns.response(
200,
"Success",
console_ns.models[LicenseModel.__name__],
)
@setup_required
@login_required
@account_initialization_required
def get(self):
"""Get full license detail (status, expiry, workspace/seat usage).
Authenticated counterpart to the license *status* exposed on the public
system-features endpoint.
"""
return FeatureService.get_license().model_dump()
current_user, _ = current_account_with_tenant_optional()
is_authenticated = current_user is not None
return FeatureService.get_system_features(is_authenticated=is_authenticated).model_dump()
+1 -2
View File
@@ -8,7 +8,6 @@ from sqlalchemy.orm import Session
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from models.model import DifySetup
from services.account_service import TenantService
@@ -64,7 +63,7 @@ def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse
def get_init_validate_status() -> bool:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if dify_config.EDITION == "SELF_HOSTED":
if os.environ.get("INIT_PASSWORD"):
if session.get("is_init_validated"):
return True
+2 -3
View File
@@ -6,7 +6,6 @@ from sqlalchemy import select
from configs import dify_config
from controllers.fastopenapi import console_router
from enums.deployment_edition import DeploymentEdition
from libs.helper import EmailStr, extract_remote_ip
from libs.password import valid_password
from models.model import DifySetup, db
@@ -53,7 +52,7 @@ def get_setup_status_api() -> SetupStatusResponse:
Only bootstrap-safe status information should be returned by this endpoint.
"""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if dify_config.EDITION == "SELF_HOSTED":
setup_status = get_setup_status()
if setup_status and not isinstance(setup_status, bool):
return SetupStatusResponse(step="finished", setup_at=setup_status.setup_at.isoformat())
@@ -103,7 +102,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
def get_setup_status() -> DifySetup | bool | None:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if dify_config.EDITION == "SELF_HOSTED":
return db.session.scalar(select(DifySetup).limit(1))
return True
+1 -2
View File
@@ -46,7 +46,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountResponse
@@ -263,7 +262,7 @@ class AccountInitApi(Resource):
payload = console_ns.payload or {}
args = AccountInitPayload.model_validate(payload)
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION == "CLOUD":
if not args.invitation_code:
raise ValueError("invitation_code is required")
+1 -1
View File
@@ -198,7 +198,7 @@ def _check_member_invite_limits(tenant_id: str, new_member_count: int, new_accou
if workspace_members.enabled is True and not workspace_members.is_available(new_member_count):
raise WorkspaceMembersLimitExceeded()
if new_account_count > 0:
seats = FeatureService.get_license().seats
seats = FeatureService.get_system_features(is_authenticated=True).license.seats
if not seats.is_available(new_account_count):
raise SeatsLimitExceeded()
return
+8 -4
View File
@@ -442,10 +442,12 @@ register_enum_models(
)
def _missing_auto_upgrade_settings(tenant_id: str) -> AutoUpgradeSettingsResponse:
"""Represent a missing persisted strategy as effectively disabled."""
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": TenantPluginAutoUpgradeStrategySetting.DISABLED,
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeMode.EXCLUDE,
"exclude_plugins": [],
@@ -1133,7 +1135,9 @@ class PluginFetchAutoUpgradeApi(Resource):
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category, session=db.session())
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade) if auto_upgrade else _missing_auto_upgrade_settings(tenant_id)
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
return jsonable_encoder(
@@ -37,7 +37,6 @@ from controllers.console.wraps import (
with_current_user,
)
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
@@ -234,7 +233,7 @@ class TenantListApi(Resource):
tenants = [tenant for tenant, _ in tenant_rows]
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
tenant_plans: dict[str, SubscriptionPlan] = {}
if is_saas:
+3 -4
View File
@@ -20,7 +20,6 @@ from controllers.common.wraps import (
from controllers.console.auth.error import AuthenticationFailedError, EmailCodeError
from controllers.console.workspace.error import AccountNotInitializedError
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from libs.encryption import FieldEncryption
@@ -130,7 +129,7 @@ def account_initialization_required[R](view: Callable[..., R]) -> Callable[...,
def only_edition_cloud[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if dify_config.EDITION != "CLOUD":
abort(404)
return view(*args, **kwargs)
@@ -152,7 +151,7 @@ def only_edition_enterprise[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def only_edition_self_hosted[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION != "SELF_HOSTED":
abort(404)
return view(*args, **kwargs)
@@ -328,7 +327,7 @@ def setup_required[R](view: Callable[..., R]) -> Callable[..., R]:
# The overloads keep Resource methods method-aware for pyrefly while
# preserving support for plain functions used in tests and utilities.
# check setup
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD and not _is_setup_completed():
if dify_config.EDITION == "SELF_HOSTED" and not _is_setup_completed():
if os.environ.get("INIT_PASSWORD"):
raise NotInitValidateError()
raise NotSetupError()
+1 -2
View File
@@ -8,7 +8,6 @@ from werkzeug.exceptions import InternalServerError
from configs import dify_config
from core.rbac import RBACPermission, RBACResourceScope
from enums.deployment_edition import DeploymentEdition
from libs.oauth_bearer import Scope, TokenType
from models.account import Account, Tenant, TenantAccountRole
from models.model import App, EndUser
@@ -27,7 +26,7 @@ class CallerKind(StrEnum):
def current_edition() -> Edition:
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION == "CLOUD":
return Edition.SAAS
if dify_config.ENTERPRISE_ENABLED:
return Edition.EE
+15 -8
View File
@@ -2,7 +2,6 @@ from flask_restx import Resource
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from libs.helper import dump_response
from services.feature_service import FeatureService, SystemFeatureModel
register_response_schema_models(web_ns, SystemFeatureModel)
@@ -11,10 +10,7 @@ register_response_schema_models(web_ns, SystemFeatureModel)
@web_ns.route("/system-features")
class SystemFeatureApi(Resource):
@web_ns.doc("get_system_features")
@web_ns.doc(
description="Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. "
"This is not a general feature registry."
)
@web_ns.doc(description="Get system feature flags and configuration")
@web_ns.doc(responses={200: "System features retrieved successfully", 500: "Internal server error"})
@web_ns.response(
200,
@@ -22,11 +18,22 @@ class SystemFeatureApi(Resource):
web_ns.models[SystemFeatureModel.__name__],
)
def get(self):
"""Get the non-sensitive bootstrap snapshot exposed before authentication.
"""Get system feature flags and configuration.
Returns the current system feature flags and configuration
that control various functionalities across the platform.
Returns:
dict: System feature configuration object
This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,
except it is intended for use by the web app, instead of the console dashboard.
Authentication configuration must be available before the authentication flow can be selected.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for webapp initialization.
Authentication would create circular dependency (can't authenticate without webapp loading).
Only non-sensitive configuration data should be returned by this endpoint.
"""
return dump_response(SystemFeatureModel, FeatureService.get_system_features())
return FeatureService.get_system_features().model_dump()
+1 -4
View File
@@ -8,7 +8,6 @@ from configs import dify_config
from controllers.common.schema import register_response_schema_models
from controllers.web import web_ns
from controllers.web.wraps import WebApiResource
from enums.deployment_edition import DeploymentEdition
from extensions.ext_database import db
from extensions.storage.storage_type import StorageType
from fields.base import ResponseModel
@@ -128,9 +127,7 @@ def _build_site_icon_url(*, site: Site, tenant_id: str) -> str | None:
"""Use direct S3 URLs only in Cloud Mode and preserve preview URLs elsewhere."""
if site.icon_type != IconType.IMAGE or not site.icon:
return None
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and (
StorageType(dify_config.STORAGE_TYPE) == StorageType.S3
):
if dify_config.EDITION == "CLOUD" and StorageType(dify_config.STORAGE_TYPE) == StorageType.S3:
return FileService(db.engine).get_file_presigned_url(file_id=site.icon, tenant_id=tenant_id)
return build_icon_url(site.icon_type, site.icon)
+36 -1
View File
@@ -10,12 +10,13 @@ from sqlalchemy.orm import Session
from core.agent.entities import AgentEntity, AgentToolEntity
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.agent_chat.app_config_manager import AgentChatAppConfig
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.base_app_runner import AppRunner
from core.app.entities.app_invoke_entities import (
AgentChatAppGenerateEntity,
ModelConfigWithCredentialsEntity,
)
from core.app.entities.queue_entities import QueueUIPartEvent
from core.app.file_access import DatabaseFileAccessController
from core.callback_handler.agent_tool_callback_handler import DifyAgentCallbackHandler
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
@@ -23,6 +24,12 @@ from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.prompt.utils.extract_thread_messages import extract_thread_messages
from core.tools.__base.tool import Tool
from core.tools.entities.ui_entities import (
MessageUIPart,
ToolUIMessage,
build_ui_part_id,
validate_tool_ui_message_batch,
)
from core.tools.tool_manager import ToolManager
from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool
from extensions.ext_database import db
@@ -352,6 +359,34 @@ class BaseAgentRunner(AppRunner):
db.session.commit()
db.session.close()
def publish_ui_messages(self, *, namespace: str, ui_messages: list[ToolUIMessage]) -> None:
"""Publish validated tool UI messages without adding them to agent observations."""
try:
validate_tool_ui_message_batch(ui_messages)
except ValueError:
logger.warning(
"Ignored tool UI batch that exceeds queue publication limits",
extra={"message_id": self.message.id, "namespace": namespace},
exc_info=True,
)
return
sequences: dict[str, int] = {}
for ui_message in ui_messages:
part_id = build_ui_part_id(namespace, ui_message.surface_id)
sequence = sequences.get(part_id, 0) + 1
sequences[part_id] = sequence
self.queue_manager.publish(
QueueUIPartEvent(
part=MessageUIPart.from_tool_ui_message(
part_id=part_id,
sequence=sequence,
ui_message=ui_message,
)
),
PublishFrom.APPLICATION_MANAGER,
)
def organize_agent_history(self, prompt_messages: list[PromptMessage], *, session: Session) -> list[PromptMessage]:
"""
Organize agent history
+11 -1
View File
@@ -235,6 +235,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
# action is tool call, invoke tool
tool_invoke_response, tool_invoke_meta = self._handle_invoke_action(
session=session,
agent_thought_id=agent_thought_id,
action=scratchpad.action,
tool_instances=tool_instances,
message_file_ids=message_file_ids,
@@ -306,9 +307,11 @@ class CotAgentRunner(BaseAgentRunner, ABC):
tool_instances: Mapping[str, Tool],
message_file_ids: list[str],
trace_manager: TraceQueueManager | None = None,
agent_thought_id: str | None = None,
) -> tuple[str, ToolInvokeMeta]:
"""
handle invoke action
:param agent_thought_id: namespace for UI parts emitted by this action
:param action: action
:param tool_instances: tool instances
:param message_file_ids: message file ids
@@ -331,7 +334,7 @@ class CotAgentRunner(BaseAgentRunner, ABC):
pass
# invoke tool
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
invoke_result = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
@@ -342,6 +345,13 @@ class CotAgentRunner(BaseAgentRunner, ABC):
agent_tool_callback=self.agent_callback,
trace_manager=trace_manager,
)
tool_invoke_response = invoke_result.observation
message_files = invoke_result.message_files
tool_invoke_meta = invoke_result.meta
self.publish_ui_messages(
namespace=agent_thought_id or self.message.id,
ui_messages=invoke_result.ui_messages,
)
session.commit()
session.close()
+8 -1
View File
@@ -251,7 +251,7 @@ class FunctionCallAgentRunner(BaseAgentRunner):
}
else:
# invoke tool
tool_invoke_response, message_files, tool_invoke_meta = ToolEngine.agent_invoke(
invoke_result = ToolEngine.agent_invoke(
session=session,
tool=tool_instance,
tool_parameters=tool_call_args,
@@ -265,6 +265,13 @@ class FunctionCallAgentRunner(BaseAgentRunner):
message_id=self.message.id,
conversation_id=self.conversation.id,
)
tool_invoke_response = invoke_result.observation
message_files = invoke_result.message_files
tool_invoke_meta = invoke_result.meta
self.publish_ui_messages(
namespace=tool_call_id,
ui_messages=invoke_result.ui_messages,
)
session.commit()
session.close()
# publish files
-93
View File
@@ -1,93 +0,0 @@
"""Publication visibility rules for calling roster Agents from Workflows.
``Agent.active_config_is_published`` describes whether the editable shared
draft still matches the active snapshot. It is false both before the first
publish and after a published Agent receives new draft edits, so it must not be
used as a runtime availability flag. App-backed Agents are callable from a
Workflow only when the active snapshot has a revision created by a
publish-visible operation. Direct roster Agents are publish-visible by
construction and only need an active snapshot.
"""
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from models.agent import Agent, AgentConfigRevision, AgentConfigRevisionOperation, AgentScope, AgentSource
PUBLISH_VISIBLE_APP_BACKED_REVISION_OPERATIONS = frozenset(
{
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_AGENT,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
)
def workflow_callable_active_snapshot_filter() -> ColumnElement[bool]:
"""Return the SQL predicate for an Agent with a Workflow-callable active snapshot.
The caller remains responsible for tenant, roster scope, lifecycle status,
and model configuration filters. The correlated revision lookup makes the
predicate safe to compose into roster pagination queries.
"""
app_backed_agent = or_(
Agent.source == AgentSource.AGENT_APP,
and_(
Agent.source == AgentSource.IMPORTED,
Agent.scope == AgentScope.ROSTER,
Agent.app_id.is_not(None),
),
)
publish_visible_revision_exists = (
select(AgentConfigRevision.id)
.where(
AgentConfigRevision.tenant_id == Agent.tenant_id,
AgentConfigRevision.agent_id == Agent.id,
AgentConfigRevision.current_snapshot_id == Agent.active_config_snapshot_id,
AgentConfigRevision.operation.in_(PUBLISH_VISIBLE_APP_BACKED_REVISION_OPERATIONS),
)
.correlate(Agent)
.exists()
)
return and_(
Agent.active_config_snapshot_id.is_not(None),
or_(
~app_backed_agent,
publish_visible_revision_exists,
),
)
def agent_has_workflow_callable_active_snapshot(*, session: Session, agent: Agent) -> bool:
"""Return whether ``agent`` has an active snapshot visible to Workflow.
This object-level form is useful after ownership and lifecycle checks have
already loaded an Agent. It intentionally ignores dirty draft state so a
previously published snapshot keeps serving while later edits remain
unpublished.
"""
if not agent.active_config_snapshot_id:
return False
is_app_backed = agent.source == AgentSource.AGENT_APP or (
agent.source == AgentSource.IMPORTED and agent.scope == AgentScope.ROSTER and agent.app_id is not None
)
if not is_app_backed:
return True
return bool(
session.scalar(
select(AgentConfigRevision.id)
.where(
AgentConfigRevision.tenant_id == agent.tenant_id,
AgentConfigRevision.agent_id == agent.id,
AgentConfigRevision.current_snapshot_id == agent.active_config_snapshot_id,
AgentConfigRevision.operation.in_(PUBLISH_VISIBLE_APP_BACKED_REVISION_OPERATIONS),
)
.limit(1)
)
)
+58 -2
View File
@@ -52,8 +52,16 @@ from core.app.entities.queue_entities import (
QueueAgentThoughtEvent,
QueueLLMChunkEvent,
QueueMessageEndEvent,
QueueUIPartEvent,
)
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.tools.entities.ui_entities import (
MessageUIPart,
ToolUIMessage,
build_ui_part_id,
parse_tool_ui_messages,
validate_tool_ui_message_batch,
)
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from extensions.ext_database import db
@@ -435,9 +443,29 @@ class _AgentProcessRecorder:
content = part.get("content")
if content is None:
content = part
self._record_tool_observation(tool_call_id=tool_call_id, tool_name=tool_name, observation=content)
thought_id = self._record_tool_observation(
tool_call_id=tool_call_id,
tool_name=tool_name,
observation=content,
)
def _record_tool_observation(self, *, tool_call_id: str | None, tool_name: str | None, observation: Any) -> None:
metadata = part.get("metadata")
if not isinstance(metadata, Mapping) or "dify_ui_messages" not in metadata:
return
try:
ui_messages = parse_tool_ui_messages(metadata["dify_ui_messages"])
except (TypeError, ValueError):
logger.warning("Ignored invalid dify_ui_messages metadata from Agent backend", exc_info=True)
return
self._publish_ui_messages(namespace=tool_call_id or thought_id, ui_messages=ui_messages)
def _record_tool_observation(
self,
*,
tool_call_id: str | None,
tool_name: str | None,
observation: Any,
) -> str:
self._close_thinking_segments()
thought_id = self._lookup_observation_thought(tool_call_id=tool_call_id, tool_name=tool_name)
if thought_id is None:
@@ -445,6 +473,34 @@ class _AgentProcessRecorder:
else:
self._mark_tool_observed(thought_id)
self._update_thought(thought_id, observation=_json_or_text(observation))
return thought_id
def _publish_ui_messages(self, *, namespace: str, ui_messages: list[ToolUIMessage]) -> None:
try:
validate_tool_ui_message_batch(ui_messages)
except ValueError:
logger.warning(
"Ignored Agent backend UI batch that exceeds queue publication limits",
extra={"message_id": self._message_id, "namespace": namespace},
exc_info=True,
)
return
sequences: dict[str, int] = {}
for ui_message in ui_messages:
part_id = build_ui_part_id(namespace, ui_message.surface_id)
sequence = sequences.get(part_id, 0) + 1
sequences[part_id] = sequence
self._queue_manager.publish(
QueueUIPartEvent(
part=MessageUIPart.from_tool_ui_message(
part_id=part_id,
sequence=sequence,
ui_message=ui_message,
)
),
PublishFrom.APPLICATION_MANAGER,
)
def _lookup_tool_thought(self, *, index: int, tool_call_id: str | None) -> str | None:
if tool_call_id and tool_call_id in self._tool_by_call_id:
+9
View File
@@ -7,6 +7,7 @@ from pydantic import BaseModel, ConfigDict, Field
from core.app.entities.agent_strategy import AgentStrategyInfo
from core.rag.entities import RetrievalSourceMetadata
from core.tools.entities.ui_entities import MessageUIPart
from core.workflow.nodes.human_input.pause_reason import PauseReason
from graphon.entities import WorkflowStartReason
from graphon.enums import NodeType, WorkflowNodeExecutionMetadataKey
@@ -43,6 +44,7 @@ class QueueEvent(StrEnum):
REASONING_CHUNK = "reasoning_chunk"
ANNOTATION_REPLY = "annotation_reply"
AGENT_THOUGHT = "agent_thought"
UI_PART = "ui_part"
MESSAGE_FILE = "message_file"
AGENT_LOG = "agent_log"
ERROR = "error"
@@ -457,6 +459,13 @@ class QueueAgentThoughtEvent(AppQueueEvent):
agent_thought_id: str
class QueueUIPartEvent(AppQueueEvent):
"""A validated tool UI surface revision."""
event: QueueEvent = QueueEvent.UI_PART
part: MessageUIPart
class QueueMessageFileEvent(AppQueueEvent):
"""
QueueAgentThoughtEvent entity
+19 -1
View File
@@ -2,10 +2,11 @@ from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import Any, Literal
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
from core.app.entities.agent_strategy import AgentStrategyInfo
from core.rag.entities import RetrievalSourceMetadata
from core.tools.entities.ui_entities import MessageUIPart, validate_ui_part_batch
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
from graphon.entities import WorkflowStartReason
@@ -30,6 +31,14 @@ class TaskStateMetadata(BaseModel):
reasoning: dict[str, str] = Field(default_factory=dict)
"""reasoning_content per LLM node id (separated mode), accumulated across iteration/loop
passes for that node; persisted to message_metadata"""
ui_parts: list[MessageUIPart] = Field(default_factory=list)
"""Latest validated revision of each tool UI part; persisted to message metadata."""
@field_validator("ui_parts")
@classmethod
def _validate_ui_parts(cls, value: list[MessageUIPart]) -> list[MessageUIPart]:
validate_ui_part_batch(value)
return value
class TaskState(BaseModel):
@@ -73,6 +82,7 @@ class StreamEvent(StrEnum):
MESSAGE_FILE = "message_file"
MESSAGE_REPLACE = "message_replace"
AGENT_THOUGHT = "agent_thought"
UI_PART = "ui_part"
AGENT_MESSAGE = "agent_message"
WORKFLOW_STARTED = "workflow_started"
WORKFLOW_PAUSED = "workflow_paused"
@@ -192,6 +202,14 @@ class AgentThoughtStreamResponse(StreamResponse):
message_files: list[str] | None = None
class UIPartStreamResponse(StreamResponse):
"""A tool-owned UI surface revision."""
event: StreamEvent = StreamEvent.UI_PART
id: str
part: MessageUIPart
class AgentMessageStreamResponse(StreamResponse):
"""
AgentMessageStreamResponse entity
@@ -26,6 +26,7 @@ from core.app.entities.queue_entities import (
QueuePingEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueUIPartEvent,
)
from core.app.entities.task_entities import (
AgentMessageStreamResponse,
@@ -41,6 +42,7 @@ from core.app.entities.task_entities import (
MessageEndStreamResponse,
StreamEvent,
StreamResponse,
UIPartStreamResponse,
)
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
@@ -52,6 +54,7 @@ from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.prompt.utils.prompt_message_util import PromptMessageUtil
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from core.tools.entities.ui_entities import upsert_ui_part
from events.message_event import message_was_created
from graphon.file import FileTransferMethod
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
@@ -311,6 +314,10 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
agent_thought_response = self._agent_thought_to_stream_response(event)
if agent_thought_response is not None:
yield agent_thought_response
case QueueUIPartEvent():
ui_part_response = self._ui_part_to_stream_response(event)
if ui_part_response is not None:
yield ui_part_response
case QueueMessageFileEvent():
response = self._message_cycle_manager.message_file_to_stream_response(event)
if response:
@@ -526,6 +533,27 @@ class EasyUIBasedGenerateTaskPipeline(BasedGenerateTaskPipeline[EasyUIAppGenerat
task_id=self._application_generate_entity.task_id, id=message_id, answer=answer
)
def _ui_part_to_stream_response(self, event: QueueUIPartEvent) -> UIPartStreamResponse | None:
"""Upsert a UI part revision into persistent metadata and stream it."""
try:
updated_parts = upsert_ui_part(self._task_state.metadata.ui_parts, event.part)
except ValueError:
logger.warning(
"Ignored UI part that exceeds assistant message limits",
extra={"message_id": self._message_id, "part_id": event.part.part_id},
exc_info=True,
)
return None
if updated_parts is None:
return None
self._task_state.metadata.ui_parts = updated_parts
return UIPartStreamResponse(
task_id=self._application_generate_entity.task_id,
id=self._message_id,
part=event.part,
)
def _agent_thought_to_stream_response(self, event: QueueAgentThoughtEvent) -> AgentThoughtStreamResponse | None:
"""
Agent thought to stream response.
+1 -1
View File
@@ -71,7 +71,7 @@ def check_credential_policy_compliance(
)
from services.feature_service import FeatureService
if not FeatureService.is_plugin_manager_enabled() or not credential_id:
if not FeatureService.get_system_features().plugin_manager.enabled or not credential_id:
return
# Check if credential exists in database first (if requested)
+1 -2
View File
@@ -6,7 +6,6 @@ from pydantic import BaseModel
from configs import dify_config
from core.entities import DEFAULT_PLUGIN_ID
from core.entities.provider_entities import ProviderQuotaType, QuotaUnit, RestrictModel
from enums.deployment_edition import DeploymentEdition
from graphon.model_runtime.entities.model_entities import ModelType
@@ -50,7 +49,7 @@ class HostingConfiguration:
self.moderation_config = None
def init_app(self, app: Flask):
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
if dify_config.EDITION != "CLOUD":
return
self.provider_map[f"{DEFAULT_PLUGIN_ID}/azure_openai/azure_openai"] = self.init_azure_openai()
+2 -3
View File
@@ -34,7 +34,6 @@ from core.entities.provider_entities import (
from core.helper import encrypter
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.helper.position_helper import is_filtered
from enums.deployment_edition import DeploymentEdition
from extensions import ext_hosting_provider
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@@ -744,7 +743,7 @@ class ProviderManager:
if preferred_provider_type_record:
preferred_provider_type = preferred_provider_type_record.preferred_provider_type
elif dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and system_configuration.enabled:
elif dify_config.EDITION == "CLOUD" and system_configuration.enabled:
preferred_provider_type = ProviderType.SYSTEM
elif custom_configuration.provider or custom_configuration.models:
preferred_provider_type = ProviderType.CUSTOM
@@ -1539,7 +1538,7 @@ class ProviderManager:
quota_type_to_provider_records_dict[provider_record.quota_type] = provider_record # type: ignore[index]
quota_configurations = []
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION == "CLOUD":
from services.credit_pool_service import CreditPoolService
trail_pool = CreditPoolService.get_pool(
@@ -10,7 +10,6 @@ from core.rag.rerank.entity.weight import KeywordSetting, VectorSetting, Weights
from core.rag.rerank.rerank_base import BaseRerankRunner
from core.rag.rerank.rerank_factory import RerankRunnerFactory
from core.rag.rerank.rerank_type import RerankMode
from extensions.otel import trace_span
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError
@@ -53,7 +52,6 @@ class DataPostProcessor:
)
self.reorder_runner = self._get_reorder_runner(reorder_enabled)
@trace_span()
def invoke(
self,
query: str,
+24 -8
View File
@@ -1,10 +1,12 @@
import concurrent.futures
import functools
import logging
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor
from typing import Any, NotRequired, TypedDict
from flask import Flask, current_app
from opentelemetry import context as otel_context
from sqlalchemy import select
from sqlalchemy.orm import Session, load_only
@@ -24,7 +26,7 @@ from core.rag.rerank.rerank_type import RerankMode
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.tools.signature import sign_upload_file_preview_url
from extensions.ext_database import db
from extensions.otel import propagate_context, trace_span
from extensions.otel import trace_span
from graphon.model_runtime.entities.model_entities import ModelType
from models.dataset import (
ChildChunk,
@@ -90,6 +92,20 @@ default_retrieval_model: DefaultRetrievalModelDict = {
logger = logging.getLogger(__name__)
def _propagate_otel_context[**P, R](func: Callable[P, R]) -> Callable[P, R]:
captured_context = otel_context.get_current()
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
token = otel_context.attach(captured_context)
try:
return func(*args, **kwargs)
finally:
otel_context.detach(token)
return wrapper
class RetrievalService:
# Cache precompiled regular expressions to avoid repeated compilation
@classmethod
@@ -123,7 +139,7 @@ class RetrievalService:
if query:
futures.append(
executor.submit(
propagate_context(retrieval_service._retrieve),
_propagate_otel_context(retrieval_service._retrieve),
flask_app=current_app._get_current_object(), # type: ignore
retrieval_method=retrieval_method,
dataset=dataset,
@@ -143,7 +159,7 @@ class RetrievalService:
for attachment_id in attachment_ids:
futures.append(
executor.submit(
propagate_context(retrieval_service._retrieve),
_propagate_otel_context(retrieval_service._retrieve),
flask_app=current_app._get_current_object(), # type: ignore
retrieval_method=retrieval_method,
dataset=dataset,
@@ -804,7 +820,7 @@ class RetrievalService:
if retrieval_method == RetrievalMethod.KEYWORD_SEARCH and query:
futures.append(
executor.submit(
propagate_context(self.keyword_search),
_propagate_otel_context(self.keyword_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,
@@ -818,7 +834,7 @@ class RetrievalService:
if query:
futures.append(
executor.submit(
propagate_context(self.embedding_search),
_propagate_otel_context(self.embedding_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,
@@ -835,7 +851,7 @@ class RetrievalService:
if attachment_id:
futures.append(
executor.submit(
propagate_context(self.embedding_search),
_propagate_otel_context(self.embedding_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=attachment_id,
@@ -852,7 +868,7 @@ class RetrievalService:
if RetrievalMethod.is_support_fulltext_search(retrieval_method) and query:
futures.append(
executor.submit(
propagate_context(self.full_text_index_search),
_propagate_otel_context(self.full_text_index_search),
flask_app=current_app._get_current_object(), # type: ignore
dataset_id=dataset.id,
query=query,
-2
View File
@@ -9,7 +9,6 @@ from core.rag.index_processor.constant.query_type import QueryType
from core.rag.models.document import Document
from core.rag.rerank.rerank_base import BaseRerankRunner
from extensions.ext_storage import storage
from extensions.otel import trace_span
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult
from models.model import UploadFile
@@ -23,7 +22,6 @@ class RerankModelRunner(BaseRerankRunner):
self._session = session
@override
@trace_span()
def run(
self,
query: str,
+24 -101
View File
@@ -65,7 +65,6 @@ from core.workflow.nodes.knowledge_retrieval.retrieval import (
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from extensions.otel import propagate_context, trace_span
from graphon.file import File, FileTransferMethod, FileType
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageRole, PromptMessageTool
@@ -117,7 +116,6 @@ class DatasetRetrieval:
else:
self._llm_usage = self._llm_usage.plus(usage)
@trace_span()
def knowledge_retrieval(self, session: Session, request: KnowledgeRetrievalRequest) -> list[Source]:
self._check_knowledge_rate_limit(request.tenant_id)
available_datasets = self._get_available_datasets(request.tenant_id, request.dataset_ids)
@@ -601,7 +599,6 @@ class DatasetRetrieval:
return "\n".join([document_context.content for document_context in document_context_list]), context_files
return "", context_files
@trace_span()
def single_retrieve(
self,
session: Session,
@@ -727,7 +724,7 @@ class DatasetRetrieval:
if results:
thread = threading.Thread(
target=propagate_context(self._on_retrieval_end),
target=self._on_retrieval_end,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"documents": results,
@@ -740,7 +737,6 @@ class DatasetRetrieval:
return results
return []
@trace_span()
def multiple_retrieve(
self,
app_id: str,
@@ -802,7 +798,7 @@ class DatasetRetrieval:
if query:
query_thread = threading.Thread(
target=propagate_context(self._multiple_retrieve_thread_safely),
target=self._multiple_retrieve_thread,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"available_datasets": available_datasets,
@@ -828,7 +824,7 @@ class DatasetRetrieval:
if attachment_ids:
for attachment_id in attachment_ids:
attachment_thread = threading.Thread(
target=propagate_context(self._multiple_retrieve_thread_safely),
target=self._multiple_retrieve_thread,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"available_datasets": available_datasets,
@@ -869,7 +865,7 @@ class DatasetRetrieval:
if all_documents:
# add thread to call _on_retrieval_end
retrieval_end_thread = threading.Thread(
target=propagate_context(self._on_retrieval_end),
target=self._on_retrieval_end,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"documents": all_documents,
@@ -1165,7 +1161,6 @@ class DatasetRetrieval:
all_documents.extend(documents)
@trace_span()
def _run_retriever_thread(
self,
*,
@@ -1177,51 +1172,27 @@ class DatasetRetrieval:
document_ids_filter: list[str] | None,
metadata_condition: MetadataFilteringCondition | None,
attachment_ids: list[str] | None,
) -> None:
with session_factory.create_session() as session:
self._retriever(
flask_app=flask_app,
session=session,
dataset_id=dataset_id,
query=query or "",
top_k=top_k,
all_documents=all_documents,
document_ids_filter=document_ids_filter,
metadata_condition=metadata_condition,
attachment_ids=attachment_ids,
)
def _run_retriever_thread_safely(
self,
*,
flask_app: Flask,
dataset_id: str,
query: str | None,
top_k: int,
all_documents: list[Document],
document_ids_filter: list[str] | None,
metadata_condition: MetadataFilteringCondition | None,
attachment_ids: list[str] | None,
cancel_event: threading.Event | None,
thread_exceptions: list[Exception] | None,
) -> None:
"""Collect errors only after they pass through the traced retrieval method."""
try:
self._run_retriever_thread(
flask_app=flask_app,
dataset_id=dataset_id,
query=query,
top_k=top_k,
all_documents=all_documents,
document_ids_filter=document_ids_filter,
metadata_condition=metadata_condition,
attachment_ids=attachment_ids,
)
except Exception as exc:
with session_factory.create_session() as session:
self._retriever(
flask_app=flask_app,
session=session,
dataset_id=dataset_id,
query=query or "",
top_k=top_k,
all_documents=all_documents,
document_ids_filter=document_ids_filter,
metadata_condition=metadata_condition,
attachment_ids=attachment_ids,
)
except Exception as e:
if cancel_event:
cancel_event.set()
if thread_exceptions is not None:
thread_exceptions.append(exc)
thread_exceptions.append(e)
def to_dataset_retriever_tool(
self,
@@ -1824,7 +1795,6 @@ class DatasetRetrieval:
return full_text, usage
@trace_span()
def _multiple_retrieve_thread(
self,
flask_app: Flask,
@@ -1843,11 +1813,11 @@ class DatasetRetrieval:
attachment_id: str | None,
dataset_count: int,
cancel_event: threading.Event | None = None,
) -> None:
thread_exceptions: list[Exception] | None = None,
):
try:
with flask_app.app_context():
threads = []
retrieval_thread_exceptions: list[Exception] = []
all_documents_item: list[Document] = []
index_type = None
for dataset in available_datasets:
@@ -1866,7 +1836,7 @@ class DatasetRetrieval:
else:
continue
retrieval_thread = threading.Thread(
target=propagate_context(self._run_retriever_thread_safely),
target=self._run_retriever_thread,
kwargs={
"flask_app": flask_app,
"dataset_id": dataset.id,
@@ -1877,7 +1847,7 @@ class DatasetRetrieval:
"metadata_condition": metadata_condition,
"attachment_ids": [attachment_id] if attachment_id else None,
"cancel_event": cancel_event,
"thread_exceptions": retrieval_thread_exceptions,
"thread_exceptions": thread_exceptions,
},
)
threads.append(retrieval_thread)
@@ -1892,9 +1862,6 @@ class DatasetRetrieval:
if cancel_event and cancel_event.is_set():
break
if retrieval_thread_exceptions:
raise retrieval_thread_exceptions[0]
# Skip second reranking when there is only one dataset
if reranking_enable and dataset_count > 1:
# do rerank for searched documents
@@ -1935,55 +1902,11 @@ class DatasetRetrieval:
all_documents_item = all_documents_item[:top_k] if top_k else all_documents_item
if all_documents_item:
all_documents.extend(all_documents_item)
except Exception:
raise
def _multiple_retrieve_thread_safely(
self,
*,
flask_app: Flask,
available_datasets: list[Dataset],
metadata_condition: MetadataFilteringCondition | None,
metadata_filter_document_ids: dict[str, list[str]] | None,
all_documents: list[Document],
tenant_id: str,
reranking_enable: bool,
reranking_mode: str,
reranking_model: RerankingModelDict | None,
weights: WeightsDict | None,
top_k: int,
score_threshold: float,
query: str | None,
attachment_id: str | None,
dataset_count: int,
cancel_event: threading.Event | None = None,
thread_exceptions: list[Exception] | None = None,
) -> None:
"""Collect errors only after they pass through the traced multi-retrieval method."""
try:
self._multiple_retrieve_thread(
flask_app=flask_app,
available_datasets=available_datasets,
metadata_condition=metadata_condition,
metadata_filter_document_ids=metadata_filter_document_ids,
all_documents=all_documents,
tenant_id=tenant_id,
reranking_enable=reranking_enable,
reranking_mode=reranking_mode,
reranking_model=reranking_model,
weights=weights,
top_k=top_k,
score_threshold=score_threshold,
query=query,
attachment_id=attachment_id,
dataset_count=dataset_count,
cancel_event=cancel_event,
)
except Exception as exc:
except Exception as e:
if cancel_event:
cancel_event.set()
if thread_exceptions is not None:
thread_exceptions.append(exc)
thread_exceptions.append(e)
def _get_available_datasets(self, tenant_id: str, dataset_ids: list[str]) -> list[Dataset]:
with session_factory.create_session() as session:
+8
View File
@@ -17,6 +17,7 @@ from core.tools.entities.tool_entities import (
ToolParameter,
ToolProviderType,
)
from core.tools.entities.ui_entities import ToolUIMessage
class Tool(ABC):
@@ -295,6 +296,13 @@ class Tool(ABC):
message=ToolInvokeMessage.JsonMessage(json_object=object, suppress_output=suppress_output),
)
def create_ui_message(self, ui_message: ToolUIMessage | dict[str, Any]) -> ToolInvokeMessage:
"""Create a validated, model-invisible UI message for chat clients."""
return ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.UI,
message=ToolUIMessage.model_validate(ui_message),
)
def create_variable_message(
self, variable_name: str, variable_value: Any, stream: bool = False
) -> ToolInvokeMessage:
@@ -7,6 +7,9 @@ from sqlalchemy.orm import Session
from core.tools.builtin_tool.tool import BuiltinTool
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.entities.ui_entities import A2UI_CATALOG_ID, A2UI_PROTOCOL_VERSION
_CURRENT_TIME_SURFACE_ID = "current-time"
class CurrentTimeTool(BuiltinTool):
@@ -20,19 +23,60 @@ class CurrentTimeTool(BuiltinTool):
app_id: str | None = None,
message_id: str | None = None,
) -> Generator[ToolInvokeMessage, None, None]:
"""
invoke tools
"""
# get timezone
tz = tool_parameters.get("timezone", "UTC")
fm = tool_parameters.get("format") or "%Y-%m-%d %H:%M:%S %Z"
if tz == "UTC":
yield self.create_text_message(f"{datetime.now(UTC).strftime(fm)}")
return
"""Return the formatted time for the model and a standard time card for chat clients.
try:
tz = pytz_timezone(tz)
except Exception:
yield self.create_text_message(f"Invalid timezone: {tz}")
return
yield self.create_text_message(f"{datetime.now(tz).strftime(fm)}")
Invalid timezone values retain the existing text-only error response.
"""
timezone_name = tool_parameters.get("timezone", "UTC")
fm = tool_parameters.get("format") or "%Y-%m-%d %H:%M:%S %Z"
if timezone_name == "UTC":
current_time = datetime.now(UTC)
else:
try:
timezone_info = pytz_timezone(timezone_name)
except Exception:
yield self.create_text_message(f"Invalid timezone: {timezone_name}")
return
current_time = datetime.now(timezone_info)
formatted_time = current_time.strftime(fm)
yield self.create_text_message(formatted_time)
yield self.create_ui_message(
{
"messages": [
{
"version": A2UI_PROTOCOL_VERSION,
"createSurface": {
"surfaceId": _CURRENT_TIME_SURFACE_ID,
"catalogId": A2UI_CATALOG_ID,
},
},
{
"version": A2UI_PROTOCOL_VERSION,
"updateDataModel": {
"surfaceId": _CURRENT_TIME_SURFACE_ID,
"value": {"currentTime": current_time.isoformat()},
},
},
{
"version": A2UI_PROTOCOL_VERSION,
"updateComponents": {
"surfaceId": _CURRENT_TIME_SURFACE_ID,
"components": [
{
"id": "root",
"component": "Card",
"children": ["time"],
},
{
"id": "time",
"component": "DateTime",
"value": {"path": "/currentTime"},
"format": "datetime",
},
],
},
},
],
}
)
+6 -1
View File
@@ -32,6 +32,7 @@ from core.plugin.entities.parameters import (
from core.rag.entities import RetrievalSourceMetadata
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.constants import TOOL_SELECTOR_MODEL_IDENTITY
from core.tools.entities.ui_entities import ToolUIMessage
class EmojiIconDict(TypedDict):
@@ -240,13 +241,15 @@ class ToolInvokeMessage(BaseModel):
LOG = auto()
BLOB_CHUNK = auto()
RETRIEVER_RESOURCES = auto()
UI = auto()
type: MessageType = MessageType.TEXT
"""
plain text, image url or link url
"""
message: (
JsonMessage
ToolUIMessage
| JsonMessage
| TextMessage
| BlobChunkMessage
| BlobMessage
@@ -275,6 +278,8 @@ class ToolInvokeMessage(BaseModel):
v = {"json_object": v}
elif msg_type == cls.MessageType.FILE:
v = {"file_marker": "file_marker"}
elif msg_type == cls.MessageType.UI:
v = ToolUIMessage.model_validate(v)
return v
+675
View File
@@ -0,0 +1,675 @@
"""Validated, read-only UI messages emitted by tools.
The public wire format is the A2UI v0.9.1 flat message shape, narrowed to a
Dify-owned catalog. Tool authors can bind display values to the surface data
model, but cannot supply executable actions, HTML, styles, themes, or custom
components. A complete ``ToolUIMessage`` describes exactly one surface and is
validated as a bounded, self-contained component graph before it crosses into
chat streaming. Sequential data-model patches are materialized with the same
object/array upsert semantics as the web renderer so cumulative limits cannot
be bypassed with individually small updates.
"""
from __future__ import annotations
import json
import re
import uuid
from collections.abc import Sequence
from enum import StrEnum
from typing import Annotated, Literal
from pydantic import (
BaseModel,
ConfigDict,
Field,
JsonValue,
SerializerFunctionWrapHandler,
field_validator,
model_serializer,
model_validator,
)
A2UI_PROTOCOL = "a2ui"
A2UI_PROTOCOL_VERSION = "v0.9.1"
A2UI_CATALOG_ID = "https://dify.ai/a2ui/catalog/v1"
DIFY_UI_JSON_ENVELOPE_KEY = "__dify_ui__"
MAX_UI_MESSAGES = 64
MAX_UI_COMPONENTS = 100
MAX_UI_STRING_LENGTH = 4096
MAX_UI_PAYLOAD_BYTES = 128 * 1024
MAX_DATA_MODEL_DEPTH = 16
MAX_DATA_MODEL_NODES = 2000
MAX_DATA_MODEL_ARRAY_INDEX = 1000
MAX_JSON_POINTER_SEGMENTS = 16
MAX_UI_PARTS_PER_MESSAGE = 16
MAX_UI_PARTS_PAYLOAD_BYTES = 512 * 1024
_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
_ARRAY_INDEX_PATTERN = re.compile(r"^(?:0|[1-9]\d*)$")
_DANGEROUS_POINTER_SEGMENTS = {"__proto__", "constructor", "prototype"}
_DANGEROUS_DATA_KEYS = _DANGEROUS_POINTER_SEGMENTS
_MAX_HISTORY_UI_PART_CANDIDATES = MAX_UI_PARTS_PER_MESSAGE * 4
_UI_PART_ID_NAMESPACE = uuid.uuid5(uuid.NAMESPACE_URL, "https://dify.ai/a2ui/part-id/v1")
class _StrictModel(BaseModel):
model_config = ConfigDict(
extra="forbid",
populate_by_name=True,
serialize_by_alias=True,
allow_inf_nan=False,
)
class A2UIDataBinding(_StrictModel):
"""A JSON Pointer into the surface data model."""
path: str
@field_validator("path")
@classmethod
def _validate_path(cls, value: str) -> str:
return _validate_json_pointer(value, label="data binding path")
type DynamicString = str | A2UIDataBinding
type DynamicNumber = int | float | A2UIDataBinding
type DynamicScalar = str | int | float | bool | None | A2UIDataBinding
class A2UIComponentType(StrEnum):
CARD = "Card"
ROW = "Row"
COLUMN = "Column"
TEXT = "Text"
ICON = "Icon"
DIVIDER = "Divider"
BADGE = "Badge"
METRIC = "Metric"
DATE_TIME = "DateTime"
PROGRESS = "Progress"
KEY_VALUE = "KeyValue"
class A2UIComponent(_StrictModel):
"""One flat component entry from the Dify catalog."""
id: str
component: A2UIComponentType
children: list[str] | None = None
title: DynamicString | None = None
gap: Literal["small", "medium", "large"] | None = None
align: Literal["start", "center", "end"] | None = None
text: DynamicString | None = None
variant: Literal["body", "caption"] | None = None
name: (
Literal[
"clock",
"cloud",
"sun",
"rain",
"snow",
"wind",
"thermometer",
"calendar",
"location",
]
| None
) = None
tone: Literal["neutral", "info", "success", "warning", "critical"] | None = None
label: DynamicString | None = None
value: DynamicScalar | None = None
unit: DynamicString | None = None
format: Literal["date", "time", "datetime"] | None = None
max: DynamicNumber | None = None
@field_validator("id")
@classmethod
def _validate_id(cls, value: str) -> str:
if not _ID_PATTERN.fullmatch(value):
raise ValueError("component id contains unsupported characters or is too long")
return value
@field_validator("children")
@classmethod
def _validate_children(cls, value: list[str] | None) -> list[str] | None:
if value is None:
return None
if len(value) > MAX_UI_COMPONENTS:
raise ValueError("component has too many children")
if len(value) != len(set(value)):
raise ValueError("component children must not contain duplicate ids")
for child_id in value:
if not _ID_PATTERN.fullmatch(child_id):
raise ValueError("child component id contains unsupported characters or is too long")
return value
@model_validator(mode="after")
def _validate_component_props(self) -> A2UIComponent:
present = self.model_fields_set - {"id", "component"}
allowed: dict[A2UIComponentType, set[str]] = {
A2UIComponentType.CARD: {"children", "title"},
A2UIComponentType.ROW: {"children", "gap", "align"},
A2UIComponentType.COLUMN: {"children", "gap"},
A2UIComponentType.TEXT: {"text", "variant"},
A2UIComponentType.ICON: {"name"},
A2UIComponentType.DIVIDER: set(),
A2UIComponentType.BADGE: {"text", "tone"},
A2UIComponentType.METRIC: {"label", "value", "unit"},
A2UIComponentType.DATE_TIME: {"value", "format"},
A2UIComponentType.PROGRESS: {"value", "max", "label"},
A2UIComponentType.KEY_VALUE: {"label", "value"},
}
required: dict[A2UIComponentType, set[str]] = {
A2UIComponentType.CARD: {"children"},
A2UIComponentType.ROW: {"children"},
A2UIComponentType.COLUMN: {"children"},
A2UIComponentType.TEXT: {"text"},
A2UIComponentType.ICON: {"name"},
A2UIComponentType.DIVIDER: set(),
A2UIComponentType.BADGE: {"text"},
A2UIComponentType.METRIC: {"label", "value"},
A2UIComponentType.DATE_TIME: {"value"},
A2UIComponentType.PROGRESS: {"value", "label"},
A2UIComponentType.KEY_VALUE: {"label", "value"},
}
unexpected = present - allowed[self.component]
missing = required[self.component] - present
if unexpected:
raise ValueError(f"{self.component} does not support properties: {sorted(unexpected)}")
if missing:
raise ValueError(f"{self.component} requires properties: {sorted(missing)}")
nullable_props = (
{"value"} if self.component in {A2UIComponentType.METRIC, A2UIComponentType.KEY_VALUE} else set()
)
null_props = {prop for prop in present if getattr(self, prop) is None and prop not in nullable_props}
if null_props:
raise ValueError(f"{self.component} properties cannot be null: {sorted(null_props)}")
if self.component == A2UIComponentType.DATE_TIME and not isinstance(self.value, str | A2UIDataBinding):
raise ValueError("DateTime value must be a string or data binding")
if self.component == A2UIComponentType.PROGRESS and (
isinstance(self.value, bool) or not isinstance(self.value, int | float | A2UIDataBinding)
):
raise ValueError("Progress value must be a number or data binding")
if (
self.component == A2UIComponentType.PROGRESS
and "max" in present
and (isinstance(self.max, bool) or not isinstance(self.max, int | float | A2UIDataBinding))
):
raise ValueError("Progress max must be a number or data binding")
return self
@model_serializer(mode="wrap")
def _serialize_component(self, handler: SerializerFunctionWrapHandler) -> dict[str, JsonValue]:
serialized = handler(self)
return {
key: value
for key, value in serialized.items()
if key in {"id", "component"} or key in self.model_fields_set
}
class A2UICreateSurface(_StrictModel):
surface_id: str = Field(alias="surfaceId")
catalog_id: Literal["https://dify.ai/a2ui/catalog/v1"] = Field(alias="catalogId")
@field_validator("surface_id")
@classmethod
def _validate_surface_id(cls, value: str) -> str:
if not _ID_PATTERN.fullmatch(value):
raise ValueError("surface id contains unsupported characters or is too long")
return value
class A2UIUpdateComponents(_StrictModel):
surface_id: str = Field(alias="surfaceId")
components: Annotated[list[A2UIComponent], Field(min_length=1, max_length=MAX_UI_COMPONENTS)]
@field_validator("surface_id")
@classmethod
def _validate_surface_id(cls, value: str) -> str:
if not _ID_PATTERN.fullmatch(value):
raise ValueError("surface id contains unsupported characters or is too long")
return value
@field_validator("components")
@classmethod
def _validate_unique_component_ids(cls, value: list[A2UIComponent]) -> list[A2UIComponent]:
ids = [component.id for component in value]
if len(ids) != len(set(ids)):
raise ValueError("updateComponents contains duplicate component ids")
return value
class A2UIUpdateDataModel(_StrictModel):
surface_id: str = Field(alias="surfaceId")
value: JsonValue
path: str | None = None
@field_validator("surface_id")
@classmethod
def _validate_surface_id(cls, value: str) -> str:
if not _ID_PATTERN.fullmatch(value):
raise ValueError("surface id contains unsupported characters or is too long")
return value
@field_validator("value")
@classmethod
def _validate_value(cls, value: JsonValue) -> JsonValue:
_validate_data_model_value(value)
return value
@field_validator("path")
@classmethod
def _validate_path(cls, value: str | None) -> str | None:
if value is None:
return value
return _validate_json_pointer(value, label="data model path", enforce_array_index_limit=True)
@model_serializer(mode="wrap")
def _serialize_update(self, handler: SerializerFunctionWrapHandler) -> dict[str, JsonValue]:
serialized = handler(self)
if "path" not in self.model_fields_set:
serialized.pop("path", None)
return serialized
class A2UIDeleteSurface(_StrictModel):
surface_id: str = Field(alias="surfaceId")
@field_validator("surface_id")
@classmethod
def _validate_surface_id(cls, value: str) -> str:
if not _ID_PATTERN.fullmatch(value):
raise ValueError("surface id contains unsupported characters or is too long")
return value
class A2UIMessage(_StrictModel):
"""A single A2UI v0.9.1 server message."""
version: Literal["v0.9.1"]
create_surface: A2UICreateSurface | None = Field(default=None, alias="createSurface")
update_components: A2UIUpdateComponents | None = Field(default=None, alias="updateComponents")
update_data_model: A2UIUpdateDataModel | None = Field(default=None, alias="updateDataModel")
delete_surface: A2UIDeleteSurface | None = Field(default=None, alias="deleteSurface")
@model_validator(mode="after")
def _validate_exactly_one_operation(self) -> A2UIMessage:
operations = (
self.create_surface,
self.update_components,
self.update_data_model,
self.delete_surface,
)
if sum(operation is not None for operation in operations) != 1:
raise ValueError("A2UI message must contain exactly one operation")
return self
@model_serializer(mode="wrap")
def _serialize_message(self, handler: SerializerFunctionWrapHandler) -> dict[str, JsonValue]:
return {key: value for key, value in handler(self).items() if value is not None}
@property
def surface_id(self) -> str:
operation = self.create_surface or self.update_components or self.update_data_model or self.delete_surface
assert operation is not None
return operation.surface_id
class ToolUIMessage(_StrictModel):
"""A complete, validated UI surface emitted by one tool result.
Validation covers both the component graph and every materialized
data-model revision, because clients render the message sequence in order.
"""
protocol: Literal["a2ui"] = "a2ui"
protocol_version: Literal["v0.9.1"] = "v0.9.1"
messages: Annotated[list[A2UIMessage], Field(min_length=1, max_length=MAX_UI_MESSAGES)]
fallback: str | None = None
@field_validator("fallback")
@classmethod
def _validate_fallback(cls, value: str | None) -> str | None:
if value is not None and len(value) > MAX_UI_STRING_LENGTH:
raise ValueError("UI fallback is too long")
return value
@model_validator(mode="after")
def _validate_surface(self) -> ToolUIMessage:
first = self.messages[0]
if first.create_surface is None:
raise ValueError("first A2UI message must be createSurface")
if any(message.create_surface is not None for message in self.messages[1:]):
raise ValueError("createSurface may only appear as the first message")
delete_indexes = [index for index, message in enumerate(self.messages) if message.delete_surface]
if delete_indexes and delete_indexes != [len(self.messages) - 1]:
raise ValueError("deleteSurface may only appear once as the final message")
surface_id = first.surface_id
if any(message.surface_id != surface_id for message in self.messages):
raise ValueError("all A2UI messages in a tool UI message must target the same surface")
components: dict[str, A2UIComponent] = {}
data_model: JsonValue = {}
root_seen_before_delete = False
for message in self.messages[1:]:
if message.update_components is not None:
for component in message.update_components.components:
components[component.id] = component
if len(components) > MAX_UI_COMPONENTS:
raise ValueError("UI surface has too many components")
if message.update_data_model is not None:
data_model = _apply_data_model_update(data_model, message.update_data_model)
_validate_data_model_value(data_model)
if message.delete_surface is not None:
root_seen_before_delete = "root" in components
if "root" not in components and not root_seen_before_delete:
raise ValueError("UI surface must define a component with id 'root'")
_validate_component_graph(components)
payload = self.model_dump(mode="json", by_alias=True, exclude_none=True)
_validate_bounded_json(payload)
return self
@model_serializer(mode="wrap")
def _serialize_ui_message(self, handler: SerializerFunctionWrapHandler) -> dict[str, JsonValue]:
return {key: value for key, value in handler(self).items() if value is not None}
@property
def surface_id(self) -> str:
return self.messages[0].surface_id
class MessageUIPart(_StrictModel):
"""SSE/history representation of one tool-owned UI surface revision."""
part_id: Annotated[str, Field(min_length=1, max_length=512)]
sequence: Annotated[int, Field(ge=1)]
protocol: Literal["a2ui"] = "a2ui"
protocol_version: Literal["v0.9.1"] = "v0.9.1"
messages: Annotated[list[A2UIMessage], Field(min_length=1, max_length=MAX_UI_MESSAGES)]
fallback: str | None = None
@model_validator(mode="after")
def _validate_messages(self) -> MessageUIPart:
ToolUIMessage(
protocol=self.protocol,
protocol_version=self.protocol_version,
messages=self.messages,
fallback=self.fallback,
)
return self
@model_serializer(mode="wrap")
def _serialize_part(self, handler: SerializerFunctionWrapHandler) -> dict[str, JsonValue]:
return {key: value for key, value in handler(self).items() if value is not None}
@classmethod
def from_tool_ui_message(
cls,
*,
part_id: str,
sequence: int,
ui_message: ToolUIMessage,
) -> MessageUIPart:
return cls(
part_id=part_id,
sequence=sequence,
protocol=ui_message.protocol,
protocol_version=ui_message.protocol_version,
messages=ui_message.messages,
fallback=ui_message.fallback,
)
def extract_ui_message_from_json(value: JsonValue) -> ToolUIMessage | None:
"""Recognize the reserved compatibility envelope used by older SDKs."""
if not isinstance(value, dict) or set(value) != {DIFY_UI_JSON_ENVELOPE_KEY}:
return None
return ToolUIMessage.model_validate(value[DIFY_UI_JSON_ENVELOPE_KEY])
def parse_tool_ui_messages(value: object) -> list[ToolUIMessage]:
"""Validate a list received through the Agent backend metadata channel."""
if not isinstance(value, list):
raise ValueError("dify_ui_messages metadata must be a list")
if len(value) > MAX_UI_PARTS_PER_MESSAGE:
raise ValueError(f"tool UI batch cannot contain more than {MAX_UI_PARTS_PER_MESSAGE} messages")
messages = [ToolUIMessage.model_validate(item) for item in value]
validate_tool_ui_message_batch(messages)
return messages
def build_ui_part_id(namespace: str, surface_id: str) -> str:
"""Build a stable, bounded ID from an unambiguous tool/surface tuple."""
if not isinstance(namespace, str) or not isinstance(surface_id, str):
raise TypeError("UI part namespace and surface id must be strings")
identity = json.dumps((namespace, surface_id), ensure_ascii=True, separators=(",", ":"))
return f"ui-{uuid.uuid5(_UI_PART_ID_NAMESPACE, identity)}"
def validate_tool_ui_message_batch(messages: Sequence[ToolUIMessage]) -> None:
"""Enforce the per-tool-call UI batch budget before queue publication."""
if len(messages) > MAX_UI_PARTS_PER_MESSAGE:
raise ValueError(f"tool UI batch cannot contain more than {MAX_UI_PARTS_PER_MESSAGE} messages")
if _serialized_model_list_size(messages) > MAX_UI_PARTS_PAYLOAD_BYTES:
raise ValueError(f"tool UI batch payload cannot exceed {MAX_UI_PARTS_PAYLOAD_BYTES} bytes")
def validate_ui_part_batch(parts: Sequence[MessageUIPart]) -> None:
"""Enforce persisted/current assistant-message UI budgets."""
if len(parts) > MAX_UI_PARTS_PER_MESSAGE:
raise ValueError(f"assistant message cannot contain more than {MAX_UI_PARTS_PER_MESSAGE} UI parts")
part_ids = [part.part_id for part in parts]
if len(part_ids) != len(set(part_ids)):
raise ValueError("assistant message UI parts must have distinct part ids")
if _serialized_model_list_size(parts) > MAX_UI_PARTS_PAYLOAD_BYTES:
raise ValueError(f"assistant message UI parts cannot exceed {MAX_UI_PARTS_PAYLOAD_BYTES} bytes")
def upsert_ui_part(parts: Sequence[MessageUIPart], part: MessageUIPart) -> list[MessageUIPart] | None:
"""Return a bounded new part list, or ``None`` for a stale revision."""
candidate = list(parts)
for index, existing_part in enumerate(candidate):
if existing_part.part_id != part.part_id:
continue
if existing_part.sequence >= part.sequence:
return None
candidate[index] = part
break
else:
candidate.append(part)
validate_ui_part_batch(candidate)
return candidate
def parse_history_ui_parts(value: object) -> list[MessageUIPart]:
"""Best-effort recovery of bounded UI parts from untrusted historical metadata."""
if not isinstance(value, list):
return []
parts: list[MessageUIPart] = []
for item in value[:_MAX_HISTORY_UI_PART_CANDIDATES]:
try:
part = MessageUIPart.model_validate(item)
updated_parts = upsert_ui_part(parts, part)
except (TypeError, ValueError):
continue
if updated_parts is not None:
parts = updated_parts
return parts
def _validate_component_graph(components: dict[str, A2UIComponent]) -> None:
for component in components.values():
for child_id in component.children or []:
if child_id not in components:
raise ValueError(f"component {component.id!r} references unknown child {child_id!r}")
visiting: set[str] = set()
visited: set[str] = set()
def visit(component_id: str) -> None:
if component_id in visiting:
raise ValueError("UI component graph contains a cycle")
if component_id in visited:
return
visiting.add(component_id)
for child_id in components[component_id].children or []:
visit(child_id)
visiting.remove(component_id)
visited.add(component_id)
for component_id in components:
visit(component_id)
parent_by_child: dict[str, str] = {}
for component in components.values():
for child_id in component.children or []:
if child_id == "root":
raise ValueError("root component cannot be referenced as a child")
existing_parent = parent_by_child.get(child_id)
if existing_parent is not None:
raise ValueError(
f"component {child_id!r} has multiple parents: {existing_parent!r} and {component.id!r}"
)
parent_by_child[child_id] = component.id
reachable = {"root"}
pending = ["root"]
while pending:
component_id = pending.pop()
for child_id in components[component_id].children or []:
if child_id in reachable:
continue
reachable.add(child_id)
pending.append(child_id)
unreachable = set(components) - reachable
if unreachable:
raise ValueError(f"UI component graph contains components unreachable from root: {sorted(unreachable)}")
def _serialized_model_list_size(values: Sequence[BaseModel]) -> int:
payload = [value.model_dump(mode="json", by_alias=True, exclude_none=True) for value in values]
return len(json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode())
def _validate_json_pointer(
value: str,
*,
label: str,
enforce_array_index_limit: bool = False,
) -> str:
if not value.startswith("/"):
raise ValueError(f"{label} must be a JSON Pointer beginning with '/'")
if len(value) > 256:
raise ValueError(f"{label} is too long")
if re.search(r"~(?![01])", value):
raise ValueError(f"{label} contains an invalid JSON Pointer escape")
decoded_segments = _decode_json_pointer(value)
if len(decoded_segments) > MAX_JSON_POINTER_SEGMENTS:
raise ValueError(f"{label} contains too many segments")
if set(decoded_segments).intersection(_DANGEROUS_POINTER_SEGMENTS):
raise ValueError(f"{label} contains a forbidden segment")
if enforce_array_index_limit and any(
segment.isdecimal() and int(segment) > MAX_DATA_MODEL_ARRAY_INDEX for segment in decoded_segments
):
raise ValueError(f"{label} contains an array index larger than {MAX_DATA_MODEL_ARRAY_INDEX}")
return value
def _decode_json_pointer(value: str) -> list[str]:
if value == "/":
return []
return [segment.replace("~1", "/").replace("~0", "~") for segment in value.split("/")[1:]]
def _apply_data_model_update(current: JsonValue, update: A2UIUpdateDataModel) -> JsonValue:
path = update.path
if path is None or path == "/":
return update.value
segments = _decode_json_pointer(path)
def apply_at(node: JsonValue | None, segment_index: int) -> JsonValue:
if segment_index == len(segments):
return update.value
segment = segments[segment_index]
is_array_segment = _ARRAY_INDEX_PATTERN.fullmatch(segment) is not None
if isinstance(node, list) or (not isinstance(node, dict) and is_array_segment):
if not is_array_segment:
raise ValueError("data model array paths must use canonical non-negative indexes")
index = int(segment)
if index > MAX_DATA_MODEL_ARRAY_INDEX:
raise ValueError(f"data model array index cannot exceed {MAX_DATA_MODEL_ARRAY_INDEX}")
next_node = list(node) if isinstance(node, list) else []
if index > len(next_node):
raise ValueError("data model array index cannot create a gap")
child = next_node[index] if index < len(next_node) else None
updated_child = apply_at(child, segment_index + 1)
if index == len(next_node):
next_node.append(updated_child)
else:
next_node[index] = updated_child
return next_node
next_node = dict(node) if isinstance(node, dict) else {}
next_node[segment] = apply_at(next_node.get(segment), segment_index + 1)
return next_node
return apply_at(current, 0)
def _validate_data_model_value(value: JsonValue) -> None:
nodes = 0
def walk(node: JsonValue, *, depth: int) -> None:
nonlocal nodes
nodes += 1
if nodes > MAX_DATA_MODEL_NODES:
raise ValueError("data model value has too many nodes")
if depth > MAX_DATA_MODEL_DEPTH:
raise ValueError("data model value is nested too deeply")
if isinstance(node, list):
for item in node:
walk(item, depth=depth + 1)
return
if isinstance(node, dict):
unsafe_keys = set(node).intersection(_DANGEROUS_DATA_KEYS)
if unsafe_keys:
raise ValueError(f"data model value contains forbidden keys: {sorted(unsafe_keys)}")
for item in node.values():
walk(item, depth=depth + 1)
walk(value, depth=0)
def _validate_bounded_json(value: JsonValue) -> None:
def walk(node: JsonValue) -> None:
if isinstance(node, str):
if len(node) > MAX_UI_STRING_LENGTH:
raise ValueError("UI payload contains a string that is too long")
return
if isinstance(node, list):
for item in node:
walk(item)
return
if isinstance(node, dict):
for item in node.values():
walk(item)
walk(value)
encoded = json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
if len(encoded) > MAX_UI_PAYLOAD_BYTES:
raise ValueError("UI payload is too large")
+165 -8
View File
@@ -3,6 +3,7 @@ import json
import logging
from collections.abc import Generator, Iterable
from copy import deepcopy
from dataclasses import dataclass
from datetime import UTC, datetime
from mimetypes import guess_type
from typing import Any, Union, cast
@@ -21,6 +22,12 @@ from core.tools.entities.tool_entities import (
ToolInvokeMeta,
ToolParameter,
)
from core.tools.entities.ui_entities import (
DIFY_UI_JSON_ENVELOPE_KEY,
ToolUIMessage,
extract_ui_message_from_json,
validate_tool_ui_message_batch,
)
from core.tools.errors import (
ToolEngineInvokeError,
ToolInvokeError,
@@ -40,6 +47,16 @@ from models.model import Message, MessageFile
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class ToolAgentInvokeResult:
"""Agent-facing tool result with UI kept outside the model observation."""
observation: str
message_files: list[str]
ui_messages: list[ToolUIMessage]
meta: ToolInvokeMeta
class ToolEngine:
"""
Tool runtime engine take care of the tool executions.
@@ -59,9 +76,13 @@ class ToolEngine:
conversation_id: str | None = None,
app_id: str | None = None,
message_id: str | None = None,
) -> tuple[str, list[str], ToolInvokeMeta]:
) -> ToolAgentInvokeResult:
"""
Agent invokes the tool with the given arguments.
Invoke a tool for an agent.
UI messages are validated and returned on a dedicated channel. They are
intentionally excluded from both the LLM observation and tracing
callback output.
"""
# check if arguments is a string
if isinstance(tool_parameters, str):
@@ -103,7 +124,7 @@ class ToolEngine:
conversation_id=message.conversation_id,
)
message_list = list(messages)
message_list, ui_messages = ToolEngine.collect_agent_messages(messages)
# extract binary data from tool invoke message
binary_files = ToolEngine._extract_tool_response_binary_and_text(message_list)
@@ -126,7 +147,12 @@ class ToolEngine:
)
# transform tool invoke message to get LLM friendly message
return plain_text, message_files, meta
return ToolAgentInvokeResult(
observation=plain_text,
message_files=message_files,
ui_messages=ui_messages,
meta=meta,
)
except ToolProviderCredentialValidationError as e:
logger.error(e, exc_info=True)
error_response = "Please check your tool provider credentials"
@@ -148,13 +174,23 @@ class ToolEngine:
error_response = f"tool invoke error: {meta.error}"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
return error_response, [], meta
return ToolAgentInvokeResult(
observation=error_response,
message_files=[],
ui_messages=[],
meta=meta,
)
except Exception as e:
error_response = f"unknown error: {e}"
agent_tool_callback.on_tool_error(e)
logger.error(e, exc_info=True)
return error_response, [], ToolInvokeMeta.error_instance(error_response)
return ToolAgentInvokeResult(
observation=error_response,
message_files=[],
ui_messages=[],
meta=ToolInvokeMeta.error_instance(error_response),
)
@staticmethod
def generic_invoke(
@@ -197,7 +233,7 @@ class ToolEngine:
tool_outputs=response,
)
return response
return ToolEngine.normalize_ui_messages(response)
except Exception as e:
workflow_tool_callback.on_tool_error(e)
raise e
@@ -266,7 +302,10 @@ class ToolEngine:
ensure_ascii=False,
)
)
elif response.type == ToolInvokeMessage.MessageType.VARIABLE:
elif response.type in {
ToolInvokeMessage.MessageType.VARIABLE,
ToolInvokeMessage.MessageType.UI,
}:
continue
else:
parts.append(str(response.message))
@@ -278,6 +317,124 @@ class ToolEngine:
return "".join(parts)
@staticmethod
def normalize_ui_messages(messages: Iterable[ToolInvokeMessage]) -> Generator[ToolInvokeMessage, None, None]:
"""Convert reserved variable/JSON compatibility envelopes into native UI."""
for message in messages:
if message.type == ToolInvokeMessage.MessageType.VARIABLE:
variable_message = cast(ToolInvokeMessage.VariableMessage, message.message)
if variable_message.variable_name != DIFY_UI_JSON_ENVELOPE_KEY:
yield message
continue
yield ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.UI,
message=ToolUIMessage.model_validate(variable_message.variable_value),
meta=message.meta,
)
continue
if message.type != ToolInvokeMessage.MessageType.JSON:
yield message
continue
json_message = cast(ToolInvokeMessage.JsonMessage, message.message)
ui_message = extract_ui_message_from_json(json_message.json_object)
if ui_message is None:
yield message
continue
yield ToolInvokeMessage(type=ToolInvokeMessage.MessageType.UI, message=ui_message, meta=message.meta)
@staticmethod
def collect_agent_messages(
messages: Iterable[ToolInvokeMessage],
) -> tuple[list[ToolInvokeMessage], list[ToolUIMessage]]:
"""Collect a bounded UI batch while preserving all non-UI observations.
A malformed or over-budget UI message invalidates the complete UI
batch. Remaining UI is skipped without parsing, but the input iterable
is still consumed so later text and file messages are retained.
"""
normalized_messages: list[ToolInvokeMessage] = []
ui_messages: list[ToolUIMessage] = []
ui_batch_rejected = False
for message in messages:
is_ui_transport = ToolEngine._is_ui_transport_message(message)
if ui_batch_rejected and is_ui_transport:
continue
try:
normalized = ToolEngine.normalize_ui_messages([message])
except (TypeError, ValueError):
if not is_ui_transport:
raise
# Reserved UI envelopes must never fall back to JSON/variable
# observations, even when malformed.
ui_batch_rejected = True
ui_messages.clear()
normalized_messages = [
normalized_message
for normalized_message in normalized_messages
if normalized_message.type != ToolInvokeMessage.MessageType.UI
]
logger.warning("Ignored invalid reserved tool UI message", exc_info=True)
continue
try:
for normalized_message in normalized:
if normalized_message.type != ToolInvokeMessage.MessageType.UI:
normalized_messages.append(normalized_message)
continue
if ui_batch_rejected:
continue
ui_message = cast(ToolUIMessage, normalized_message.message)
candidate = [*ui_messages, ui_message]
try:
validate_tool_ui_message_batch(candidate)
except ValueError:
ui_batch_rejected = True
ui_messages.clear()
normalized_messages = [
collected_message
for collected_message in normalized_messages
if collected_message.type != ToolInvokeMessage.MessageType.UI
]
logger.warning(
"Ignored tool UI batch that exceeds agent invocation limits",
exc_info=True,
)
continue
ui_messages = candidate
normalized_messages.append(normalized_message)
except (TypeError, ValueError):
if not is_ui_transport:
raise
ui_batch_rejected = True
ui_messages.clear()
normalized_messages = [
normalized_message
for normalized_message in normalized_messages
if normalized_message.type != ToolInvokeMessage.MessageType.UI
]
logger.warning("Ignored invalid reserved tool UI message", exc_info=True)
return normalized_messages, ui_messages
@staticmethod
def _is_ui_transport_message(message: ToolInvokeMessage) -> bool:
if message.type == ToolInvokeMessage.MessageType.UI:
return True
if message.type == ToolInvokeMessage.MessageType.VARIABLE:
variable_message = cast(ToolInvokeMessage.VariableMessage, message.message)
return variable_message.variable_name == DIFY_UI_JSON_ENVELOPE_KEY
if message.type == ToolInvokeMessage.MessageType.JSON:
json_message = cast(ToolInvokeMessage.JsonMessage, message.message)
value = json_message.json_object
return isinstance(value, dict) and set(value) == {DIFY_UI_JSON_ENVELOPE_KEY}
return False
@staticmethod
def _extract_tool_response_binary_and_text(
tool_response: list[ToolInvokeMessage],
+7
View File
@@ -90,6 +90,7 @@ from .system_variables import SystemVariableKey, get_system_text
if TYPE_CHECKING:
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolInvokeMessage as CoreToolInvokeMessage
from core.tools.entities.ui_entities import ToolUIMessage
from graphon.nodes.llm.file_saver import LLMFileSaver
from graphon.nodes.tool.entities import ToolNodeData
@@ -666,6 +667,11 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
) -> Generator[ToolRuntimeMessage, None, None]:
try:
for message in messages:
# Tool Gen-UI is currently a chat presentation channel. Graphon
# does not define an equivalent runtime message, so workflow
# execution safely ignores it instead of coercing it to JSON.
if message.type.value == "ui":
continue
yield self._convert_message(message)
except Exception as exc:
raise self._map_invocation_exception(exc, provider_name=provider_name) from exc
@@ -686,6 +692,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
| CoreToolInvokeMessage.FileMessage
| CoreToolInvokeMessage.VariableMessage
| CoreToolInvokeMessage.RetrieverResourceMessage
| ToolUIMessage
| None,
) -> (
ToolRuntimeMessage.TextMessage
@@ -4,16 +4,8 @@ from dataclasses import dataclass
from sqlalchemy import select
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.db.session_factory import session_factory
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
class WorkflowAgentBindingError(Exception):
@@ -32,7 +24,7 @@ class WorkflowAgentBindingBundle:
class WorkflowAgentBindingResolver:
"""Resolve an owned binding without allowing unpublished roster snapshots to run."""
"""Resolve the Agent binding owned by the current workflow id and node id."""
def resolve(
self,
@@ -61,20 +53,18 @@ class WorkflowAgentBindingResolver:
if binding.agent_id is None:
raise WorkflowAgentBindingError("agent_not_available", "Workflow Agent binding has no agent.")
agent_stmt = select(Agent).where(
Agent.tenant_id == tenant_id,
Agent.id == binding.agent_id,
)
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
agent_stmt = agent_stmt.where(
Agent.scope == AgentScope.ROSTER,
workflow_callable_active_snapshot_filter(),
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.id == binding.agent_id,
)
agent = session.scalar(agent_stmt.limit(1))
.limit(1)
)
if agent is None or agent.status == AgentStatus.ARCHIVED:
raise WorkflowAgentBindingError(
"agent_not_available",
f"Agent {binding.agent_id} is not available or has not been published.",
f"Agent {binding.agent_id} is not available.",
)
snapshot_id = (
+10 -25
View File
@@ -6,17 +6,9 @@ from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentBindingType, WorkflowAgentNodeBinding
from models.agent_config_entities import (
AgentFileRefConfig,
AgentHumanContactConfig,
@@ -125,28 +117,21 @@ class WorkflowAgentNodeValidator:
binding: WorkflowAgentNodeBinding,
topology: _WorkflowGraphTopology | None = None,
) -> None:
"""Validate binding ownership, publication state, Agent Soul, and node-job references."""
if binding.agent_id is None:
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} is missing agent binding.")
agent_stmt = select(Agent).where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
agent = session.scalar(
select(Agent)
.where(
Agent.tenant_id == binding.tenant_id,
Agent.id == binding.agent_id,
)
.limit(1)
)
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT:
agent_stmt = agent_stmt.where(
Agent.scope == AgentScope.ROSTER,
workflow_callable_active_snapshot_filter(),
)
agent = session.scalar(agent_stmt.limit(1))
if agent is None or agent.status == AgentStatus.ARCHIVED:
availability = (
"an unavailable or unpublished roster agent"
if binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT
else "an unavailable agent"
raise WorkflowAgentNodeValidationError(
f"Workflow Agent node {binding.node_id} references an unavailable agent."
)
raise WorkflowAgentNodeValidationError(f"Workflow Agent node {binding.node_id} references {availability}.")
snapshot_id = (
agent.active_config_snapshot_id
+2 -2
View File
@@ -67,10 +67,10 @@ class FormInputType(enum.StrEnum):
class ValueSourceType(enum.StrEnum):
"""ValueSourceType records whether the value comes from a static setting
in form definition, or a variable while the workflow is running.
in form definiton, or a variable while the workflow is running.
"""
# `VARIABLE` means that the value comes from a variable in workflow execution
VARIABLE = enum.auto()
# `CONSTANT` means that the value comes from a static setting in form definition.
# `CONSTANT` measn that the value comes from a static setting in form definition.
CONSTANT = enum.auto()
-11
View File
@@ -1,11 +0,0 @@
from enum import StrEnum
class DeploymentEdition(StrEnum):
"""
Enum representing the deployment edition of the platform.
"""
COMMUNITY = "COMMUNITY"
ENTERPRISE = "ENTERPRISE"
CLOUD = "CLOUD"
-2
View File
@@ -1,4 +1,3 @@
from extensions.otel.context import propagate_context
from extensions.otel.decorators.base import trace_span
from extensions.otel.decorators.handler import SpanHandler
from extensions.otel.decorators.handlers.generate_handler import AppGenerateHandler
@@ -8,6 +7,5 @@ __all__ = [
"AppGenerateHandler",
"SpanHandler",
"WorkflowAppRunnerHandler",
"propagate_context",
"trace_span",
]
-21
View File
@@ -1,21 +0,0 @@
"""Utilities for propagating OpenTelemetry context across execution boundaries."""
import functools
from collections.abc import Callable
from opentelemetry import context as otel_context
def propagate_context[**P, R](func: Callable[P, R]) -> Callable[P, R]:
"""Capture the current context and attach it whenever ``func`` executes."""
captured_context = otel_context.get_current()
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
token = otel_context.attach(captured_context)
try:
return func(*args, **kwargs)
finally:
otel_context.detach(token)
return wrapper
+8
View File
@@ -7,6 +7,7 @@ from uuid import uuid4
from pydantic import Field, computed_field, field_validator
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.tools.entities.ui_entities import MessageUIPart, parse_history_ui_parts
from fields.base import ResponseModel
from fields.conversation_fields import AgentThought, JSONValue, MessageFile
from graphon.file import File
@@ -64,6 +65,7 @@ class MessageListItem(ResponseModel):
status: str
error: str | None = None
extra_contents: list[ExecutionExtraContentDomainModel]
ui_parts: list[MessageUIPart] = Field(default_factory=list, validation_alias="message_metadata_dict")
@computed_field
def total_tokens(self) -> int:
@@ -79,6 +81,12 @@ class MessageListItem(ResponseModel):
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
@field_validator("ui_parts", mode="before")
@classmethod
def _normalize_ui_parts(cls, value: object) -> list[MessageUIPart]:
raw_parts = value.get("ui_parts", []) if isinstance(value, dict) else value
return parse_history_ui_parts(raw_parts)
class WebMessageListItem(MessageListItem):
metadata: JSONValueType | None = Field(
+23 -39
View File
@@ -9475,11 +9475,15 @@ Used for frontend component type mapping
| 200 | Success | **application/json**: [SchemaDefinitionsResponse](#schemadefinitionsresponse)<br> |
### [GET] /system-features
**Get the non-sensitive bootstrap snapshot exposed before authentication**
**Get system-wide feature configuration**
Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
Authentication configuration must be available before the authentication flow can be selected.
Authenticated license detail is served separately by SystemFeatureLicenseApi.
Get system-wide feature configuration
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for dashboard initialization.
Authentication would create circular dependency (can't login without dashboard loading).
Only non-sensitive configuration data should be returned by this endpoint.
#### Responses
@@ -9487,19 +9491,6 @@ Authenticated license detail is served separately by SystemFeatureLicenseApi.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SystemFeatureModel](#systemfeaturemodel)<br> |
### [GET] /system-features/license
**Get full license detail (status, expiry, workspace/seat usage)**
Get license status and usage detail
Authenticated counterpart to the license *status* exposed on the public
system-features endpoint.
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [LicenseModel](#licensemodel)<br> |
### [POST] /tag-bindings
#### Request Body
@@ -17327,14 +17318,6 @@ Default model entity.
| tool_name | string | | Yes |
| type | string | | Yes |
#### DeploymentEdition
Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | Enum representing the deployment edition of the platform. | |
#### DismissNotificationPayload
| Name | Type | Description | Required |
@@ -18832,12 +18815,6 @@ Enum class for large language model mode.
| ---- | ---- | ----------- | -------- |
| LicenseStatus | string | | |
#### LicenseStatusModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | [LicenseStatus](#licensestatus) | | Yes |
#### LimitationModel
| Name | Type | Description | Required |
@@ -20516,6 +20493,12 @@ Shared permission levels for resources (datasets, credentials, etc.)
| plugins | [ [PluginEntity](#pluginentity) ] | | Yes |
| total | integer | | Yes |
#### PluginManagerModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### PluginManifestResponse
| Name | Type | Description | Required |
@@ -21028,7 +21011,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| can_trial | boolean | | Yes |
| can_trial | boolean | | No |
| export_data | string | | Yes |
| icon | string | | No |
| icon_background | string | | No |
@@ -21061,7 +21044,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| ---- | ---- | ----------- | -------- |
| app | [RecommendedAppInfoResponse](#recommendedappinforesponse) | | No |
| app_id | string | | Yes |
| can_trial | boolean | | Yes |
| can_trial | boolean | | No |
| categories | [ string ] | | No |
| copyright | string | | No |
| custom_disclaimer | string | | No |
@@ -22041,12 +22024,9 @@ Model class for provider system configuration response.
#### SystemFeatureModel
Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
@@ -22058,11 +22038,15 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| license | [LicenseModel](#licensemodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
| rbac_enabled | boolean | | Yes |
| sso_enforced_for_signin | boolean | | Yes |
| sso_enforced_for_signin_protocol | string | | Yes |
@@ -22992,11 +22976,11 @@ User action configuration.
#### ValueSourceType
ValueSourceType records whether the value comes from a static setting
in form definition, or a variable while the workflow is running.
in form definiton, or a variable while the workflow is running.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | |
#### VerificationTokenResponse
+2 -2
View File
@@ -4052,11 +4052,11 @@ User action configuration.
#### ValueSourceType
ValueSourceType records whether the value comes from a static setting
in form definition, or a variable while the workflow is running.
in form definiton, or a variable while the workflow is running.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | |
#### WeightKeywordSetting
+48 -23
View File
@@ -774,13 +774,24 @@ Retrieve app site information and configuration.
| 500 | Internal Server Error | |
### [GET] /system-features
**Get the non-sensitive bootstrap snapshot exposed before authentication**
**Get system feature flags and configuration**
Get system feature flags and configuration
Returns the current system feature flags and configuration
that control various functionalities across the platform.
Returns:
dict: System feature configuration object
Get the non-sensitive bootstrap snapshot exposed before Console or Web authentication. This is not a general feature registry.
This endpoint is akin to the `SystemFeatureApi` endpoint in api/controllers/console/feature.py,
except it is intended for use by the web app, instead of the console dashboard.
Authentication configuration must be available before the authentication flow can be selected.
NOTE: This endpoint is unauthenticated by design, as it provides system features
data required for webapp initialization.
Authentication would create circular dependency (can't authenticate without webapp loading).
Only non-sensitive configuration data should be returned by this endpoint.
#### Responses
@@ -1047,14 +1058,6 @@ Button styles for user actions.
| auto_generate | boolean | Automatically generate the conversation name. When `true`, the `name` field is ignored. | No |
| name | string | Conversation name. Required when `auto_generate` is `false`. | No |
#### DeploymentEdition
Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| DeploymentEdition | string | Enum representing the deployment edition of the platform. | |
#### EmailCodeLoginSendPayload
| Name | Type | Description | Required |
@@ -1281,18 +1284,33 @@ Parsed multipart form fields for HITL uploads.
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
#### LicenseLimitationModel
- enabled: whether this limit is enforced
- size: current usage count
- limit: maximum allowed count; 0 means unlimited
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | Whether this limit is currently active | Yes |
| limit | integer | Maximum number of resources allowed; 0 means no limit | Yes |
| size | integer | Number of resources already consumed | Yes |
#### LicenseModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| expired_at | string | | Yes |
| seats | [LicenseLimitationModel](#licenselimitationmodel) | | Yes |
| status | [LicenseStatus](#licensestatus) | | Yes |
| workspaces | [LicenseLimitationModel](#licenselimitationmodel) | | Yes |
#### LicenseStatus
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| LicenseStatus | string | | |
#### LicenseStatusModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| status | [LicenseStatus](#licensestatus) | | Yes |
#### LoginPayload
| Name | Type | Description | Required |
@@ -1401,6 +1419,12 @@ Form input definition.
| ---- | ---- | ----------- | -------- |
| PluginInstallationScope | string | | |
#### PluginManagerModel
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| enabled | boolean | | Yes |
#### RemoteFileInfo
| Name | Type | Description | Required |
@@ -1540,12 +1564,9 @@ Default configuration for form inputs.
#### SystemFeatureModel
Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| branding | [BrandingModel](#brandingmodel) | | Yes |
| deployment_edition | [DeploymentEdition](#deploymentedition) | | Yes |
| enable_app_deploy | boolean | | Yes |
| enable_change_email | boolean, <br>**Default:** true | | Yes |
| enable_collaboration_mode | boolean, <br>**Default:** true | | Yes |
@@ -1557,11 +1578,15 @@ Non-sensitive bootstrap snapshot exposed before Console or Web authentication.
| enable_marketplace | boolean | | Yes |
| enable_social_oauth_login | boolean | | Yes |
| enable_step_by_step_tour | boolean | | Yes |
| enable_trial_app | boolean | | Yes |
| is_allow_create_workspace | boolean | | Yes |
| is_allow_register | boolean | | Yes |
| is_email_setup | boolean | | Yes |
| knowledge_fs_enabled | boolean | | Yes |
| license | [LicenseStatusModel](#licensestatusmodel) | | Yes |
| license | [LicenseModel](#licensemodel) | | Yes |
| max_plugin_package_size | integer, <br>**Default:** 15728640 | | Yes |
| plugin_installation_permission | [PluginInstallationPermissionModel](#plugininstallationpermissionmodel) | | Yes |
| plugin_manager | [PluginManagerModel](#pluginmanagermodel) | | Yes |
| rbac_enabled | boolean | | Yes |
| sso_enforced_for_signin | boolean | | Yes |
| sso_enforced_for_signin_protocol | string | | Yes |
@@ -1599,11 +1624,11 @@ User action configuration.
#### ValueSourceType
ValueSourceType records whether the value comes from a static setting
in form definition, or a variable while the workflow is running.
in form definiton, or a variable while the workflow is running.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definition, or a variable while the workflow is running. | |
| ValueSourceType | string | ValueSourceType records whether the value comes from a static setting in form definiton, or a variable while the workflow is running. | |
#### VerificationTokenResponse
+1 -1
View File
@@ -29,7 +29,7 @@ In `pyproject.toml`:
pgvector = "dify_vdb_pgvector.pgvector:PGVectorFactory"
```
The value is **`module:attribute`**: an importable module path and the class implementing `AbstractVectorFactory`.
The value is **`module:attribute`**: a importable module path and the class implementing `AbstractVectorFactory`.
### How registration works
+21 -13
View File
@@ -442,9 +442,9 @@ class AccountService:
# A licensed seat is one Account row, deployment-wide; joining an existing
# account into another workspace does not pass through here and costs no seat.
# get_license() carries the full license payload that server-side enforcement needs;
# the public system-features endpoint exposes only license status.
if not FeatureService.get_license().seats.is_available():
# is_authenticated=True: server-side enforcement needs the full license payload,
# which the enterprise fill withholds from unauthenticated (browser-facing) calls.
if not FeatureService.get_system_features(is_authenticated=True).license.seats.is_available():
raise SeatsLimitExceededError("licensed seats limit exceeded")
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(email):
@@ -1229,12 +1229,12 @@ class AccountService:
if hour_limit_count >= 1:
redis_client.setex(freeze_key, 60 * 60, 1)
return True
else:
redis_client.setex(hour_limit_key, 60 * 10, hour_limit_count + 1) # first time limit 10 minutes
# First strike claims a 10-minute window atomically; a concurrent
# over-limit request that loses the claim is the second strike and
# freezes the IP for an hour.
if not redis_client.set(hour_limit_key, 1, ex=60 * 10, nx=True):
redis_client.setex(freeze_key, 60 * 60, 1)
# add hour limit count
redis_client.incr(hour_limit_key)
redis_client.expire(hour_limit_key, 60 * 60)
return True
@@ -1258,7 +1258,11 @@ class TenantService:
session: Session,
) -> Tenant:
"""Create tenant"""
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if (
not FeatureService.get_system_features().is_allow_create_workspace
and not is_setup
and not is_from_dashboard
):
from controllers.console.error import NotAllowedCreateWorkspace
raise NotAllowedCreateWorkspace()
@@ -1321,10 +1325,14 @@ class TenantService:
owner. It persists the legacy membership before creating the matching
RBAC role binding, then makes the workspace current for the account.
"""
if not FeatureService.is_workspace_creation_allowed() and not is_setup and not is_from_dashboard:
if (
not FeatureService.get_system_features().is_allow_create_workspace
and not is_setup
and not is_from_dashboard
):
raise WorkSpaceNotAllowedCreateError()
workspaces = FeatureService.get_license().workspaces
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
raise WorkspacesLimitExceededError()
@@ -2002,9 +2010,9 @@ class RegisterService:
AccountService.link_account_integrate(provider, open_id, account, session=session)
if (
FeatureService.is_workspace_creation_allowed()
FeatureService.get_system_features().is_allow_create_workspace
and create_workspace_required
and FeatureService.get_license().workspaces.is_available()
and FeatureService.get_system_features().license.workspaces.is_available()
):
try:
TenantService.create_owner_tenant(account, session=session)
+29 -6
View File
@@ -7,7 +7,6 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from sqlalchemy.sql.elements import ColumnElement
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from libs.helper import to_timestamp
from models import Account
from models.agent import (
@@ -272,8 +271,6 @@ class AgentComposerService:
source_snapshot_id: str | None = None,
idempotency_key: str | None = None,
) -> dict[str, Any]:
"""Copy a callable roster Agent snapshot into a workflow-owned inline Agent."""
workflow = cls._get_draft_workflow(session=session, tenant_id=tenant_id, app_id=app_id)
binding = cls._require_binding(
cls._get_workflow_binding(session=session, tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
@@ -299,8 +296,6 @@ class AgentComposerService:
source_agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=source_agent_id)
if source_agent.scope != AgentScope.ROSTER or source_agent.status != AgentStatus.ACTIVE:
raise InvalidComposerConfigError("Source agent must be an active roster agent.")
if not agent_has_workflow_callable_active_snapshot(session=session, agent=source_agent):
raise InvalidComposerConfigError("Source agent must have a published config snapshot.")
source_version = cls._require_version(
session=session,
tenant_id=tenant_id,
@@ -534,11 +529,39 @@ class AgentComposerService:
)
if not active_version:
return False
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
if agent.source in APP_BACKED_AGENT_SOURCES and not cls._has_publish_visible_revision(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
snapshot_id=agent.active_config_snapshot_id,
):
return False
return _agent_soul_config_json(agent_soul) == _agent_soul_config_json(active_version.config_snapshot_dict)
@classmethod
def _has_publish_visible_revision(
cls, *, session: Session, tenant_id: str, agent_id: str, snapshot_id: str
) -> bool:
revisions = session.scalars(
select(AgentConfigRevision.operation).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
AgentConfigRevision.current_snapshot_id == snapshot_id,
)
).all()
return any(
operation
in {
AgentConfigRevisionOperation.PUBLISH_DRAFT,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
for operation in revisions
)
@classmethod
def publish_agent_app_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str, version_note: str | None = None
+1 -5
View File
@@ -6,7 +6,6 @@ from sqlalchemy.exc import IntegrityError
from clients.agent_backend.session_cleanup import AgentBackendSessionCleanupPayload
from constants.model_template import default_app_templates
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore
from core.app.entities.app_invoke_entities import InvokeFrom
from libs.datetime_utils import naive_utc_now
@@ -226,11 +225,8 @@ class AgentRosterService:
def list_invite_options(
self, *, tenant_id: str, page: int = 1, limit: int = 20, keyword: str | None = None, app_id: str | None = None
) -> dict[str, Any]:
"""List active roster Agents whose published snapshot can be called by Workflow."""
stmt = self._build_roster_agents_stmt(tenant_id=tenant_id, keyword=keyword).where(
Agent.active_config_has_model.is_(True),
workflow_callable_active_snapshot_filter(),
Agent.active_config_has_model.is_(True)
)
total = self._session.scalar(select(func.count()).select_from(stmt.subquery())) or 0
agents = list(self._session.scalars(stmt.offset((page - 1) * limit).limit(limit)).all())
@@ -8,7 +8,6 @@ from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError, WorkflowAgentNodeValidator
from models.agent import (
Agent,
@@ -448,8 +447,6 @@ class WorkflowAgentPublishService:
node_id: str,
agent_id: str,
) -> tuple[Agent, str]:
"""Resolve an active roster Agent whose published snapshot is callable."""
agent = session.scalar(
select(Agent)
.where(
@@ -457,12 +454,11 @@ class WorkflowAgentPublishService:
Agent.id == agent_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
workflow_callable_active_snapshot_filter(),
)
.limit(1)
)
if agent is None:
raise ValueError(f"Workflow Agent node {node_id} references an unavailable or unpublished roster agent.")
raise ValueError(f"Workflow Agent node {node_id} references an unavailable roster agent.")
if agent.scope != AgentScope.ROSTER:
raise ValueError(f"Workflow Agent node {node_id} roster_agent binding must reference a roster agent.")
if not agent.active_config_snapshot_id:
+3 -15
View File
@@ -9,7 +9,6 @@ import contexts
from core.app.app_config.easy_ui_based_app.agent.manager import AgentConfigManager
from core.plugin.impl.agent import PluginAgentClient
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.tools.entities.tool_entities import EmojiIconDict
from core.tools.tool_manager import ToolManager
from libs.login import current_user
from models import Account
@@ -106,22 +105,11 @@ class AgentService:
tool_output = tool_outputs.get(tool_name, {})
tool_meta_data = tool_meta.get(tool_name, {})
tool_config = tool_meta_data.get("tool_config", {})
tool_provider_type = tool_config.get("tool_provider_type", "")
tool_provider_id = tool_config.get("tool_provider", "")
if not tool_provider_type:
tool_entity = find_agent_tool(tool_name)
if tool_entity:
tool_provider_type = tool_entity.provider_type
tool_provider_id = tool_provider_id or tool_entity.provider_id
tool_icon: str | EmojiIconDict = ""
if tool_provider_type and tool_provider_type != "dataset-retrieval":
if tool_config.get("tool_provider_type", "") != "dataset-retrieval":
tool_icon = ToolManager.get_tool_icon(
tenant_id=app_model.tenant_id,
provider_type=tool_provider_type,
provider_id=tool_provider_id,
provider_type=tool_config.get("tool_provider_type", ""),
provider_id=tool_config.get("tool_provider", ""),
)
if not tool_icon:
tool_entity = find_agent_tool(tool_name)
+1
View File
@@ -92,6 +92,7 @@ class AgentToolInnerService:
conversation_id=request.caller.conversation_id,
)
)
transformed_messages, _ = ToolEngine.collect_agent_messages(transformed_messages)
except ToolProviderNotFoundError as exc:
raise AgentToolInnerServiceError(
error_code="agent_tool_declaration_not_found",
+37 -67
View File
@@ -5,7 +5,6 @@ from pydantic import BaseModel, ConfigDict, Field
from configs import dify_config
from constants.dsl_version import CURRENT_APP_DSL_VERSION
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from enums.hosted_provider import HostedTrialProvider
from services.billing_service import BillingInfo, BillingService
from services.enterprise.enterprise_service import EnterpriseService
@@ -76,11 +75,8 @@ class LicenseStatus(StrEnum):
LOST = "lost"
class LicenseStatusModel(FeatureResponseModel):
class LicenseModel(FeatureResponseModel):
status: LicenseStatus = LicenseStatus.NONE
class LicenseModel(LicenseStatusModel):
expired_at: str = ""
workspaces: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
seats: LicenseLimitationModel = LicenseLimitationModel(enabled=False, size=0, limit=0)
@@ -161,26 +157,31 @@ class KnowledgeRateLimitModel(FeatureResponseModel):
subscription_plan: str = ""
class SystemFeatureModel(FeatureResponseModel):
"""Non-sensitive bootstrap snapshot exposed before Console or Web authentication."""
class PluginManagerModel(FeatureResponseModel):
enabled: bool = False
deployment_edition: DeploymentEdition
class SystemFeatureModel(FeatureResponseModel):
enable_app_deploy: bool = False
sso_enforced_for_signin: bool = False
sso_enforced_for_signin_protocol: str = ""
enable_marketplace: bool = False
max_plugin_package_size: int = dify_config.PLUGIN_MAX_PACKAGE_SIZE
enable_email_code_login: bool = False
enable_email_password_login: bool = True
enable_social_oauth_login: bool = False
enable_collaboration_mode: bool = True
is_allow_register: bool = False
is_allow_create_workspace: bool = False
is_email_setup: bool = False
license: LicenseStatusModel = LicenseStatusModel()
license: LicenseModel = LicenseModel()
branding: BrandingModel = BrandingModel()
webapp_auth: WebAppAuthModel = WebAppAuthModel()
plugin_installation_permission: PluginInstallationPermissionModel = PluginInstallationPermissionModel()
enable_change_email: bool = True
plugin_manager: PluginManagerModel = PluginManagerModel()
enable_creators_platform: bool = False
enable_trial_app: bool = False
enable_explore_banner: bool = False
enable_learn_app: bool = True
enable_step_by_step_tour: bool = False
@@ -250,8 +251,8 @@ class FeatureService:
)
@classmethod
def get_system_features(cls) -> SystemFeatureModel:
system_features = SystemFeatureModel(deployment_edition=dify_config.DEPLOYMENT_EDITION)
def get_system_features(cls, is_authenticated: bool = False) -> SystemFeatureModel:
system_features = SystemFeatureModel()
system_features.rbac_enabled = dify_config.RBAC_ENABLED
cls._fulfill_system_params_from_env(system_features)
@@ -260,7 +261,8 @@ class FeatureService:
system_features.branding.enabled = True
system_features.webapp_auth.enabled = True
system_features.enable_change_email = False
cls._fulfill_params_from_enterprise(system_features)
system_features.plugin_manager.enabled = True
cls._fulfill_params_from_enterprise(system_features, is_authenticated)
if dify_config.MARKETPLACE_ENABLED:
system_features.enable_marketplace = True
@@ -270,32 +272,6 @@ class FeatureService:
return system_features
@classmethod
def is_workspace_creation_allowed(cls) -> bool:
"""Resolve the backend workspace-creation policy, including the Enterprise override."""
is_allowed = dify_config.ALLOW_CREATE_WORKSPACE
if not dify_config.ENTERPRISE_ENABLED:
return is_allowed
enterprise_info = EnterpriseService.get_info()
return bool(enterprise_info.get("IsAllowCreateWorkspace", is_allowed))
@classmethod
def is_plugin_manager_enabled(cls) -> bool:
"""Return whether Enterprise plugin credential policies must be enforced."""
return dify_config.ENTERPRISE_ENABLED
@classmethod
def get_license(cls) -> LicenseModel:
"""Return full license detail. Enterprise-only; requires an authenticated caller.
Non-enterprise deployments have no license, so an unconstrained default
(unlimited seats/workspaces) is returned.
"""
if not dify_config.ENTERPRISE_ENABLED:
return LicenseModel()
return cls._build_license(EnterpriseService.get_info())
@classmethod
def get_app_dsl_version(cls) -> str:
return CURRENT_APP_DSL_VERSION
@@ -307,8 +283,9 @@ class FeatureService:
system_features.enable_social_oauth_login = dify_config.ENABLE_SOCIAL_OAUTH_LOGIN
system_features.enable_collaboration_mode = dify_config.ENABLE_COLLABORATION_MODE
system_features.is_allow_register = dify_config.ALLOW_REGISTER
system_features.is_allow_create_workspace = dify_config.ALLOW_CREATE_WORKSPACE
system_features.is_email_setup = dify_config.MAIL_TYPE is not None and dify_config.MAIL_TYPE != ""
system_features.enable_change_email = dify_config.ENABLE_CHANGE_EMAIL
system_features.enable_trial_app = dify_config.ENABLE_TRIAL_APP
system_features.enable_explore_banner = dify_config.ENABLE_EXPLORE_BANNER
system_features.enable_learn_app = dify_config.ENABLE_LEARN_APP
system_features.webapp_auth.allow_public_access = dify_config.WEBAPP_PUBLIC_ACCESS_ENABLED
@@ -433,27 +410,7 @@ class FeatureService:
vector_space.limit = billing_info["vector_space"]["limit"]
@classmethod
def _build_license(cls, enterprise_info: dict) -> LicenseModel:
license_model = LicenseModel()
if license_info := enterprise_info.get("License"):
license_model.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
license_model.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
license_model.workspaces = LicenseLimitationModel(
enabled=workspaces_info.get("enabled", False),
limit=workspaces_info.get("limit", 0),
size=workspaces_info.get("used", 0),
)
if seats_info := license_info.get("licensedSeats"):
license_model.seats = LicenseLimitationModel(
enabled=seats_info.get("enabled", False),
limit=seats_info.get("limit", 0),
size=seats_info.get("used", 0),
)
return license_model
@classmethod
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel):
def _fulfill_params_from_enterprise(cls, features: SystemFeatureModel, is_authenticated: bool = False):
enterprise_info = EnterpriseService.get_info()
if "SSOEnforcedForSignin" in enterprise_info:
@@ -471,6 +428,9 @@ class FeatureService:
if "IsAllowRegister" in enterprise_info:
features.is_allow_register = enterprise_info["IsAllowRegister"]
if "IsAllowCreateWorkspace" in enterprise_info:
features.is_allow_create_workspace = enterprise_info["IsAllowCreateWorkspace"]
if "EnableAppDeploy" in enterprise_info:
features.enable_app_deploy = enterprise_info["EnableAppDeploy"]
@@ -490,14 +450,24 @@ class FeatureService:
)
features.webapp_auth.sso_config.protocol = enterprise_info.get("SSOEnforcedForWebProtocol", "")
# SECURITY NOTE: system-features is unauthenticated, so it exposes only license
# *status* — enough for the login page to detect an expired/inactive license after
# force-logout. Full license detail (expiry, workspace/seat usage) is served
# separately by get_license() behind an authenticated endpoint.
# SECURITY NOTE: Only license *status* is exposed to unauthenticated callers
# so the login page can detect an expired/inactive license after force-logout.
# All other license details (expiry date, workspace usage) remain auth-gated.
# This behavior reflects prior internal review of information-leakage risks.
if license_info := enterprise_info.get("License"):
features.license = LicenseStatusModel(
status=LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
)
features.license.status = LicenseStatus(license_info.get("status", LicenseStatus.INACTIVE))
if is_authenticated:
features.license.expired_at = license_info.get("expiredAt", "")
if workspaces_info := license_info.get("workspaces"):
features.license.workspaces.enabled = workspaces_info.get("enabled", False)
features.license.workspaces.limit = workspaces_info.get("limit", 0)
features.license.workspaces.size = workspaces_info.get("used", 0)
if seats_info := license_info.get("licensedSeats"):
features.license.seats.enabled = seats_info.get("enabled", False)
features.license.seats.limit = seats_info.get("limit", 0)
features.license.seats.size = seats_info.get("used", 0)
if "PluginInstallationPermission" in enterprise_info:
plugin_installation_info = enterprise_info["PluginInstallationPermission"]
+7 -5
View File
@@ -99,7 +99,7 @@ def _external_source_operation(
*,
request_headers: tuple[str, ...] = ("x-trace-id",),
) -> KnowledgeFSOperation:
"""Declare an external-source operation restricted to dataset editors."""
"""Declare a source-connection operation restricted to dataset editors."""
return _console_operation(
operation_id,
method,
@@ -347,10 +347,12 @@ KNOWLEDGE_FS_CONSOLE_OPERATIONS: Final[tuple[KnowledgeFSOperation, ...]] = (
rbac_permission=RBACPermission.DATASET_READONLY,
legacy_role="reader",
),
_external_source_operation(
"putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy",
"PUT",
"knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
_console_operation(
operation_id="putKnowledgeSpacesByIdSourcesBySourceIdSyncPolicy",
method="PUT",
path="knowledge-spaces/{id}/sources/{sourceId}/sync-policy",
rbac_permission=RBACPermission.DATASET_EDIT,
legacy_role="dataset_editor",
),
_dataset_read_operation("getKnowledgeSpacesByIdDocuments", "knowledge-spaces/{id}/documents"),
_dataset_edit_operation("postKnowledgeSpacesByIdDocuments", "POST", "knowledge-spaces/{id}/documents"),
+13 -28
View File
@@ -4,19 +4,12 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from models.model import AccountTrialAppRecord, App, TrialApp
from services.feature_service import FeatureService
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
class RecommendedAppService:
"""Own recommended app retrieval and Cloud-only trial eligibility."""
@staticmethod
def is_trial_app_enabled() -> bool:
"""Return whether trial execution is enabled for this deployment."""
return dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD and dify_config.ENABLE_TRIAL_APP
@classmethod
def get_app(cls, app_id: str, *, session: Session) -> App | None:
"""Return a normal app only when it belongs to the recommended catalog."""
@@ -45,12 +38,11 @@ class RecommendedAppService:
)
)
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
if FeatureService.get_system_features().enable_trial_app:
apps = result["recommended_apps"]
for app in apps:
app_id = app["app_id"]
app["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
@@ -64,14 +56,11 @@ class RecommendedAppService:
retrieval_instance = RecommendAppRetrievalFactory.get_recommend_app_factory(mode)()
result = retrieval_instance.get_learn_dify_apps(language, session=session)
apps = result["recommended_apps"]
trial_app_ids = (
cls._get_trial_app_ids(session, [app["app_id"] for app in apps]) if cls.is_trial_app_enabled() else set()
)
for app in apps:
app["can_trial"] = app["app_id"] in trial_app_ids
if FeatureService.get_system_features().enable_trial_app:
for app in result["recommended_apps"]:
app["can_trial"] = cls._can_trial_app(session, app["app_id"])
return {"recommended_apps": apps}
return {"recommended_apps": result["recommended_apps"]}
@classmethod
def get_recommend_app_detail(cls, app_id: str, *, session: Session) -> dict[str, Any] | None:
@@ -85,7 +74,9 @@ class RecommendedAppService:
result: dict[str, Any] | None = retrieval_instance.get_recommend_app_detail(app_id, session=session)
if result is None:
return None
result["can_trial"] = cls.is_trial_app_enabled() and cls._can_trial_app(session, result["id"])
if FeatureService.get_system_features().enable_trial_app:
app_id = result["id"]
result["can_trial"] = cls._can_trial_app(session, app_id)
return result
@classmethod
@@ -111,9 +102,3 @@ class RecommendedAppService:
def _can_trial_app(session: Session, app_id: str) -> bool:
trial_app_model = session.scalar(select(TrialApp).where(TrialApp.app_id == app_id).limit(1))
return trial_app_model is not None
@staticmethod
def _get_trial_app_ids(session: Session, app_ids: list[str]) -> set[str]:
if not app_ids:
return set()
return set(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
+1 -1
View File
@@ -542,7 +542,7 @@ class WorkflowService:
# Validate credentials before publishing, for credential policy check
from services.feature_service import FeatureService
if FeatureService.is_plugin_manager_enabled():
if FeatureService.get_system_features().plugin_manager.enabled:
self._validate_workflow_credentials(draft_workflow, session=session)
# validate graph structure
+1 -2
View File
@@ -4,7 +4,6 @@ from sqlalchemy.orm import Session
from configs import dify_config
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from models.account import Tenant, TenantAccountJoin, TenantAccountRole
from services.account_service import TenantService
from services.feature_service import FeatureService
@@ -61,7 +60,7 @@ class WorkspaceService:
"remove_webapp_brand": remove_webapp_brand,
"replace_webapp_logo": replace_webapp_logo,
}
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION == "CLOUD":
tenant_info["next_credit_reset_date"] = feature.next_credit_reset_date
from services.credit_pool_service import CreditPoolBalance, CreditPoolService
+1 -2
View File
@@ -1,11 +1,10 @@
from enum import StrEnum
from configs import dify_config
from enums.deployment_edition import DeploymentEdition
from services.workflow.entities import WorkflowScheduleCFSPlanEntity
# Determine queue names based on edition
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
if dify_config.EDITION == "CLOUD":
# Cloud edition: separate queues for different tiers
_professional_queue = "workflow_professional"
_team_queue = "workflow_team"
@@ -61,7 +61,7 @@ def add_tenant_for_account(
) -> Tenant:
"""Create an additional tenant and join ``account`` to it (real service calls)."""
with patch("services.account_service.FeatureService") as mock_feature_service:
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
tenant = TenantService.create_tenant(name=name, session=session)
TenantService.create_tenant_member(tenant, account, session, role=role)
return tenant
@@ -37,9 +37,8 @@ class TestAccountService:
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
mock_passport_service.return_value.issue.return_value = "mock_jwt_token"
@@ -401,10 +400,12 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
account = AccountService.create_account_and_tenant(
email=email,
@@ -434,7 +435,9 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(WorkSpaceNotAllowedCreateError):
@@ -458,10 +461,12 @@ class TestAccountService:
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = False
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(WorkspacesLimitExceededError):
@@ -487,7 +492,7 @@ class TestAccountService:
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.seats.is_available.return_value = False
].get_system_features.return_value.license.seats.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
with pytest.raises(SeatsLimitExceededError):
@@ -1268,9 +1273,8 @@ class TestTenantService:
patch("services.account_service.BillingService") as mock_billing_service,
):
# Setup default mock returns
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
yield {
@@ -1285,7 +1289,9 @@ class TestTenantService:
fake = Faker()
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1304,7 +1310,9 @@ class TestTenantService:
fake = Faker()
tenant_name = fake.company()
# Setup mocks to disable workspace creation
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
with pytest.raises(NotAllowedCreateWorkspace): # NotAllowedCreateWorkspace exception
TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1318,7 +1326,9 @@ class TestTenantService:
fake = Faker()
custom_tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
# Create tenant with setup flag (should bypass workspace creation restriction)
tenant = TenantService.create_tenant(
@@ -1342,7 +1352,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1376,7 +1388,9 @@ class TestTenantService:
name2 = fake.name()
password2 = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1414,7 +1428,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1447,7 +1463,9 @@ class TestTenantService:
tenant1_name = fake.company()
tenant2_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account and tenants
account = AccountService.create_account(
@@ -1484,7 +1502,9 @@ class TestTenantService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account and tenant
account = AccountService.create_account(
@@ -1520,7 +1540,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account without setting current tenant
account = AccountService.create_account(
@@ -1546,7 +1568,9 @@ class TestTenantService:
tenant1_name = fake.company()
tenant2_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account and tenants
account = AccountService.create_account(
@@ -1584,7 +1608,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account
account = AccountService.create_account(
@@ -1611,7 +1637,9 @@ class TestTenantService:
password = generate_valid_password(fake)
tenant_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create account and tenant
account = AccountService.create_account(
@@ -1640,7 +1668,9 @@ class TestTenantService:
admin_name = fake.name()
admin_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1685,7 +1715,9 @@ class TestTenantService:
tenant_name = fake.company()
invalid_role = fake.word()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1704,7 +1736,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1739,7 +1773,9 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1780,7 +1816,9 @@ class TestTenantService:
password = generate_valid_password(fake)
invalid_action = "invalid_action_that_doesnt_exist"
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1813,7 +1851,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1849,7 +1889,9 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1937,7 +1979,9 @@ class TestTenantService:
name = fake.name()
password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and account
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -1971,7 +2015,9 @@ class TestTenantService:
non_member_name = fake.name()
non_member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2012,7 +2058,9 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2063,7 +2111,9 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2122,7 +2172,9 @@ class TestTenantService:
member_name = fake.name()
member_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2160,7 +2212,9 @@ class TestTenantService:
tenant2_name = fake.company()
tenant3_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create multiple tenants
tenant1 = TenantService.create_tenant(name=tenant1_name, session=db_session_with_containers)
@@ -2185,10 +2239,12 @@ class TestTenantService:
password = generate_valid_password(fake)
workspace_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
# Create account
account = AccountService.create_account(
@@ -2224,10 +2280,12 @@ class TestTenantService:
existing_tenant_name = fake.company()
new_workspace_name = fake.company()
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
# Create account and existing tenant
account = AccountService.create_account(
@@ -2265,7 +2323,9 @@ class TestTenantService:
password = generate_valid_password(fake)
workspace_name = fake.company()
# Setup mocks to disable workspace creation
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
# Create account
account = AccountService.create_account(
@@ -2298,7 +2358,9 @@ class TestTenantService:
normal_name = fake.name()
normal_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2365,7 +2427,9 @@ class TestTenantService:
normal_name = fake.name()
normal_password = generate_valid_password(fake)
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant and accounts
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2414,7 +2478,9 @@ class TestTenantService:
theme = fake.random_element(elements=("dark", "light"))
language = fake.random_element(elements=("zh-CN", "en-US"))
# Setup mocks
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = True
# Create tenant with custom config
tenant = TenantService.create_tenant(name=tenant_name, session=db_session_with_containers)
@@ -2447,9 +2513,8 @@ class TestRegisterService:
):
# Setup default mock returns
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_license.return_value.workspaces.is_available.return_value = True
mock_feature_service.get_license.return_value.seats.is_available.return_value = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
mock_feature_service.get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_billing_service.is_email_in_freeze.return_value = False
mock_passport_service.return_value.issue.return_value = "mock_jwt_token"
@@ -2561,10 +2626,12 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration
@@ -2603,10 +2670,12 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration with OAuth
@@ -2650,10 +2719,12 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Execute registration with pending status
@@ -2694,7 +2765,9 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = False
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.is_allow_create_workspace = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# with pytest.raises(AccountRegisterError, match="Workspace is not allowed to create."):
@@ -2731,10 +2804,12 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = False
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = False
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# with pytest.raises(AccountRegisterError, match="Workspace is not allowed to create."):
@@ -2808,10 +2883,12 @@ class TestRegisterService:
language = fake.random_element(elements=("en-US", "zh-CN"))
# Setup mocks
mock_external_service_dependencies["feature_service"].get_system_features.return_value.is_allow_register = True
mock_external_service_dependencies["feature_service"].is_workspace_creation_allowed.return_value = True
mock_external_service_dependencies[
"feature_service"
].get_license.return_value.workspaces.is_available.return_value = True
].get_system_features.return_value.is_allow_create_workspace = True
mock_external_service_dependencies[
"feature_service"
].get_system_features.return_value.license.workspaces.is_available.return_value = True
mock_external_service_dependencies["billing_service"].is_email_in_freeze.return_value = False
# Create tenant and inviter account
@@ -5,12 +5,10 @@ from faker import Faker
from sqlalchemy.orm import Session
from enums.cloud_plan import CloudPlan
from enums.deployment_edition import DeploymentEdition
from services.feature_service import (
FeatureModel,
FeatureService,
KnowledgeRateLimitModel,
LicenseModel,
LicenseStatus,
SystemFeatureModel,
)
@@ -275,7 +273,6 @@ class TestFeatureService:
tenant_id = self._create_test_tenant_id()
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@@ -285,9 +282,10 @@ class TestFeatureService:
mock_config.ALLOW_REGISTER = False
mock_config.ALLOW_CREATE_WORKSPACE = False
mock_config.MAIL_TYPE = "smtp"
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the method under test
result = FeatureService.get_system_features()
result = FeatureService.get_system_features(is_authenticated=True)
# Assert: Verify the expected outcomes
assert result is not None
@@ -308,6 +306,7 @@ class TestFeatureService:
assert result.enable_email_password_login is False
assert result.enable_collaboration_mode is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify branding configuration
assert result.branding.application_title == "Test Enterprise"
@@ -321,9 +320,12 @@ class TestFeatureService:
assert result.webapp_auth.allow_email_password_login is False
assert result.webapp_auth.sso_config.protocol == "oidc"
# Verify license status (public system-features exposes status only; detail lives on get_license)
# Verify license configuration
assert result.license.status.value == "active"
assert not hasattr(result.license, "expired_at")
assert result.license.expired_at == "2025-12-31"
assert result.license.workspaces.enabled is True
assert result.license.workspaces.limit == 5
assert result.license.workspaces.size == 2
# Verify plugin installation permission
assert result.plugin_installation_permission.plugin_installation_scope == "official_only"
@@ -348,7 +350,6 @@ class TestFeatureService:
"""
# Arrange: Setup test data with exact same config as success test
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
@@ -359,18 +360,20 @@ class TestFeatureService:
mock_config.MAIL_TYPE = "smtp"
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
# Act: Execute the public (unauthenticated) system-features call
result = FeatureService.get_system_features()
# Act: Execute with is_authenticated=False
result = FeatureService.get_system_features(is_authenticated=False)
# Assert: Basic structure
assert result is not None
assert isinstance(result, SystemFeatureModel)
# --- 1. Verify only license *status* is exposed to unauthenticated clients ---
# Detailed license info (expiry, workspaces) is not part of the public model.
# Detailed license info (expiry, workspaces) remains auth-gated.
assert result.license.status == LicenseStatus.ACTIVE
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.limit == 0
assert result.license.workspaces.size == 0
# --- 2. Verify Public UI Configuration Availability ---
# Ensure that data required for frontend rendering remains accessible.
@@ -391,47 +394,6 @@ class TestFeatureService:
# Marketplace should be visible
assert result.enable_marketplace is True
def test_get_license_returns_full_detail(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test the authenticated license accessor.
This test verifies that:
- get_license() returns the full license payload (status, expiry, workspace usage).
- Detail withheld from the public system-features model is present here.
"""
# Arrange
with patch("services.feature_service.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = True
# Act
result = FeatureService.get_license()
# Assert: full license detail is populated
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.ACTIVE
assert result.expired_at == "2025-12-31"
assert result.workspaces.enabled is True
assert result.workspaces.limit == 5
assert result.workspaces.size == 2
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
def test_get_license_non_enterprise_is_unconstrained(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""Non-enterprise deployments have no license, so limits are unconstrained."""
with patch("services.feature_service.dify_config") as mock_config:
mock_config.ENTERPRISE_ENABLED = False
result = FeatureService.get_license()
assert isinstance(result, LicenseModel)
assert result.status == LicenseStatus.NONE
assert result.workspaces.is_available() is True
assert result.seats.is_available() is True
mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called()
def test_get_system_features_basic_config(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -446,14 +408,12 @@ class TestFeatureService:
"""
# Arrange: Setup basic config mock (no enterprise)
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENTERPRISE_ENABLED = False
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = True
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = False
mock_config.ENABLE_COLLABORATION_MODE = False
mock_config.ENABLE_CHANGE_EMAIL = True
mock_config.ALLOW_REGISTER = True
mock_config.ALLOW_CREATE_WORKSPACE = True
mock_config.MAIL_TYPE = "smtp"
@@ -478,11 +438,15 @@ class TestFeatureService:
assert result.enable_social_oauth_login is False
assert result.enable_collaboration_mode is False
assert result.is_allow_register is True
assert result.is_allow_create_workspace is True
assert result.is_email_setup is True
# Verify marketplace configuration
assert result.enable_marketplace is False
# Verify plugin package size (uses default value from dify_config)
assert result.max_plugin_package_size == 15728640
def test_get_features_billing_disabled(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
@@ -647,13 +611,11 @@ class TestFeatureService:
"""
# Arrange: Setup enterprise disabled mock
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_config.ENTERPRISE_ENABLED = False
mock_config.MARKETPLACE_ENABLED = True
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
mock_config.ENABLE_EMAIL_PASSWORD_LOGIN = True
mock_config.ENABLE_SOCIAL_OAUTH_LOGIN = True
mock_config.ENABLE_CHANGE_EMAIL = True
mock_config.ALLOW_REGISTER = False
mock_config.ALLOW_CREATE_WORKSPACE = False
mock_config.MAIL_TYPE = None
@@ -677,15 +639,19 @@ class TestFeatureService:
assert result.enable_email_password_login is True
assert result.enable_social_oauth_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
assert result.is_email_setup is False
# Verify marketplace configuration
assert result.enable_marketplace is True
# Verify plugin package size (uses default value from dify_config)
assert result.max_plugin_package_size == 15728640
# Verify default license status
assert result.license.status == "none"
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
# Verify no enterprise service calls
mock_external_service_dependencies["enterprise_service"].get_info.assert_not_called()
@@ -874,7 +840,6 @@ class TestFeatureService:
"""
# Arrange: Setup edge case webapp auth mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -913,6 +878,7 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -994,7 +960,6 @@ class TestFeatureService:
# Test case 1: Official only scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1018,7 +983,6 @@ class TestFeatureService:
# Test case 2: All plugins scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1039,7 +1003,6 @@ class TestFeatureService:
# Test case 3: Specific partners scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1063,7 +1026,6 @@ class TestFeatureService:
# Test case 4: None scope
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1156,19 +1118,24 @@ class TestFeatureService:
}
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
# Act: Execute the method under test
result = FeatureService.get_system_features(is_authenticated=True)
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, LicenseModel)
assert isinstance(result, SystemFeatureModel)
# Verify license status and detail
assert result.status == "inactive"
assert result.expired_at == ""
assert result.workspaces.enabled is False
assert result.workspaces.size == 0
assert result.workspaces.limit == 0
# Verify license status
assert result.license.status == "inactive"
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
assert result.license.workspaces.size == 0
assert result.license.workspaces.limit == 0
# Verify enterprise features
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -1187,7 +1154,6 @@ class TestFeatureService:
"""
# Arrange: Setup partial enterprise info mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1234,8 +1200,8 @@ class TestFeatureService:
# Verify default license status
assert result.license.status == "none"
assert not hasattr(result.license, "expired_at")
assert not hasattr(result.license, "workspaces")
assert result.license.expired_at == ""
assert result.license.workspaces.enabled is False
# Verify default plugin installation permission
assert result.plugin_installation_permission.plugin_installation_scope == "all"
@@ -1317,7 +1283,6 @@ class TestFeatureService:
"""
# Arrange: Setup edge case protocols mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1528,19 +1493,24 @@ class TestFeatureService:
}
}
# Act: Execute the authenticated license accessor
result = FeatureService.get_license()
# Act: Execute the method under test
result = FeatureService.get_system_features(is_authenticated=True)
# Assert: Verify the expected outcomes
assert result is not None
assert isinstance(result, LicenseModel)
assert isinstance(result, SystemFeatureModel)
# Verify license status and detail
assert result.status == "expired"
assert result.expired_at == "2023-12-31"
assert result.workspaces.enabled is False
assert result.workspaces.size == 0
assert result.workspaces.limit == 0
# Verify license status
assert result.license.status == "expired"
assert result.license.expired_at == "2023-12-31"
assert result.license.workspaces.enabled is False
assert result.license.workspaces.size == 0
assert result.license.workspaces.limit == 0
# Verify enterprise features
assert result.branding.enabled is True
assert result.webapp_auth.enabled is True
assert result.enable_change_email is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -1617,7 +1587,6 @@ class TestFeatureService:
"""
# Arrange: Setup edge case branding mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1661,6 +1630,7 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.is_allow_create_workspace is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -1806,7 +1776,6 @@ class TestFeatureService:
"""
# Arrange: Setup lost license mock with proper config
with patch("services.feature_service.dify_config") as mock_config:
mock_config.DEPLOYMENT_EDITION = DeploymentEdition.ENTERPRISE
mock_config.ENTERPRISE_ENABLED = True
mock_config.MARKETPLACE_ENABLED = False
mock_config.ENABLE_EMAIL_CODE_LOGIN = False
@@ -1818,7 +1787,7 @@ class TestFeatureService:
mock_config.PLUGIN_MAX_PACKAGE_SIZE = 100
mock_external_service_dependencies["enterprise_service"].get_info.return_value = {
"License": {"status": "lost"}
"license": {"status": "lost", "expired_at": None, "plan": None}
}
# Act: Execute the method under test
@@ -1839,7 +1808,7 @@ class TestFeatureService:
assert result.enable_email_code_login is False
assert result.enable_email_password_login is True
assert result.is_allow_register is False
assert result.license.status == LicenseStatus.LOST
assert result.is_allow_create_workspace is False
# Verify mock interactions
mock_external_service_dependencies["enterprise_service"].get_info.assert_called_once()
@@ -41,7 +41,7 @@ class TestWebhookService:
# Mock feature service
mock_feature_service.get_system_features.return_value.is_allow_register = True
mock_feature_service.is_workspace_creation_allowed.return_value = True
mock_feature_service.get_system_features.return_value.is_allow_create_workspace = True
yield {
"async_service": mock_async_service,
@@ -6,7 +6,6 @@ import pytest
from faker import Faker
from sqlalchemy.orm import Session
from enums.deployment_edition import DeploymentEdition
from models import Account, Tenant, TenantAccountJoin, TenantAccountRole
from services.credit_pool_service import CreditPoolBalance
from services.workspace_service import WorkspaceService
@@ -612,7 +611,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.COMMUNITY
mock_external_service_dependencies["dify_config"].EDITION = "SELF_HOSTED"
mock_external_service_dependencies["feature_service"].get_features.return_value.can_replace_logo = False
mock_external_service_dependencies["tenant_service"].has_roles.return_value = False
@@ -633,7 +632,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -658,7 +657,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -686,7 +685,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -714,7 +713,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -750,7 +749,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -780,7 +779,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -809,7 +808,7 @@ class TestWorkspaceService:
db_session_with_containers, mock_external_service_dependencies
)
mock_external_service_dependencies["dify_config"].DEPLOYMENT_EDITION = DeploymentEdition.CLOUD
mock_external_service_dependencies["dify_config"].EDITION = "CLOUD"
feature = mock_external_service_dependencies["feature_service"].get_features.return_value
feature.can_replace_logo = False
feature.next_credit_reset_date = "2025-02-01"
@@ -112,8 +112,8 @@ def test_publish_blocks_start_and_trigger_coexistence(
monkeypatch.setattr(
feature_service_module.FeatureService,
"is_plugin_manager_enabled",
classmethod(lambda _cls: False),
"get_system_features",
classmethod(lambda _cls: SimpleNamespace(plugin_manager=SimpleNamespace(enabled=False))),
)
monkeypatch.setattr("services.workflow_service.dify_config", SimpleNamespace(BILLING_ENABLED=False))
@@ -110,27 +110,6 @@ def test_generate_specs_writes_unique_operation_ids(tmp_path):
assert len(operation_ids) == len(set(operation_ids))
def test_system_features_specs_exclude_backend_only_fields(tmp_path):
module = _load_generate_swagger_specs_module()
written_paths = module.generate_specs(tmp_path)
excluded_fields = {
"enable_trial_app",
"is_allow_create_workspace",
"max_plugin_package_size",
"plugin_manager",
}
for spec_name in ("console-openapi.json", "web-openapi.json"):
spec_path = next(path for path in written_paths if path.name == spec_name)
payload = json.loads(spec_path.read_text(encoding="utf-8"))
schemas = payload["components"]["schemas"]
system_features_schema = schemas["SystemFeatureModel"]
assert excluded_fields.isdisjoint(system_features_schema["properties"])
assert "PluginManagerModel" not in schemas
def test_generate_specs_writes_get_operations_without_request_bodies(tmp_path):
module = _load_generate_swagger_specs_module()
@@ -224,13 +203,7 @@ def test_generate_specs_include_console_contract_shapes_for_schema_migration(tmp
app_detail_schema = schemas["RecommendedAppDetailResponse"]
assert app_detail_schema["properties"]["id"]["type"] == "string"
assert app_detail_schema["properties"]["export_data"]["type"] == "string"
assert app_detail_schema["properties"]["can_trial"]["type"] == "boolean"
assert "anyOf" not in app_detail_schema["properties"]["can_trial"]
assert "can_trial" in app_detail_schema["required"]
app_list_item_schema = schemas["RecommendedAppResponse"]
assert app_list_item_schema["properties"]["can_trial"]["type"] == "boolean"
assert "anyOf" not in app_list_item_schema["properties"]["can_trial"]
assert "can_trial" in app_list_item_schema["required"]
assert {"type": "boolean"} in app_detail_schema["properties"]["can_trial"]["anyOf"]
app_detail_nullable_schema = schemas["RecommendedAppDetailNullableResponse"]
assert _response_schema(paths["/explore/apps/{app_id}"]["get"])["$ref"] == (
"#/components/schemas/RecommendedAppDetailNullableResponse"
@@ -13,7 +13,6 @@ from sqlalchemy import Engine, event
from sqlalchemy.orm import Session
from controllers.console.app import app_import as app_import_module
from enums.deployment_edition import DeploymentEdition
from models.account import Account
from models.base import TypeBase
from models.engine import db
@@ -48,10 +47,7 @@ class _Result:
def _install_features(monkeypatch: pytest.MonkeyPatch, enabled: bool) -> None:
features = SystemFeatureModel(
deployment_edition=DeploymentEdition.COMMUNITY,
webapp_auth=WebAppAuthModel(enabled=enabled),
)
features = SystemFeatureModel(webapp_auth=WebAppAuthModel(enabled=enabled))
monkeypatch.setattr(app_import_module.FeatureService, "get_system_features", lambda: features)
@@ -4,7 +4,6 @@ from contextlib import nullcontext
from inspect import getsource
from types import SimpleNamespace
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from sqlalchemy import Select
@@ -19,50 +18,53 @@ from controllers.console.app.error import AppNotFoundError
from models.model import App, AppMode, TrialApp
def _persist_app(sqlite_session: Session, *, mode: AppMode = AppMode.CHAT) -> App:
app_model = App(
tenant_id=str(uuid4()),
name="Test App",
mode=mode,
enable_site=True,
enable_api=True,
)
app_model.id = str(uuid4())
sqlite_session.add(app_model)
sqlite_session.commit()
return app_model
class FakeSession:
app_model: object | None
scalar_called: bool
def __init__(self, app_model: object | None = None) -> None:
self.app_model = app_model
self.scalar_called = False
def scalar(self, *_args: object, **_kwargs: object) -> object | None:
self.scalar_called = True
return self.app_model
def commit(self) -> None:
pass
def rollback(self) -> None:
pass
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
app_model = _persist_app(sqlite_session)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, app_model.tenant_id))
monkeypatch.setattr(wraps_module.db, "session", sqlite_session)
def test_get_app_model_injects_model(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model))
@wraps_module.get_app_model
def handler(app_model):
return app_model.id
assert handler(app_id=app_model.id) == app_model.id
assert handler(app_id="app-1") == "app-1"
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None:
app_model = _persist_app(sqlite_session)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, app_model.tenant_id))
monkeypatch.setattr(wraps_module.db, "session", sqlite_session)
def test_get_app_model_rejects_wrong_mode(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(wraps_module.db, "session", SimpleNamespace(scalar=lambda *_args, **_kwargs: app_model))
@wraps_module.get_app_model(mode=[AppMode.COMPLETION])
def handler(app_model):
return app_model.id
with pytest.raises(AppNotFoundError):
handler(app_id=app_model.id)
handler(app_id="app-1")
def test_get_app_model_with_trial_requires_trial_app_registration(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
session = MagicMock(spec=Session)
session = FakeSession()
def scalar(statement: Select[tuple[App]]) -> object | None:
has_trial_app_join = any(
@@ -134,30 +136,28 @@ def test_wraps_with_session_reexports_common_session_decorator() -> None:
assert wraps_module.with_session is with_session
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_get_app_model_prefers_injected_session(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
) -> None:
app_model = _persist_app(sqlite_session)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, app_model.tenant_id))
def test_get_app_model_prefers_injected_session(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal", tenant_id="t1")
session = FakeSession(app_model)
monkeypatch.setattr(wraps_module, "current_account_with_tenant", lambda: (None, "t1"))
monkeypatch.setattr(
wraps_module.db,
"session",
SimpleNamespace(scalar=lambda *_args, **_kwargs: pytest.fail("db.session should not be used")),
)
class Handler:
@wraps_module.get_app_model
def get(self, _injected_session, app_model):
return app_model.id
# An unbound real Session fails on query, so success proves the injected
# request Session was preferred over the legacy scoped-session fallback.
with Session() as scoped_session:
monkeypatch.setattr(wraps_module.db, "session", scoped_session)
assert Handler().get(sqlite_session, app_id=app_model.id) == app_model.id
assert Handler().get(session, app_id="app-1") == "app-1"
assert session.scalar_called
def test_get_app_model_with_trial_prefers_injected_session(monkeypatch: pytest.MonkeyPatch) -> None:
app_model = SimpleNamespace(id="app-1", mode=AppMode.CHAT.value, status="normal")
session = MagicMock(spec=Session)
session.scalar.return_value = app_model
session = FakeSession(app_model)
monkeypatch.setattr(
wraps_module.db,
"session",
@@ -173,7 +173,7 @@ def test_get_app_model_with_trial_prefers_injected_session(monkeypatch: pytest.M
return app_model.id
assert Handler().get(app_id="app-1") == "app-1"
session.scalar.assert_called_once()
assert session.scalar_called
def test_get_app_model_with_trial_requires_injected_session() -> None:

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