Compare commits

..

33 Commits

Author SHA1 Message Date
yyh
48904952ca Merge remote-tracking branch 'origin/main' into codex/input 2026-05-20 20:14:59 +08:00
yyh
9f9cb4d17e feat(ui): migrate radio to Base UI and update web callsites (#36451)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 12:05:31 +00:00
yyh
0fbaee46a5 chore: clarify legacy input import restriction 2026-05-20 18:24:12 +08:00
yyh
35bce2b3d7 chore: deprecate legacy web input imports 2026-05-20 18:22:15 +08:00
yyh
dc92130b29 fix: rely on field invalid state for input 2026-05-20 18:18:03 +08:00
yyh
3aa63d9665 fix: simplify input invalid state handling 2026-05-20 18:15:45 +08:00
yyh
166c869fe9 feat: add dify-ui input primitive 2026-05-20 18:10:51 +08:00
yyh
7d0d9019d8 chore: upgrade base ui to 1.5.0 (#36442) 2026-05-20 09:58:08 +00:00
d646bcf257 chore: remove unused pyrefly ignore comments in dataset.py (#36443)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 09:14:43 +00:00
e3b45a48eb fix: allow config pubsub join timeout for lower post-run latency (#36438)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-05-20 08:45:51 +00:00
848c15a265 chore: update to only SaaS can view template (#36440) 2026-05-20 08:18:26 +00:00
yyh
be8627233d ci: show web test shard failures (#36436) 2026-05-20 08:03:15 +00:00
1fe8b7fb1d fix(auth): use validity-returned token in ChangePasswordForm reset submit (#36415) 2026-05-20 07:59:09 +00:00
yyh
5a585c8618 refactor(web): use dropdown data attributes (#36431)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 07:32:11 +00:00
cc9b90a5ae chore(api): cap non-dev dependency major versions (#36429) 2026-05-20 07:25:50 +00:00
b64d4b53ca chore: move API readiness reporting to terminal output (#36433) 2026-05-20 07:23:35 +00:00
5cdf4e405b fix(web): debounce email check when change email (#36421)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: JzoNgKVO <27049666+JzoNgKVO@users.noreply.github.com>
2026-05-20 07:09:15 +00:00
7cb14cb4cc chore(codeowners): assign trigger scheduler ownership (#36430) 2026-05-20 06:48:34 +00:00
de38bba99b chore: example for [Refactor/Chore] add missing-override-decorator #36406 (#36425)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-05-20 06:16:52 +00:00
f04d809426 fix(api): fix invalid token error while changing email (#36412) 2026-05-20 05:51:15 +00:00
7ed3c7c500 chore: Check more files (#36407)
Co-authored-by: 99 <wh2099@pm.me>
2026-05-20 04:20:18 +00:00
yyh
77f1aeb1ac fix(web): resolve model provider console warnings (#36422) 2026-05-20 04:02:01 +00:00
7bc5c89e3c fix: prevent recursion error when SharePoint folder is empty (#36372) 2026-05-20 03:56:49 +00:00
718ab8433e chore: bump versions for litellm and langsmith (#36385) 2026-05-20 03:50:05 +00:00
8f197c5a0a build: fix api docker build (#36423) 2026-05-20 03:48:18 +00:00
0295862d0d chore(deps): bump the storage group across 1 directory with 4 updates (#36393)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-20 03:41:43 +00:00
2b2a5824c1 refactor: migrate to tailwind v4 style (#36417) 2026-05-20 03:39:44 +00:00
yyh
468cc19e68 fix(web): prevent local cloud analytics script errors (#36420) 2026-05-20 03:23:21 +00:00
77333e57a7 refactor: convert isinstance chains to match/case pattern (#36364) 2026-05-20 03:07:19 +00:00
f52491e2c1 chore: update deps (#36413) 2026-05-20 01:48:30 +00:00
05408af8a1 fix: fix add uv_cache_dir env (#36398) 2026-05-19 17:56:54 +00:00
yyh
d3ae074456 chore(web): remove generic tailwind skill (#36402) 2026-05-19 13:16:10 +00:00
0b48a7e991 fix: workflow node selection state not sync caused problem (#36390) 2026-05-19 12:29:16 +00:00
1096 changed files with 9435 additions and 8345 deletions

View File

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

View File

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

44
.github/CODEOWNERS vendored
View File

@ -92,28 +92,28 @@
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @WH-2099
# Backend - Trigger/Schedule/Webhook
/api/controllers/trigger/ @Mairuis
/api/controllers/console/app/workflow_trigger.py @Mairuis
/api/controllers/console/workspace/trigger_providers.py @Mairuis
/api/core/trigger/ @Mairuis
/api/core/app/layers/trigger_post_layer.py @Mairuis
/api/services/trigger/ @Mairuis
/api/models/trigger.py @Mairuis
/api/fields/workflow_trigger_fields.py @Mairuis
/api/repositories/workflow_trigger_log_repository.py @Mairuis
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @Mairuis
/api/libs/schedule_utils.py @Mairuis
/api/services/workflow/scheduler.py @Mairuis
/api/schedule/trigger_provider_refresh_task.py @Mairuis
/api/schedule/workflow_schedule_task.py @Mairuis
/api/tasks/trigger_processing_tasks.py @Mairuis
/api/tasks/trigger_subscription_refresh_tasks.py @Mairuis
/api/tasks/workflow_schedule_tasks.py @Mairuis
/api/tasks/workflow_cfs_scheduler/ @Mairuis
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @Mairuis
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @Mairuis
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @Mairuis
/api/events/event_handlers/sync_webhook_when_app_created.py @Mairuis
/api/controllers/trigger/ @CourTeous33
/api/controllers/console/app/workflow_trigger.py @CourTeous33
/api/controllers/console/workspace/trigger_providers.py @CourTeous33
/api/core/trigger/ @CourTeous33
/api/core/app/layers/trigger_post_layer.py @CourTeous33
/api/services/trigger/ @CourTeous33
/api/models/trigger.py @CourTeous33
/api/fields/workflow_trigger_fields.py @CourTeous33
/api/repositories/workflow_trigger_log_repository.py @CourTeous33
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33
/api/libs/schedule_utils.py @CourTeous33
/api/services/workflow/scheduler.py @CourTeous33
/api/schedule/trigger_provider_refresh_task.py @CourTeous33
/api/schedule/workflow_schedule_task.py @CourTeous33
/api/tasks/trigger_processing_tasks.py @CourTeous33
/api/tasks/trigger_subscription_refresh_tasks.py @CourTeous33
/api/tasks/workflow_schedule_tasks.py @CourTeous33
/api/tasks/workflow_cfs_scheduler/ @CourTeous33
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @CourTeous33
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @CourTeous33
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @CourTeous33
/api/events/event_handlers/sync_webhook_when_app_created.py @CourTeous33
# Backend - Async Workflow
/api/services/async_workflow_service.py @Mairuis

View File

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

View File

@ -195,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ./coverage.xml
disable_search: true

View File

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

View File

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

View File

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

View File

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

View File

@ -767,6 +767,7 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true

View File

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

View File

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

View File

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

View File

@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast
from urllib.parse import quote_plus, urlunparse
from pydantic import AliasChoices, Field
from pydantic.types import NonNegativeInt
from pydantic_settings import BaseSettings
@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings):
default=600,
)
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
description=(
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
"an SSE client and the response stream actually closing.\n\n"
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
"return promptly while the daemon listener thread cleans itself up on the next poll "
"boundary - safe because the listener holds no critical state and exits within one poll "
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
),
default=2000,
)
def _build_default_pubsub_url(self) -> str:
defaults = _redis_defaults(self)
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:

View File

@ -56,6 +56,12 @@ from models.enums import CreatorUserRole
from models.model import UploadFile
from services.account_service import AccountService
from services.billing_service import BillingService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
ChangeEmailOldEmailToken,
ChangeEmailOldEmailVerifiedToken,
)
from services.errors.account import CurrentPasswordIncorrectError as ServiceCurrentPasswordIncorrectError
@ -620,8 +626,8 @@ class ChangeEmailSendEmailApi(Resource):
language = "zh-Hans"
else:
language = "en-US"
account = None
user_email = None
account = current_user
user_email = current_user.email
email_for_sending = args.email.lower()
# Default to the initial phase; any legacy/unexpected client input is
# coerced back to `old_email` so we never trust the caller to declare
@ -636,24 +642,18 @@ class ChangeEmailSendEmailApi(Resource):
if reset_data is None:
raise InvalidTokenError()
# The token used to request a new-email code must come from the
# old-email verification step. This prevents the bypass described
# in GHSA-4q3w-q5mc-45rq where the phase-1 token was reused here.
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
if not isinstance(reset_data, ChangeEmailOldEmailVerifiedToken):
raise InvalidTokenError()
user_email = reset_data.get("email", "")
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
user_email = reset_data.email
if user_email.lower() != current_user.email.lower():
raise InvalidEmailError()
user_email = current_user.email
else:
account = AccountService.get_account_by_email_with_case_fallback(args.email)
if account is None:
raise AccountNotFound()
email_for_sending = account.email
user_email = account.email
if email_for_sending != current_user.email.lower():
raise InvalidEmailError()
email_for_sending = current_user.email
token = AccountService.send_change_email_email(
account=account,
@ -674,6 +674,7 @@ class ChangeEmailCheckApi(Resource):
@login_required
@account_initialization_required
def post(self):
current_user, _ = current_account_with_tenant()
payload = console_ns.payload or {}
args = ChangeEmailValidityPayload.model_validate(payload)
@ -686,42 +687,26 @@ class ChangeEmailCheckApi(Resource):
token_data = AccountService.get_change_email_data(args.token)
if token_data is None:
raise InvalidTokenError()
if not token_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
token_email = token_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
normalized_token_email = token_data.email.lower()
if user_email != normalized_token_email:
raise InvalidEmailError()
if args.code != token_data.get("code"):
if args.code != token_data.code:
AccountService.add_change_email_error_rate_limit(user_email)
raise EmailCodeError()
# Only advance tokens that were minted by the matching send-code step;
# refuse tokens that have already progressed or lack a phase marker so
# the chain `old_email -> old_email_verified -> new_email -> new_email_verified`
# is strictly enforced.
phase_transitions = {
AccountService.CHANGE_EMAIL_PHASE_OLD: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
AccountService.CHANGE_EMAIL_PHASE_NEW: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
token_phase = token_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if not isinstance(token_phase, str):
raise InvalidTokenError()
refreshed_phase = phase_transitions.get(token_phase)
if refreshed_phase is None:
if isinstance(token_data, ChangeEmailOldEmailToken | ChangeEmailNewEmailToken):
refreshed_token_data = token_data.promote()
else:
raise InvalidTokenError()
# Verified, revoke the first token
AccountService.revoke_change_email_token(args.token)
# Refresh token data by generating a new token that carries the
# upgraded phase so later steps can check it.
_, new_token = AccountService.generate_change_email_token(
user_email,
code=args.code,
old_email=token_data.get("old_email"),
additional_data={AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: refreshed_phase},
)
new_token = AccountService.generate_change_email_token(refreshed_token_data, current_user)
AccountService.reset_change_email_error_rate_limit(user_email)
return {"is_valid": True, "email": normalized_token_email, "token": new_token}
@ -746,27 +731,22 @@ class ChangeEmailResetApi(Resource):
if not AccountService.check_email_unique(normalized_new_email):
raise EmailAlreadyInUseError()
current_user, _ = current_account_with_tenant()
reset_data = AccountService.get_change_email_data(args.token)
if not reset_data:
raise InvalidTokenError()
if not reset_data.is_bound_to_account(current_user.id):
raise InvalidTokenError()
# Only tokens that completed both verification phases may be used to
# change the email. This closes GHSA-4q3w-q5mc-45rq where a token from
# the initial send-code step could be replayed directly here.
token_phase = reset_data.get(AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY)
if token_phase != AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
if not isinstance(reset_data, ChangeEmailNewEmailVerifiedToken):
raise InvalidTokenError()
# Bind the new email to the token that was mailed and verified, so a
# verified token cannot be reused with a different `new_email` value.
token_email = reset_data.get("email")
normalized_token_email = token_email.lower() if isinstance(token_email, str) else token_email
if normalized_token_email != normalized_new_email:
if reset_data.email.lower() != normalized_new_email:
raise InvalidTokenError()
old_email = reset_data.get("old_email", "")
current_user, _ = current_account_with_tenant()
if current_user.email.lower() != old_email.lower():
if current_user.email.lower() != reset_data.old_email.lower():
raise AccountNotFound()
# Revoke only after all checks pass so failed attempts don't burn a

View File

@ -177,14 +177,9 @@ class DatasetListApi(DatasetApiResource):
data = marshal(datasets, dataset_detail_fields)
for item in data:
if (
item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY # pyrefly: ignore[bad-index]
and item["embedding_model_provider"] # pyrefly: ignore[bad-index]
):
item["embedding_model_provider"] = str( # pyrefly: ignore[unsupported-operation]
ModelProviderID(item["embedding_model_provider"]) # pyrefly: ignore[bad-index]
)
item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}" # pyrefly: ignore[bad-index]
if item["indexing_technique"] == IndexTechniqueType.HIGH_QUALITY and item["embedding_model_provider"]:
item["embedding_model_provider"] = str(ModelProviderID(item["embedding_model_provider"]))
item_model = f"{item['embedding_model']}:{item['embedding_model_provider']}"
if item_model in model_names:
item["embedding_available"] = True # type: ignore
else:

View File

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

View File

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

View File

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

View File

@ -457,14 +457,16 @@ def init_app(app: DifyApp):
def get_pubsub_broadcast_channel() -> BroadcastChannelProtocol:
assert _pubsub_redis_client is not None, "PubSub redis Client should be initialized here."
join_timeout_ms = dify_config.PUBSUB_LISTENER_JOIN_TIMEOUT_MS
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "sharded":
return ShardedRedisBroadcastChannel(_pubsub_redis_client)
return ShardedRedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
if dify_config.PUBSUB_REDIS_CHANNEL_TYPE == "streams":
return StreamsBroadcastChannel(
_pubsub_redis_client,
retention_seconds=dify_config.PUBSUB_STREAMS_RETENTION_SECONDS,
join_timeout_ms=join_timeout_ms,
)
return RedisBroadcastChannel(_pubsub_redis_client)
return RedisBroadcastChannel(_pubsub_redis_client, join_timeout_ms=join_timeout_ms)
def redis_fallback[T](default_return: T | None = None): # type: ignore

View File

@ -26,6 +26,8 @@ class RedisSubscriptionBase(Subscription):
client: Redis | RedisCluster,
pubsub: PubSub,
topic: str,
*,
join_timeout_ms: int = 2000,
):
# The _pubsub is None only if the subscription is closed.
self._client = client
@ -37,6 +39,11 @@ class RedisSubscriptionBase(Subscription):
self._listener_thread: threading.Thread | None = None
self._start_lock = threading.Lock()
self._started = False
# Max time close() will wait for the listener thread to finish before
# returning. Bounds SSE close tail latency. The listener is a daemon
# and exits on its own within one poll window (~1s), so a low value
# here just means close() returns sooner without breaking anything.
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def _start_if_needed(self) -> None:
"""Start the subscription if not already started."""
@ -205,7 +212,7 @@ class RedisSubscriptionBase(Subscription):
# Due to the restriction above, the PubSub cleanup logic happens inside the consumer thread.
listener = self._listener_thread
if listener is not None:
listener.join(timeout=1.0)
listener.join(timeout=self._join_timeout_ms / 1000.0)
self._listener_thread = None
# Abstract methods to be implemented by subclasses

View File

@ -22,18 +22,30 @@ class BroadcastChannel:
def __init__(
self,
redis_client: Redis | RedisCluster,
*,
join_timeout_ms: int = 2000,
):
self._client = redis_client
# See `RedisSubscriptionBase._join_timeout_ms`: how long close()
# waits for the listener thread before returning.
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def topic(self, topic: str) -> Topic:
return Topic(self._client, topic)
return Topic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
class Topic:
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
def __init__(
self,
redis_client: Redis | RedisCluster,
topic: str,
*,
join_timeout_ms: int = 2000,
):
self._client = redis_client
self._topic = topic
self._redis_topic = serialize_redis_name(topic)
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def as_producer(self) -> Producer:
return self
@ -49,6 +61,7 @@ class Topic:
client=self._client,
pubsub=self._client.pubsub(),
topic=self._redis_topic,
join_timeout_ms=self._join_timeout_ms,
)

View File

@ -20,18 +20,28 @@ class ShardedRedisBroadcastChannel:
def __init__(
self,
redis_client: Redis | RedisCluster,
*,
join_timeout_ms: int = 2000,
):
self._client = redis_client
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def topic(self, topic: str) -> ShardedTopic:
return ShardedTopic(self._client, topic)
return ShardedTopic(self._client, topic, join_timeout_ms=self._join_timeout_ms)
class ShardedTopic:
def __init__(self, redis_client: Redis | RedisCluster, topic: str):
def __init__(
self,
redis_client: Redis | RedisCluster,
topic: str,
*,
join_timeout_ms: int = 2000,
):
self._client = redis_client
self._topic = topic
self._redis_topic = serialize_redis_name(topic)
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def as_producer(self) -> Producer:
return self
@ -47,6 +57,7 @@ class ShardedTopic:
client=self._client,
pubsub=self._client.pubsub(),
topic=self._redis_topic,
join_timeout_ms=self._join_timeout_ms,
)

View File

@ -24,20 +24,42 @@ class StreamsBroadcastChannel:
- The stream key expires `retention_seconds` after the last event is published (to bound storage).
"""
def __init__(self, redis_client: Redis | RedisCluster, *, retention_seconds: int = 600):
def __init__(
self,
redis_client: Redis | RedisCluster,
*,
retention_seconds: int = 600,
join_timeout_ms: int = 2000,
):
self._client = redis_client
self._retention_seconds = max(int(retention_seconds or 0), 0)
# Max time close() will wait for the listener thread to finish.
# See `_StreamsSubscription._join_timeout_ms` for the rationale.
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
def topic(self, topic: str) -> StreamsTopic:
return StreamsTopic(self._client, topic, retention_seconds=self._retention_seconds)
return StreamsTopic(
self._client,
topic,
retention_seconds=self._retention_seconds,
join_timeout_ms=self._join_timeout_ms,
)
class StreamsTopic:
def __init__(self, redis_client: Redis | RedisCluster, topic: str, *, retention_seconds: int = 600):
def __init__(
self,
redis_client: Redis | RedisCluster,
topic: str,
*,
retention_seconds: int = 600,
join_timeout_ms: int = 2000,
):
self._client = redis_client
self._topic = topic
self._key = serialize_redis_name(f"stream:{topic}")
self._retention_seconds = retention_seconds
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
self.max_length = 5000
def as_producer(self) -> Producer:
@ -55,15 +77,23 @@ class StreamsTopic:
return self
def subscribe(self) -> Subscription:
return _StreamsSubscription(self._client, self._key)
return _StreamsSubscription(self._client, self._key, join_timeout_ms=self._join_timeout_ms)
class _StreamsSubscription(Subscription):
_SENTINEL = object()
def __init__(self, client: Redis | RedisCluster, key: str):
def __init__(self, client: Redis | RedisCluster, key: str, *, join_timeout_ms: int = 2000):
self._client = client
self._key = key
# Max time close() will wait for the listener thread to finish before
# returning. Bounds SSE close tail latency: the listener blocks on
# XREAD with BLOCK=1000ms, so close() naturally waits up to ~1s for
# the thread to notice _closed. Setting this lower lets close()
# return promptly while the daemon listener exits on its own within
# one BLOCK window - safe because the listener holds no critical
# state. ``0`` means close() does not wait at all.
self._join_timeout_ms = max(int(join_timeout_ms or 0), 0)
self._queue: queue.Queue[object] = queue.Queue()
@ -181,11 +211,13 @@ class _StreamsSubscription(Subscription):
# We close the listener outside of the with block to avoid holding the
# lock for a long time.
if listener is not None and listener.is_alive():
listener.join(timeout=2.0)
listener.join(timeout=self._join_timeout_ms / 1000.0)
if listener.is_alive():
logger.warning(
"Streams subscription listener for key %s did not stop within timeout; keeping reference.",
logger.debug(
"Streams subscription listener for key %s did not stop within %dms; "
"daemon thread will exit on its own within one poll window.",
self._key,
self._join_timeout_ms,
)
# Context manager helpers

View File

@ -16,7 +16,7 @@ from zoneinfo import available_timezones
from flask import Response, stream_with_context
from flask_restx import fields
from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel, ConfigDict, TypeAdapter, with_config
from pydantic.functional_validators import AfterValidator
from typing_extensions import TypedDict
@ -33,13 +33,29 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
@with_config(ConfigDict(extra="allow"))
class _TokenData(TypedDict, total=False):
"""Shared baseline token payload.
`extra='allow'` keeps TokenManager from silently stripping business-
specific metadata keys while still validating the common auth fields.
Business flows that need stronger guarantees should validate again at
their own boundary with a dedicated Pydantic model.
For the change-email flow specifically, `email_change_phase` is the
discriminator used by `services.entities.auth_entities.ChangeEmailTokenData`.
It is declared here so the shared token adapter can still provide baseline
validation for the state-machine key without taking over the full business
model.
"""
account_id: str | None
email: str
token_type: str
code: str
old_email: str
phase: str
email_change_phase: str
_token_data_adapter: TypeAdapter[_TokenData] = TypeAdapter(_TokenData)
@ -466,7 +482,7 @@ class TokenManager:
raise ValueError("Account or email must be provided")
account_id = account.id if account else None
account_email = account.email if account else email
account_email = email if email is not None else account.email if account else None
if account_id:
old_token = cls._get_current_token_for_account(account_id, token_type)
@ -508,8 +524,7 @@ class TokenManager:
if token_data_json is None:
logger.warning("%s token %s not found with key %s", token_type, token, key)
return None
token_data = dict(_token_data_adapter.validate_json(token_data_json))
return token_data
return dict(_token_data_adapter.validate_json(token_data_json))
@classmethod
def _get_current_token_for_account(cls, account_id: str, token_type: str) -> str | None:

View File

@ -2,7 +2,7 @@
name = "dify-trace-arize-phoenix"
version = "0.0.1"
dependencies = [
"arize-phoenix-otel~=0.15.0",
"arize-phoenix-otel==0.15.0",
]
description = "Dify ops tracing provider (Arize / Phoenix)."

View File

@ -2,7 +2,7 @@
name = "dify-trace-langsmith"
version = "0.0.1"
dependencies = [
"langsmith~=0.7.30",
"langsmith==0.8.5",
]
description = "Dify ops tracing provider (LangSmith)."

View File

@ -2,7 +2,7 @@
name = "dify-trace-mlflow"
version = "0.0.1"
dependencies = [
"mlflow-skinny>=3.11.1",
"mlflow-skinny>=3.11.1,<4.0.0",
]
description = "Dify ops tracing provider (MLflow / Databricks)."

View File

@ -2,7 +2,7 @@
name = "dify-trace-weave"
version = "0.0.1"
dependencies = [
"weave>=0.52.36",
"weave==0.52.36",
]
description = "Dify ops tracing provider (Weave)."

View File

@ -2,7 +2,7 @@
name = "dify-vdb-alibabacloud-mysql"
version = "0.0.1"
dependencies = [
"mysql-connector-python>=9.3.0",
"mysql-connector-python>=9.3.0,<10.0.0",
]
description = "Dify vector store backend (dify-vdb-alibabacloud-mysql)."

View File

@ -3,8 +3,8 @@ name = "dify-vdb-analyticdb"
version = "0.0.1"
dependencies = [
"alibabacloud_gpdb20160503~=5.2.0",
"alibabacloud_tea_openapi~=0.4.3",
"clickhouse-connect~=0.15.0",
"alibabacloud_tea_openapi==0.4.4",
"clickhouse-connect==0.15.1",
]
description = "Dify vector store backend (dify-vdb-analyticdb)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-clickzetta"
version = "0.0.1"
dependencies = [
"clickzetta-connector-python>=0.8.102",
"clickzetta-connector-python==0.8.104",
]
description = "Dify vector store backend (dify-vdb-clickzetta)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-hologres"
version = "0.0.1"
dependencies = [
"holo-search-sdk>=0.4.2",
"holo-search-sdk==0.4.2",
]
description = "Dify vector store backend (dify-vdb-hologres)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-iris"
version = "0.0.1"
dependencies = [
"intersystems-irispython>=5.1.0",
"intersystems-irispython>=5.1.0,<6.0.0",
]
description = "Dify vector store backend (dify-vdb-iris)."

View File

@ -4,7 +4,7 @@ version = "0.0.1"
dependencies = [
"opensearch-py==3.1.0",
"tenacity>=8.0.0",
"tenacity>=8.0.0,<9.0.0",
]
description = "Dify vector store backend (dify-vdb-lindorm)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-matrixone"
version = "0.0.1"
dependencies = [
"mo-vector~=0.1.13",
"mo-vector==0.1.13",
]
description = "Dify vector store backend (dify-vdb-matrixone)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-myscale"
version = "0.0.1"
dependencies = [
"clickhouse-connect~=0.15.0",
"clickhouse-connect==0.15.1",
]
description = "Dify vector store backend (dify-vdb-myscale)."

View File

@ -3,8 +3,8 @@ name = "dify-vdb-oceanbase"
version = "0.0.1"
dependencies = [
"pyobvector~=0.2.17",
"mysql-connector-python>=9.3.0",
"pyobvector==0.2.25",
"mysql-connector-python>=9.3.0,<10.0.0",
]
description = "Dify vector store backend (dify-vdb-oceanbase)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-pgvecto-rs"
version = "0.0.1"
dependencies = [
"pgvecto-rs[sqlalchemy]~=0.2.2",
"pgvecto-rs[sqlalchemy]==0.2.2",
]
description = "Dify vector store backend (dify-vdb-pgvecto-rs)."

View File

@ -3,7 +3,7 @@ name = "dify-vdb-vastbase"
version = "0.0.1"
dependencies = [
"pyobvector~=0.2.17",
"pyobvector==0.2.25",
]
description = "Dify vector store backend (dify-vdb-vastbase)."

View File

@ -5,48 +5,48 @@ requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.3.0",
"boto3>=1.43.6",
"celery>=5.6.3",
"croniter>=6.2.2",
"bleach>=6.3.0,<7.0.0",
"boto3>=1.43.10,<2.0.0",
"celery>=5.6.3,<6.0.0",
"croniter>=6.2.2,<7.0.0",
"dify-agent",
"flask>=3.1.3,<4.0.0",
"flask-cors>=6.0.2",
"gevent>=26.4.0",
"gevent-websocket>=0.10.1",
"gmpy2>=2.3.0",
"google-api-python-client>=2.196.0",
"gunicorn>=26.0.0",
"psycogreen>=1.0.2",
"psycopg2-binary>=2.9.12",
"python-socketio>=5.13.0",
"redis[hiredis]>=7.4.0",
"sendgrid>=6.12.5",
"sseclient-py>=1.8.0",
"flask-cors>=6.0.2,<7.0.0",
"gevent>=26.4.0,<26.5.0",
"gevent-websocket==0.10.1",
"gmpy2>=2.3.0,<3.0.0",
"google-api-python-client>=2.196.0,<3.0.0",
"gunicorn>=26.0.0,<27.0.0",
"psycogreen>=1.0.2,<2.0.0",
"psycopg2-binary>=2.9.12,<3.0.0",
"python-socketio>=5.13.0,<6.0.0",
"redis[hiredis]>=7.4.0,<8.0.0",
"sendgrid>=6.12.5,<7.0.0",
"sseclient-py>=1.8.0,<2.0.0",
# Stable: production-proven, cap below the next major
"aliyun-log-python-sdk>=0.9.44,<1.0.0",
"aliyun-log-python-sdk==0.9.44",
"azure-identity>=1.25.3,<2.0.0",
"flask-compress>=1.24,<2.0.0",
"flask-login>=0.6.3,<1.0.0",
"flask-login==0.6.3",
"flask-migrate>=4.1.0,<5.0.0",
"flask-orjson>=2.0.0,<3.0.0",
"flask-restx>=1.3.2,<2.0.0",
"google-cloud-aiplatform>=1.151.0,<2.0.0",
"httpx[socks]>=0.28.1,<1.0.0",
"opentelemetry-distro>=0.62b1,<1.0.0",
"opentelemetry-instrumentation-celery>=0.62b0,<1.0.0",
"opentelemetry-instrumentation-flask>=0.62b0,<1.0.0",
"opentelemetry-instrumentation-httpx>=0.62b0,<1.0.0",
"opentelemetry-instrumentation-redis>=0.62b0,<1.0.0",
"opentelemetry-instrumentation-sqlalchemy>=0.62b0,<1.0.0",
"httpx[socks]==0.28.1",
"opentelemetry-distro==0.62b1",
"opentelemetry-instrumentation-celery==0.62b1",
"opentelemetry-instrumentation-flask==0.62b1",
"opentelemetry-instrumentation-httpx==0.62b1",
"opentelemetry-instrumentation-redis==0.62b1",
"opentelemetry-instrumentation-sqlalchemy==0.62b1",
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
"readabilipy>=0.3.0,<1.0.0",
"readabilipy==0.3.0",
"resend>=2.27.0,<3.0.0",
# Emerging: newer and fast-moving, use compatible pins
"fastopenapi[flask]~=0.7.0",
"graphon~=0.4.0",
"httpx-sse~=0.4.0",
"json-repair~=0.59.4",
"fastopenapi[flask]==0.7.0",
"graphon==0.4.0",
"httpx-sse==0.4.3",
"json-repair==0.59.4",
]
# Before adding new dependency, consider place it in
# alphabet order (a-z) and suitable group.
@ -103,8 +103,8 @@ dify-trace-weave = { workspace = true }
default-groups = ["storage", "tools", "vdb-all", "trace-all"]
package = false
override-dependencies = [
"litellm>=1.83.7",
"pyarrow>=18.0.0",
"litellm>=1.83.10,<2.0.0",
"pyarrow>=23.0.1,<24.0.0",
]
[dependency-groups]
@ -158,7 +158,7 @@ dev = [
"types-tensorflow>=2.18.0.20260408",
"types-tqdm>=4.67.3.20260408",
"types-ujson>=5.10.0",
"boto3-stubs>=1.43.2",
"boto3-stubs>=1.43.10",
"types-jmespath>=1.1.0.20260408",
"hypothesis>=6.152.4",
"types_pyOpenSSL>=24.1.0",
@ -183,21 +183,21 @@ dev = [
# Required for storage clients
############################################################
storage = [
"azure-storage-blob>=12.28.0",
"bce-python-sdk>=0.9.71",
"cos-python-sdk-v5>=1.9.42",
"esdk-obs-python>=3.22.2",
"google-cloud-storage>=3.10.1",
"opendal>=0.46.0",
"oss2>=2.19.1",
"supabase>=2.30.0",
"tos>=2.9.0",
"azure-storage-blob>=12.29.0,<13.0.0",
"bce-python-sdk==0.9.71",
"cos-python-sdk-v5>=1.9.43,<2.0.0",
"esdk-obs-python>=3.22.2,<4.0.0",
"google-cloud-storage>=3.10.1,<4.0.0",
"opendal==0.46.0",
"oss2>=2.19.1,<3.0.0",
"supabase>=2.30.0,<3.0.0",
"tos>=2.9.0,<3.0.0",
]
############################################################
# [ Tools ] dependency group
############################################################
tools = ["cloudscraper>=1.2.71", "nltk>=3.9.1"]
tools = ["cloudscraper>=1.2.71,<2.0.0", "nltk>=3.9.1,<4.0.0"]
############################################################
# [ VDB ] workspace plugins — hollow packages under providers/vdb/*
@ -267,7 +267,7 @@ vdb-vastbase = ["dify-vdb-vastbase"]
vdb-vikingdb = ["dify-vdb-vikingdb"]
vdb-weaviate = ["dify-vdb-weaviate"]
# Optional client used by some tests / integrations (not a vector backend plugin)
vdb-xinference = ["xinference-client>=2.7.0"]
vdb-xinference = ["xinference-client>=2.7.0,<3.0.0"]
trace-all = [
"dify-trace-aliyun",
@ -293,4 +293,5 @@ project-includes = ["."]
project-excludes = [".venv", "migrations/"]
python-platform = "linux"
python-version = "3.12.0"
infer-with-first-use = false
infer-with-first-use = true
min-severity = "warn"

View File

@ -1,148 +1,93 @@
controllers/console/app/annotation.py
controllers/console/app/app.py
controllers/console/app/app_import.py
controllers/console/app/mcp_server.py
controllers/console/app/site.py
controllers/console/auth/email_register.py
controllers/console/human_input_form.py
controllers/console/init_validate.py
controllers/console/ping.py
controllers/console/setup.py
controllers/console/version.py
controllers/console/workspace/trigger_providers.py
controllers/service_api/app/annotation.py
controllers/web/workflow_events.py
core/agent/fc_agent_runner.py
core/app/apps/advanced_chat/app_generator.py
core/app/apps/advanced_chat/app_runner.py
core/app/apps/advanced_chat/generate_task_pipeline.py
core/app/apps/agent_chat/app_generator.py
core/app/apps/base_app_generate_response_converter.py
core/app/apps/base_app_generator.py
core/app/apps/chat/app_generator.py
core/app/apps/common/workflow_response_converter.py
core/app/apps/completion/app_generator.py
core/app/apps/pipeline/pipeline_generator.py
core/app/apps/pipeline/pipeline_runner.py
core/app/apps/workflow/app_generator.py
core/app/apps/workflow/app_runner.py
core/app/apps/workflow/generate_task_pipeline.py
core/app/apps/workflow_app_runner.py
core/app/task_pipeline/easy_ui_based_generate_task_pipeline.py
core/datasource/datasource_manager.py
core/external_data_tool/api/api.py
core/llm_generator/llm_generator.py
core/llm_generator/output_parser/structured_output.py
core/mcp/mcp_client.py
providers/trace/trace-aliyun/src/dify_trace_aliyun/data_exporter/traceclient.py
providers/trace/trace-arize-phoenix/src/dify_trace_arize_phoenix/arize_phoenix_trace.py
providers/trace/trace-mlflow/src/dify_trace_mlflow/mlflow_trace.py
core/ops/ops_trace_manager.py
providers/trace/trace-tencent/src/dify_trace_tencent/client.py
providers/trace/trace-tencent/src/dify_trace_tencent/utils.py
core/plugin/backwards_invocation/base.py
core/plugin/backwards_invocation/model.py
core/prompt/utils/extract_thread_messages.py
core/rag/datasource/keyword/jieba/jieba.py
core/rag/datasource/keyword/jieba/jieba_keyword_table_handler.py
providers/vdb/**
core/rag/extractor/csv_extractor.py
core/rag/extractor/excel_extractor.py
core/rag/extractor/firecrawl/firecrawl_app.py
core/rag/extractor/firecrawl/firecrawl_web_extractor.py
core/rag/extractor/html_extractor.py
core/rag/extractor/jina_reader_extractor.py
core/rag/extractor/markdown_extractor.py
core/rag/extractor/notion_extractor.py
core/rag/extractor/pdf_extractor.py
core/rag/extractor/text_extractor.py
core/rag/extractor/unstructured/unstructured_doc_extractor.py
core/rag/extractor/unstructured/unstructured_eml_extractor.py
core/rag/extractor/unstructured/unstructured_epub_extractor.py
core/rag/extractor/unstructured/unstructured_markdown_extractor.py
core/rag/extractor/unstructured/unstructured_msg_extractor.py
core/rag/extractor/unstructured/unstructured_ppt_extractor.py
core/rag/extractor/unstructured/unstructured_pptx_extractor.py
core/rag/extractor/unstructured/unstructured_xml_extractor.py
core/rag/extractor/watercrawl/client.py
core/rag/extractor/watercrawl/extractor.py
core/prompt/utils/prompt_message_util.py
core/rag/retrieval/dataset_retrieval.py
core/tools/tool_manager.py
extensions/ext_celery.py
providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_factory.py
providers/vdb/vdb-alibabacloud-mysql/tests/unit_tests/test_alibabacloud_mysql_vector.py
providers/vdb/vdb-analyticdb/src/dify_vdb_analyticdb/analyticdb_vector_openapi.py
providers/vdb/vdb-analyticdb/tests/integration_tests/test_analyticdb.py
providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector.py
providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_openapi.py
providers/vdb/vdb-analyticdb/tests/unit_tests/test_analyticdb_vector_sql.py
providers/vdb/vdb-baidu/src/dify_vdb_baidu/baidu_vector.py
providers/vdb/vdb-baidu/tests/unit_tests/test_baidu_vector.py
providers/vdb/vdb-chroma/tests/unit_tests/test_chroma_vector.py
providers/vdb/vdb-clickzetta/tests/unit_tests/test_clickzetta_vector.py
providers/vdb/vdb-couchbase/tests/unit_tests/test_couchbase_vector.py
providers/vdb/vdb-elasticsearch/src/dify_vdb_elasticsearch/elasticsearch_vector.py
providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_ja_vector.py
providers/vdb/vdb-elasticsearch/tests/unit_tests/test_elasticsearch_vector.py
providers/vdb/vdb-hologres/tests/unit_tests/test_hologres_vector.py
providers/vdb/vdb-hologres/tests/integration_tests/conftest.py
providers/vdb/vdb-huawei-cloud/tests/unit_tests/test_huawei_cloud_vector.py
providers/vdb/vdb-iris/tests/unit_tests/test_iris_vector.py
providers/vdb/vdb-lindorm/src/dify_vdb_lindorm/lindorm_vector.py
providers/vdb/vdb-lindorm/tests/integration_tests/test_lindorm.py
providers/vdb/vdb-lindorm/tests/unit_tests/test_lindorm_vector.py
providers/vdb/vdb-matrixone/tests/unit_tests/test_matrixone_vector.py
providers/vdb/vdb-milvus/tests/unit_tests/test_milvus.py
providers/vdb/vdb-myscale/tests/unit_tests/test_myscale_vector.py
providers/vdb/vdb-oceanbase/src/dify_vdb_oceanbase/oceanbase_vector.py
providers/vdb/vdb-oceanbase/tests/unit_tests/test_oceanbase_vector.py
providers/vdb/vdb-opengauss/tests/integration_tests/test_opengauss.py
providers/vdb/vdb-opengauss/tests/unit_tests/test_opengauss.py
providers/vdb/vdb-opensearch/src/dify_vdb_opensearch/opensearch_vector.py
providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch.py
providers/vdb/vdb-opensearch/tests/unit_tests/test_opensearch_vector.py
providers/vdb/vdb-oracle/src/dify_vdb_oracle/oraclevector.py
providers/vdb/vdb-oracle/tests/unit_tests/test_oraclevector.py
providers/vdb/vdb-pgvecto-rs/tests/unit_tests/test_pgvecto_rs.py
providers/vdb/vdb-pgvecto-rs/src/dify_vdb_pgvecto_rs/pgvecto_rs.py
providers/vdb/vdb-pgvector/tests/unit_tests/test_pgvector.py
providers/vdb/vdb-qdrant/tests/unit_tests/test_qdrant_vector.py
providers/vdb/vdb-relyt/tests/unit_tests/test_relyt_vector.py
providers/vdb/vdb-tablestore/tests/integration_tests/test_tablestore.py
providers/vdb/vdb-tablestore/tests/unit_tests/test_tablestore_vector.py
providers/vdb/vdb-tencent/src/dify_vdb_tencent/tencent_vector.py
providers/vdb/vdb-tencent/tests/integration_tests/conftest.py
providers/vdb/vdb-tencent/tests/unit_tests/test_tencent_vector.py
providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_on_qdrant_vector.py
providers/vdb/vdb-tidb-on-qdrant/src/dify_vdb_tidb_on_qdrant/tidb_service.py
providers/vdb/vdb-tidb-on-qdrant/tests/unit_tests/test_tidb_service.py
providers/vdb/vdb-tidb-vector/tests/unit_tests/test_tidb_vector.py
providers/vdb/vdb-upstash/src/dify_vdb_upstash/upstash_vector.py
providers/vdb/vdb-upstash/tests/unit_tests/test_upstash_vector.py
providers/vdb/vdb-vastbase/tests/unit_tests/test_vastbase_vector.py
providers/vdb/vdb-vikingdb/src/dify_vdb_vikingdb/vikingdb_vector.py
providers/vdb/vdb-vikingdb/tests/unit_tests/test_vikingdb_vector.py
providers/vdb/vdb-weaviate/src/dify_vdb_weaviate/weaviate_vector.py
providers/vdb/vdb-weaviate/tests/unit_tests/test_weaviate_vector.py
core/rag/extractor/watercrawl/provider.py
core/rag/extractor/word_extractor.py
core/rag/index_processor/processor/paragraph_index_processor.py
core/rag/index_processor/processor/parent_child_index_processor.py
core/rag/index_processor/processor/qa_index_processor.py
core/rag/retrieval/router/multi_dataset_function_call_router.py
core/rag/summary_index/summary_index.py
core/repositories/sqlalchemy_workflow_execution_repository.py
core/repositories/sqlalchemy_workflow_node_execution_repository.py
core/tools/__base/tool.py
core/tools/mcp_tool/provider.py
core/tools/plugin_tool/provider.py
core/tools/utils/message_transformer.py
core/tools/utils/web_reader_tool.py
core/tools/workflow_as_tool/provider.py
core/trigger/debug/event_selectors.py
core/trigger/entities/entities.py
core/trigger/provider.py
core/workflow/workflow_entry.py
enterprise/telemetry/contracts.py
enterprise/telemetry/draft_trace.py
enterprise/telemetry/enterprise_trace.py
enterprise/telemetry/entities/__init__.py
enterprise/telemetry/event_handlers.py
enterprise/telemetry/exporter.py
enterprise/telemetry/id_generator.py
enterprise/telemetry/metric_handler.py
enterprise/telemetry/telemetry_log.py
core/workflow/nodes/knowledge_retrieval/knowledge_retrieval_node.py
extensions/logstore/repositories/logstore_api_workflow_run_repository.py
extensions/otel/instrumentation.py
extensions/otel/runtime.py
extensions/storage/aliyun_oss_storage.py
extensions/storage/aws_s3_storage.py
extensions/storage/azure_blob_storage.py
extensions/storage/baidu_obs_storage.py
extensions/storage/clickzetta_volume/clickzetta_volume_storage.py
extensions/storage/clickzetta_volume/file_lifecycle.py
extensions/storage/google_cloud_storage.py
extensions/storage/huawei_obs_storage.py
extensions/storage/opendal_storage.py
extensions/storage/oracle_oci_storage.py
extensions/storage/supabase_storage.py
extensions/storage/tencent_cos_storage.py
extensions/storage/volcengine_tos_storage.py
libs/gmpy2_pkcs10aep_cipher.py
schedule/queue_monitor_task.py
services/account_service.py
services/audio_service.py
services/auth/firecrawl/firecrawl.py
services/auth/jina.py
services/auth/jina/jina.py
services/auth/watercrawl/watercrawl.py
services/conversation_service.py
services/dataset_service.py
services/app_service.py
services/document_indexing_proxy/document_indexing_task_proxy.py
services/document_indexing_proxy/duplicate_document_indexing_task_proxy.py
services/external_knowledge_service.py
services/plugin/plugin_migration.py
services/recommend_app/buildin/buildin_retrieval.py
services/recommend_app/database/database_retrieval.py
services/recommend_app/remote/remote_retrieval.py
services/summary_index_service.py
services/tools/tools_transform_service.py
services/trigger/trigger_provider_service.py
services/trigger/trigger_subscription_builder_service.py
services/trigger/webhook_service.py
services/workflow_draft_variable_service.py
services/workflow_event_snapshot_service.py
services/workflow_service.py
tasks/app_generate/workflow_execute_task.py
tasks/regenerate_summary_index_task.py
tasks/trigger_processing_tasks.py
tasks/workflow_cfs_scheduler/cfs_scheduler.py
tasks/add_document_to_index_task.py
tasks/create_segment_to_index_task.py
tasks/disable_segment_from_index_task.py
tasks/enable_segment_to_index_task.py
tasks/remove_document_from_index_task.py
tasks/workflow_execution_tasks.py

View File

@ -7,7 +7,7 @@ from datetime import UTC, datetime, timedelta
from hashlib import sha256
from typing import Any, TypedDict, cast
from pydantic import BaseModel, TypeAdapter
from pydantic import BaseModel, TypeAdapter, ValidationError
from sqlalchemy import delete, func, select, update
from core.db.session_factory import session_factory
@ -46,6 +46,12 @@ from models.account import (
)
from models.model import DifySetup
from services.billing_service import BillingService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailOldEmailToken,
ChangeEmailPhase,
ChangeEmailTokenData,
)
from services.errors.account import (
AccountAlreadyInTenantError,
AccountLoginError,
@ -84,6 +90,8 @@ from tasks.mail_reset_password_task import (
logger = logging.getLogger(__name__)
_change_email_token_adapter: TypeAdapter[ChangeEmailTokenData] = TypeAdapter(ChangeEmailTokenData)
class InvitationDetailDict(TypedDict):
account: Account
@ -113,13 +121,10 @@ REFRESH_TOKEN_EXPIRY = timedelta(days=dify_config.REFRESH_TOKEN_EXPIRE_DAYS)
class AccountService:
# Phase-bound token metadata for the change-email flow. Tokens carry the
# current phase so that downstream endpoints can enforce proper progression
CHANGE_EMAIL_TOKEN_PHASE_KEY = "email_change_phase"
CHANGE_EMAIL_PHASE_OLD = "old_email"
CHANGE_EMAIL_PHASE_OLD_VERIFIED = "old_email_verified"
CHANGE_EMAIL_PHASE_NEW = "new_email"
CHANGE_EMAIL_PHASE_NEW_VERIFIED = "new_email_verified"
CHANGE_EMAIL_PHASE_OLD = ChangeEmailPhase.OLD_EMAIL
CHANGE_EMAIL_PHASE_OLD_VERIFIED = ChangeEmailPhase.OLD_EMAIL_VERIFIED
CHANGE_EMAIL_PHASE_NEW = ChangeEmailPhase.NEW_EMAIL
CHANGE_EMAIL_PHASE_NEW_VERIFIED = ChangeEmailPhase.NEW_EMAIL_VERIFIED
reset_password_rate_limiter = RateLimiter(prefix="reset_password_rate_limit", max_attempts=1, time_window=60 * 1)
email_register_rate_limiter = RateLimiter(prefix="email_register_rate_limit", max_attempts=1, time_window=60 * 1)
@ -583,31 +588,42 @@ class AccountService:
@classmethod
def send_change_email_email(
cls,
account: Account | None = None,
account: Account,
email: str | None = None,
old_email: str | None = None,
language: str = "en-US",
phase: str | None = None,
):
account_email = account.email if account else email
if account_email is None:
raise ValueError("Email must be provided.")
account_email = email if email is not None else account.email
if not phase:
raise ValueError("phase must be provided.")
if phase not in (cls.CHANGE_EMAIL_PHASE_OLD, cls.CHANGE_EMAIL_PHASE_NEW):
raise ValueError("phase must be one of old_email or new_email.")
if old_email is None:
raise ValueError("old_email must be provided.")
if cls.change_email_rate_limiter.is_rate_limited(account_email):
from controllers.console.auth.error import EmailChangeRateLimitExceededError
raise EmailChangeRateLimitExceededError(int(cls.change_email_rate_limiter.time_window / 60))
code, token = cls.generate_change_email_token(
account_email,
account,
old_email=old_email,
additional_data={cls.CHANGE_EMAIL_TOKEN_PHASE_KEY: phase},
)
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
token_data: ChangeEmailTokenData
if phase == cls.CHANGE_EMAIL_PHASE_OLD:
token_data = ChangeEmailOldEmailToken(
account_id=account.id,
email=account_email,
old_email=old_email,
code=code,
)
else:
token_data = ChangeEmailNewEmailToken(
account_id=account.id,
email=account_email,
old_email=old_email,
code=code,
)
token = cls.generate_change_email_token(token_data, account)
send_change_mail_task.delay(
language=language,
@ -735,20 +751,16 @@ class AccountService:
@classmethod
def generate_change_email_token(
cls,
email: str,
account: Account | None = None,
code: str | None = None,
old_email: str | None = None,
additional_data: dict[str, Any] = {},
):
if not code:
code = "".join([str(secrets.randbelow(exclusive_upper_bound=10)) for _ in range(6)])
additional_data["code"] = code
additional_data["old_email"] = old_email
token_data: ChangeEmailTokenData,
account: Account,
) -> str:
token = TokenManager.generate_token(
account=account, email=email, token_type="change_email", additional_data=additional_data
account=account,
email=token_data.email,
token_type="change_email",
additional_data=token_data.to_token_manager_payload(),
)
return code, token
return token
@classmethod
def generate_owner_transfer_token(
@ -791,8 +803,15 @@ class AccountService:
return TokenManager.get_token_data(token, "email_register")
@classmethod
def get_change_email_data(cls, token: str) -> dict[str, Any] | None:
return TokenManager.get_token_data(token, "change_email")
def get_change_email_data(cls, token: str) -> ChangeEmailTokenData | None:
token_data = TokenManager.get_token_data(token, "change_email")
if token_data is None:
return None
try:
return _change_email_token_adapter.validate_python(token_data)
except ValidationError:
logger.warning("change_email token %s has invalid payload", token, exc_info=True)
return None
@classmethod
def get_owner_transfer_data(cls, token: str) -> dict[str, Any] | None:

View File

@ -1,6 +1,7 @@
from enum import StrEnum, auto
from typing import Annotated, Literal
from pydantic import BaseModel, Field, field_validator
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from libs.helper import EmailStr
from libs.password import valid_password
@ -20,6 +21,24 @@ class LoginFailureReason(StrEnum):
LOGIN_RATE_LIMITED = auto()
class ChangeEmailPhase(StrEnum):
"""Change-email token state machine.
Allowed transitions:
`OLD_EMAIL -> OLD_EMAIL_VERIFIED -> NEW_EMAIL -> NEW_EMAIL_VERIFIED`
The flow starts by sending a code to the current email address. Only a
token in `OLD_EMAIL_VERIFIED` may request the new-email code, and only a
token in `NEW_EMAIL_VERIFIED` may perform the final email reset.
"""
OLD_EMAIL = "old_email"
OLD_EMAIL_VERIFIED = "old_email_verified"
NEW_EMAIL = "new_email"
NEW_EMAIL_VERIFIED = "new_email_verified"
class LoginPayloadBase(BaseModel):
email: EmailStr
password: str
@ -45,3 +64,122 @@ class ForgotPasswordResetPayload(BaseModel):
@classmethod
def validate_password(cls, value: str) -> str:
return valid_password(value)
class ChangeEmailTokenBase(BaseModel):
"""Stored change-email token payload.
The discriminator lives in `email_change_phase`; callers use the concrete
model type to decide which transitions are legal.
The full progression is:
`old_email -> old_email_verified -> new_email -> new_email_verified`
Every state is bound to the initiating `account_id` so the change-email
flow cannot be replayed across accounts.
"""
token_type: Literal["change_email"] = "change_email"
account_id: str = Field(min_length=1)
email: EmailStr
old_email: EmailStr
code: str = Field(min_length=1)
model_config = ConfigDict(extra="forbid")
def to_token_manager_payload(self) -> dict[str, str]:
return self.model_dump(exclude={"token_type", "account_id", "email"})
def is_bound_to_account(self, account_id: str) -> bool:
return self.account_id == account_id
class _ChangeEmailOldAddressMixin(ChangeEmailTokenBase):
"""States whose `email` must still be the account's current address."""
@model_validator(mode="after")
def validate_old_address_binding(self) -> "_ChangeEmailOldAddressMixin":
if self.email.lower() != self.old_email.lower():
raise ValueError("old-email token payload must bind email to old_email")
return self
class ChangeEmailOldEmailToken(_ChangeEmailOldAddressMixin):
"""Phase-1 token minted when sending a code to the old email address.
This token proves only that the flow started for the current account. It
must not unlock the new-email send step or the final reset step until the
old-email verification code has been checked.
"""
email_change_phase: Literal[ChangeEmailPhase.OLD_EMAIL] = ChangeEmailPhase.OLD_EMAIL
def promote(self) -> "ChangeEmailOldEmailVerifiedToken":
"""Advance to the state that is allowed to request the new-email code."""
return ChangeEmailOldEmailVerifiedToken(
**self.model_dump(exclude={"email_change_phase"}),
email_change_phase=ChangeEmailPhase.OLD_EMAIL_VERIFIED,
)
class ChangeEmailOldEmailVerifiedToken(_ChangeEmailOldAddressMixin):
"""Token returned after the old email verification code succeeds.
The token used to request a new-email code must come from this state. This
blocks the GHSA-4q3w-q5mc-45rq bypass where a phase-1 token was replayed to
skip the old-email verification step.
"""
email_change_phase: Literal[ChangeEmailPhase.OLD_EMAIL_VERIFIED] = ChangeEmailPhase.OLD_EMAIL_VERIFIED
class ChangeEmailNewEmailToken(ChangeEmailTokenBase):
"""Token minted when sending a code to the target new email address.
At this point the account binding is already fixed, but the new address has
not been verified yet, so the token may only be promoted by a successful
new-email verification code check.
"""
email_change_phase: Literal[ChangeEmailPhase.NEW_EMAIL] = ChangeEmailPhase.NEW_EMAIL
def promote(self) -> "ChangeEmailNewEmailVerifiedToken":
"""Advance to the only state that may perform the final email reset."""
return ChangeEmailNewEmailVerifiedToken(
**self.model_dump(exclude={"email_change_phase"}),
email_change_phase=ChangeEmailPhase.NEW_EMAIL_VERIFIED,
)
class ChangeEmailNewEmailVerifiedToken(ChangeEmailTokenBase):
"""Final verified token for the change-email flow.
Only this state may change the account email, and the reset endpoint must
additionally require that the request's `new_email` matches this token's
`email` so a verified token for address A cannot be replayed for address B.
"""
email_change_phase: Literal[ChangeEmailPhase.NEW_EMAIL_VERIFIED] = ChangeEmailPhase.NEW_EMAIL_VERIFIED
# Tokens that can still advance by verifying a code.
ChangeEmailPendingTokenData = Annotated[
ChangeEmailOldEmailToken | ChangeEmailNewEmailToken,
Field(discriminator="email_change_phase"),
]
# Tokens that already completed a verification step.
ChangeEmailVerifiedTokenData = Annotated[
ChangeEmailOldEmailVerifiedToken | ChangeEmailNewEmailVerifiedToken,
Field(discriminator="email_change_phase"),
]
# Complete change-email token state machine.
ChangeEmailTokenData = Annotated[
ChangeEmailOldEmailToken
| ChangeEmailOldEmailVerifiedToken
| ChangeEmailNewEmailToken
| ChangeEmailNewEmailVerifiedToken,
Field(discriminator="email_change_phase"),
]

View File

@ -618,26 +618,27 @@ class RagPipelineService:
for key, value in datasource_parameters.items():
param_value = value.get("value")
if not param_value:
variables_map[key] = param_value
elif isinstance(param_value, str):
# handle string type parameter value, check if it contains variable reference pattern
pattern = r"\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z0-9_][a-zA-Z0-9_]{0,29}){1,10})#\}\}"
match = re.match(pattern, param_value)
if match:
# extract variable path and try to get value from user inputs
full_path = match.group(1)
last_part = full_path.split(".")[-1]
variables_map[key] = user_inputs.get(last_part, param_value)
else:
match param_value:
case None | "" | [] | {}:
variables_map[key] = param_value
case str():
# handle string type parameter value, check if it contains variable reference pattern
pattern = r"\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z0-9_][a-zA-Z0-9_]{0,29}){1,10})#\}\}"
match_result = re.match(pattern, param_value)
if match_result:
# extract variable path and try to get value from user inputs
full_path = match_result.group(1)
last_part = full_path.split(".")[-1]
variables_map[key] = user_inputs.get(last_part, param_value)
else:
variables_map[key] = param_value
case list() if param_value:
# handle list type parameter value, check if the last element is in user inputs
last_part = param_value[-1]
variables_map[key] = user_inputs.get(last_part, param_value)
case _:
# other type directly use original value
variables_map[key] = param_value
elif isinstance(param_value, list) and param_value:
# handle list type parameter value, check if the last element is in user inputs
last_part = param_value[-1]
variables_map[key] = user_inputs.get(last_part, param_value)
else:
# other type directly use original value
variables_map[key] = param_value
from core.datasource.datasource_manager import DatasourceManager

View File

@ -78,32 +78,33 @@ class ToolTransformService:
:param tenant_id: the tenant id
:param provider: the provider dict
"""
if isinstance(provider, dict) and "icon" in provider:
provider["icon"] = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider["type"], provider_name=provider["name"], icon=provider["icon"]
)
elif isinstance(provider, ToolProviderApiEntity):
if provider.plugin_id:
if isinstance(provider.icon, str):
provider.icon = PluginService.get_plugin_icon_url(tenant_id=tenant_id, filename=provider.icon)
if isinstance(provider.icon_dark, str) and provider.icon_dark:
provider.icon_dark = PluginService.get_plugin_icon_url(
tenant_id=tenant_id, filename=provider.icon_dark
)
else:
provider.icon = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon
match provider:
case dict() if "icon" in provider:
provider["icon"] = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider["type"], provider_name=provider["name"], icon=provider["icon"]
)
if provider.icon_dark:
provider.icon_dark = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon_dark
)
elif isinstance(provider, PluginDatasourceProviderEntity):
if provider.plugin_id:
if isinstance(provider.declaration.identity.icon, str):
provider.declaration.identity.icon = PluginService.get_plugin_icon_url(
tenant_id=tenant_id, filename=provider.declaration.identity.icon
case ToolProviderApiEntity():
if provider.plugin_id:
if isinstance(provider.icon, str):
provider.icon = PluginService.get_plugin_icon_url(tenant_id=tenant_id, filename=provider.icon)
if isinstance(provider.icon_dark, str) and provider.icon_dark:
provider.icon_dark = PluginService.get_plugin_icon_url(
tenant_id=tenant_id, filename=provider.icon_dark
)
else:
provider.icon = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon
)
if provider.icon_dark:
provider.icon_dark = ToolTransformService.get_tool_provider_icon_url(
provider_type=provider.type.value, provider_name=provider.name, icon=provider.icon_dark
)
case PluginDatasourceProviderEntity():
if provider.plugin_id:
if isinstance(provider.declaration.identity.icon, str):
provider.declaration.identity.icon = PluginService.get_plugin_icon_url(
tenant_id=tenant_id, filename=provider.declaration.identity.icon
)
@classmethod
def builtin_provider_to_user_provider(

View File

@ -13,6 +13,12 @@ from controllers.console.workspace.account import (
)
from models import Account, AccountStatus
from services.account_service import AccountService
from services.entities.auth_entities import (
ChangeEmailNewEmailToken,
ChangeEmailNewEmailVerifiedToken,
ChangeEmailOldEmailToken,
ChangeEmailOldEmailVerifiedToken,
)
@pytest.fixture
@ -39,7 +45,66 @@ def _set_logged_in_user(account: Account):
g._current_tenant = account.current_tenant
def _build_change_email_token(
phase: str,
*,
account_id: str = "acc",
email: str,
old_email: str,
code: str = "1234",
):
token_kwargs = {
"account_id": account_id,
"email": email,
"old_email": old_email,
"code": code,
}
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD:
return ChangeEmailOldEmailToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED:
return ChangeEmailOldEmailVerifiedToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW:
return ChangeEmailNewEmailToken(**token_kwargs)
if phase == AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED:
return ChangeEmailNewEmailVerifiedToken(**token_kwargs)
raise AssertionError(f"Unsupported phase for test helper: {phase}")
class TestChangeEmailSend:
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_old_email_phase_when_request_email_does_not_match_current_user(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_current_account,
mock_db,
app: Flask,
):
from controllers.console.auth.error import InvalidEmailError
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("current@example.com", "acc1"), None)
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "other@example.com", "language": "en-US", "phase": "old_email"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidEmailError):
ChangeEmailSendEmailApi().post()
mock_send_email.assert_not_called()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@ -63,10 +128,12 @@ class TestChangeEmailSend:
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
}
mock_get_change_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
account_id="acc1",
email="current@example.com",
old_email="current@example.com",
)
mock_send_email.return_value = "token-abc"
with app.test_request_context(
@ -79,7 +146,7 @@ class TestChangeEmailSend:
assert response == {"result": "success", "data": "token-abc"}
mock_send_email.assert_called_once_with(
account=None,
account=mock_account,
email="new@example.com",
old_email="current@example.com",
language="en-US",
@ -115,10 +182,12 @@ class TestChangeEmailSend:
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = {
"email": "current@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
mock_get_change_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD,
account_id="acc1",
email="current@example.com",
old_email="current@example.com",
)
with app.test_request_context(
"/account/change-email",
@ -131,6 +200,49 @@ class TestChangeEmailSend:
mock_send_email.assert_not_called()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.send_change_email_email")
@patch("controllers.console.workspace.account.AccountService.is_email_send_ip_limit", return_value=False)
@patch("controllers.console.workspace.account.extract_remote_ip", return_value="127.0.0.1")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_new_email_phase_when_token_account_id_does_not_match_current_user(
self,
mock_features,
mock_csrf,
mock_extract_ip,
mock_is_ip_limit,
mock_send_email,
mock_get_change_data,
mock_current_account,
mock_db,
app: Flask,
):
from controllers.console.auth.error import InvalidTokenError
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_account = _build_account("current@example.com", "acc1")
mock_current_account.return_value = (mock_account, None)
mock_get_change_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
account_id="other-account",
email="current@example.com",
old_email="current@example.com",
)
with app.test_request_context(
"/account/change-email",
method="POST",
json={"email": "new@example.com", "language": "en-US", "phase": "new_email", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailSendEmailApi().post()
mock_send_email.assert_not_called()
class TestChangeEmailValidity:
@patch("controllers.console.wraps.db")
@ -161,13 +273,13 @@ class TestChangeEmailValidity:
mock_account = _build_account("user@example.com", "acc2")
mock_current_account.return_value = (mock_account, None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "user@example.com",
"code": "1234",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
mock_generate_token.return_value = (None, "new-token")
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD,
account_id="acc2",
email="user@example.com",
old_email="user@example.com",
)
mock_generate_token.return_value = "new-token"
with app.test_request_context(
"/account/change-email/validity",
@ -182,12 +294,13 @@ class TestChangeEmailValidity:
mock_add_rate.assert_not_called()
mock_revoke_token.assert_called_once_with("token-123")
mock_generate_token.assert_called_once_with(
"user@example.com",
code="1234",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
},
_build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
account_id="acc2",
email="user@example.com",
old_email="user@example.com",
),
mock_account,
)
mock_reset_rate.assert_called_once_with("user@example.com")
mock_csrf.assert_called_once()
@ -219,13 +332,13 @@ class TestChangeEmailValidity:
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "new@example.com",
"code": "1234",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
}
mock_generate_token.return_value = (None, "new-verified-token")
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW,
account_id="acc",
email="new@example.com",
old_email="old@example.com",
)
mock_generate_token.return_value = "new-verified-token"
with app.test_request_context(
"/account/change-email/validity",
@ -237,12 +350,13 @@ class TestChangeEmailValidity:
assert response == {"is_valid": True, "email": "new@example.com", "token": "new-verified-token"}
mock_generate_token.assert_called_once_with(
"new@example.com",
code="1234",
old_email="old@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
},
_build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc",
email="new@example.com",
old_email="old@example.com",
),
mock_current_account.return_value[0],
)
@patch("controllers.console.wraps.db")
@ -255,7 +369,7 @@ class TestChangeEmailValidity:
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_validity_when_token_phase_is_unknown(
def test_should_reject_validity_when_token_is_already_verified(
self,
mock_features,
mock_csrf,
@ -269,23 +383,22 @@ class TestChangeEmailValidity:
mock_db,
app: Flask,
):
"""A token whose phase marker is a string but not a known transition must be rejected."""
from controllers.console.auth.error import InvalidTokenError
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "user@example.com",
"code": "1234",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: "something_else",
}
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD_VERIFIED,
account_id="acc",
email="old@example.com",
old_email="old@example.com",
)
with app.test_request_context(
"/account/change-email/validity",
method="POST",
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
json={"email": "old@example.com", "code": "1234", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
@ -304,7 +417,7 @@ class TestChangeEmailValidity:
@patch("controllers.console.workspace.account.AccountService.is_change_email_error_rate_limit")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_validity_when_token_has_no_phase(
def test_should_reject_validity_when_token_account_id_does_not_match_current_user(
self,
mock_features,
mock_csrf,
@ -318,22 +431,22 @@ class TestChangeEmailValidity:
mock_db,
app: Flask,
):
"""A token minted without a phase marker (e.g. a hand-crafted token) must not validate."""
from controllers.console.auth.error import InvalidTokenError
mock_features.return_value = SimpleNamespace(enable_change_email=True)
mock_current_account.return_value = (_build_account("old@example.com", "acc"), None)
mock_is_rate_limit.return_value = False
mock_get_data.return_value = {
"email": "user@example.com",
"code": "1234",
"old_email": "old@example.com",
}
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW,
account_id="other-account",
email="new@example.com",
old_email="old@example.com",
)
with app.test_request_context(
"/account/change-email/validity",
method="POST",
json={"email": "user@example.com", "code": "1234", "token": "token-123"},
json={"email": "new@example.com", "code": "1234", "token": "token-123"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
@ -373,11 +486,12 @@ class TestChangeEmailReset:
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"email": "new@example.com",
"old_email": "OLD@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc3",
email="new@example.com",
old_email="OLD@example.com",
)
mock_account_after_update = _build_account("new@example.com", "acc3-updated")
mock_update_account.return_value = mock_account_after_update
@ -428,13 +542,12 @@ class TestChangeEmailReset:
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
# Simulate a token straight out of step #1 (phase=old_email) — exactly
# the replay used in the advisory PoC.
mock_get_data.return_value = {
"email": "old@example.com",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_OLD,
}
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_OLD,
account_id="acc3",
email="old@example.com",
old_email="old@example.com",
)
with app.test_request_context(
"/account/change-email/reset",
@ -481,11 +594,12 @@ class TestChangeEmailReset:
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = {
"email": "verified@example.com",
"old_email": "old@example.com",
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc3",
email="verified@example.com",
old_email="old@example.com",
)
with app.test_request_context(
"/account/change-email/reset",
@ -500,6 +614,57 @@ class TestChangeEmailReset:
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
@patch("controllers.console.wraps.db")
@patch("controllers.console.workspace.account.current_account_with_tenant")
@patch("controllers.console.workspace.account.AccountService.send_change_email_completed_notify_email")
@patch("controllers.console.workspace.account.AccountService.update_account_email")
@patch("controllers.console.workspace.account.AccountService.revoke_change_email_token")
@patch("controllers.console.workspace.account.AccountService.get_change_email_data")
@patch("controllers.console.workspace.account.AccountService.check_email_unique")
@patch("controllers.console.workspace.account.AccountService.is_account_in_freeze")
@patch("libs.login.check_csrf_token", return_value=None)
@patch("controllers.console.wraps.FeatureService.get_system_features")
def test_should_reject_reset_when_token_account_id_does_not_match_current_user(
self,
mock_features,
mock_csrf,
mock_is_freeze,
mock_check_unique,
mock_get_data,
mock_revoke_token,
mock_update_account,
mock_send_notify,
mock_current_account,
mock_db,
app: Flask,
):
from controllers.console.auth.error import InvalidTokenError
mock_features.return_value = SimpleNamespace(enable_change_email=True)
current_user = _build_account("old@example.com", "acc3")
mock_current_account.return_value = (current_user, None)
mock_is_freeze.return_value = False
mock_check_unique.return_value = True
mock_get_data.return_value = _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="other-account",
email="new@example.com",
old_email="old@example.com",
)
with app.test_request_context(
"/account/change-email/reset",
method="POST",
json={"new_email": "new@example.com", "token": "token-verified"},
):
_set_logged_in_user(_build_account("tester@example.com", "tester"))
with pytest.raises(InvalidTokenError):
ChangeEmailResetApi().post()
mock_revoke_token.assert_not_called()
mock_update_account.assert_not_called()
mock_send_notify.assert_not_called()
class TestAccountServiceSendChangeEmailEmail:
"""Service-level coverage for the phase-bound changes in `send_change_email_email`."""
@ -507,7 +672,8 @@ class TestAccountServiceSendChangeEmailEmail:
def test_should_raise_value_error_for_invalid_phase(self):
with pytest.raises(ValueError, match="phase must be one of"):
AccountService.send_change_email_email(
email="user@example.com",
account=_build_account("old@example.com", "acc"),
email="new@example.com",
old_email="user@example.com",
phase="old_email_verified",
)
@ -515,33 +681,77 @@ class TestAccountServiceSendChangeEmailEmail:
@patch("services.account_service.send_change_mail_task")
@patch("services.account_service.AccountService.change_email_rate_limiter")
@patch("services.account_service.AccountService.generate_change_email_token")
def test_should_stamp_phase_into_generated_token(
def test_should_bind_account_id_and_target_email_into_generated_token(
self,
mock_generate_token,
mock_rate_limiter,
mock_mail_task,
):
mock_rate_limiter.is_rate_limited.return_value = False
mock_generate_token.return_value = ("123456", "the-token")
mock_generate_token.return_value = "the-token"
account = _build_account("old@example.com", "acc-123")
returned = AccountService.send_change_email_email(
email="user@example.com",
old_email="user@example.com",
account=account,
email="new@example.com",
old_email="old@example.com",
language="en-US",
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
)
assert returned == "the-token"
mock_generate_token.assert_called_once_with(
"user@example.com",
None,
old_email="user@example.com",
additional_data={
AccountService.CHANGE_EMAIL_TOKEN_PHASE_KEY: AccountService.CHANGE_EMAIL_PHASE_NEW,
},
_build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW,
account_id="acc-123",
email="new@example.com",
old_email="old@example.com",
code=mock_mail_task.delay.call_args.kwargs["code"],
),
account,
)
mock_mail_task.delay.assert_called_once()
mock_rate_limiter.increment_rate_limit.assert_called_once_with("user@example.com")
mock_mail_task.delay.assert_called_once_with(
language="en-US",
to="new@example.com",
code=mock_mail_task.delay.call_args.kwargs["code"],
phase=AccountService.CHANGE_EMAIL_PHASE_NEW,
)
mock_rate_limiter.increment_rate_limit.assert_called_once_with("new@example.com")
class TestAccountServiceGetChangeEmailData:
@patch("services.account_service.TokenManager.get_token_data")
def test_should_parse_change_email_token_into_discriminated_union_model(self, mock_get_token_data):
mock_get_token_data.return_value = {
"token_type": "change_email",
"account_id": "acc-1",
"email": "new@example.com",
"old_email": "old@example.com",
"code": "654321",
"email_change_phase": AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
}
token_data = AccountService.get_change_email_data("token-123")
assert token_data == _build_change_email_token(
AccountService.CHANGE_EMAIL_PHASE_NEW_VERIFIED,
account_id="acc-1",
email="new@example.com",
old_email="old@example.com",
code="654321",
)
@patch("services.account_service.TokenManager.get_token_data")
def test_should_reject_change_email_token_without_account_id(self, mock_get_token_data):
mock_get_token_data.return_value = {
"token_type": "change_email",
"email": "new@example.com",
"old_email": "old@example.com",
"code": "654321",
"email_change_phase": AccountService.CHANGE_EMAIL_PHASE_NEW,
}
assert AccountService.get_change_email_data("token-123") is None
class TestAccountDeletionFeedback:

View File

@ -388,6 +388,10 @@ class TestChangeEmailApis:
with (
app.test_request_context("/", json=payload),
patch(
"controllers.console.workspace.account.current_account_with_tenant",
return_value=(MagicMock(id="acc-1"), "t1"),
),
patch.object(
type(console_ns),
"payload",
@ -400,7 +404,11 @@ class TestChangeEmailApis:
),
patch(
"controllers.console.workspace.account.AccountService.get_change_email_data",
return_value={"email": "a@test.com", "code": "y"},
return_value=MagicMock(
email="a@test.com",
code="y",
is_bound_to_account=MagicMock(return_value=True),
),
),
):
with pytest.raises(EmailCodeError):

View File

@ -717,3 +717,129 @@ def test_get_files_in_folder_recurses_and_collects(generator):
)
assert {f["id"] for f in all_files} == {"f1", "f2"}
def test_get_files_in_folder_handles_empty_folder(generator):
"""An empty folder must return an empty file list without recursion errors."""
class FilesPage:
def __init__(self, files, is_truncated=False, next_page_parameters=None):
self.files = files
self.is_truncated = is_truncated
self.next_page_parameters = next_page_parameters
class Result:
def __init__(self, result):
self.result = result
class Runtime:
def datasource_provider_type(self):
return DatasourceProviderType.ONLINE_DRIVE
def online_drive_browse_files(self, user_id, request, provider_type):
# Empty folder: returns a page with no files, not truncated
return iter([Result([FilesPage([], False, None)])])
runtime = Runtime()
all_files: list = []
generator._get_files_in_folder(
datasource_runtime=runtime,
prefix="empty-folder",
bucket="b",
user_id="user",
all_files=all_files,
datasource_info={},
)
assert all_files == []
def test_get_files_in_folder_handles_empty_folder_with_false_truncation(generator):
"""An empty folder that incorrectly reports is_truncated=True must not recurse forever."""
call_count = 0
class FilesPage:
def __init__(self, files, is_truncated=False, next_page_parameters=None):
self.files = files
self.is_truncated = is_truncated
self.next_page_parameters = next_page_parameters
class Result:
def __init__(self, result):
self.result = result
class Runtime:
def datasource_provider_type(self):
return DatasourceProviderType.ONLINE_DRIVE
def online_drive_browse_files(self, user_id, request, provider_type):
nonlocal call_count
call_count += 1
# Empty folder that incorrectly claims truncation
return iter([Result([FilesPage([], True, {"page": 2})])])
runtime = Runtime()
all_files: list = []
generator._get_files_in_folder(
datasource_runtime=runtime,
prefix="buggy-folder",
bucket="b",
user_id="user",
all_files=all_files,
datasource_info={},
)
assert all_files == []
# Should only be called once -- the empty-page guard prevents further recursion
assert call_count == 1
def test_get_files_in_folder_handles_self_referencing_folder(generator):
"""A folder that lists itself as a child must not recurse infinitely."""
class File:
def __init__(self, id, name, type):
self.id = id
self.name = name
self.type = type
class FilesPage:
def __init__(self, files, is_truncated=False, next_page_parameters=None):
self.files = files
self.is_truncated = is_truncated
self.next_page_parameters = next_page_parameters
class Result:
def __init__(self, result):
self.result = result
call_count = 0
class Runtime:
def datasource_provider_type(self):
return DatasourceProviderType.ONLINE_DRIVE
def online_drive_browse_files(self, user_id, request, provider_type):
nonlocal call_count
call_count += 1
# The folder returns itself as a child (self-reference)
return iter([Result([FilesPage([File("self-ref", "myfolder", "folder")], False, None)])])
runtime = Runtime()
all_files: list = []
generator._get_files_in_folder(
datasource_runtime=runtime,
prefix="self-ref",
bucket="b",
user_id="user",
all_files=all_files,
datasource_info={},
)
assert all_files == []
# Should only be called once -- the visited-set guard prevents re-entry
assert call_count == 1

View File

@ -176,6 +176,48 @@ class TestStreamsBroadcastChannel:
assert topic.as_producer() is topic
assert topic.as_subscriber() is topic
def test_join_timeout_ms_propagates_from_channel_to_subscription(self, fake_redis: FakeStreamsRedis):
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=150)
topic = channel.topic("join-timeout-prop")
assert topic._join_timeout_ms == 150
sub = topic.subscribe()
try:
assert sub._join_timeout_ms == 150
finally:
sub.close()
def test_join_timeout_ms_defaults_to_2000(self, fake_redis: FakeStreamsRedis):
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60)
topic = channel.topic("join-timeout-default")
assert topic._join_timeout_ms == 2000
def test_small_join_timeout_makes_close_return_promptly(self, fake_redis: FakeStreamsRedis):
"""close() should respect the configured join timeout.
Regression test for SSE close tail latency: when an idle listener is
blocked on its poll cycle, close() with a small join_timeout_ms must
not wait for the full poll window. The orphaned daemon listener
cleans itself up later.
"""
channel = StreamsBroadcastChannel(fake_redis, retention_seconds=60, join_timeout_ms=50)
topic = channel.topic("join-timeout-prompt-close")
sub = topic.subscribe()
# Drive listener startup so the thread is actually blocked in xread.
assert sub.receive(timeout=0.05) is None
time.sleep(0.05)
started = time.monotonic()
sub.close()
elapsed = time.monotonic() - started
# 50ms timeout + scheduling slack; pick a ceiling well under the
# default poll window (1000ms) to make the regression meaningful.
assert elapsed < 0.5, f"close() took {elapsed:.3f}s; expected prompt return"
def test_publish_logs_warning_when_expire_fails(self, caplog: pytest.LogCaptureFixture):
channel = StreamsBroadcastChannel(FailExpireRedis(), retention_seconds=60)
topic = channel.topic("expire-warning")
@ -342,10 +384,17 @@ class TestStreamsSubscription:
assert next(iter(subscription)) == b"event"
def test_close_logs_warning_when_listener_does_not_stop_in_time(
def test_close_logs_debug_when_listener_does_not_stop_in_time(
self,
caplog: pytest.LogCaptureFixture,
):
"""When a low join_timeout elapses with the listener still alive,
close() should log at DEBUG (not WARNING) - with a deliberately small
timeout this is expected, not anomalous; the orphaned daemon thread
cleans itself up on the next poll boundary.
"""
import logging
blocking_redis = BlockingRedis()
subscription = _StreamsSubscription(blocking_redis, "stream:slow-close")
@ -363,8 +412,10 @@ class TestStreamsSubscription:
listener.is_alive = lambda: True # type: ignore[method-assign]
try:
subscription.close()
assert "did not stop within timeout" in caplog.text
with caplog.at_level(logging.DEBUG, logger="libs.broadcast_channel.redis.streams_channel"):
subscription.close()
assert "did not stop within" in caplog.text
assert "daemon thread will exit on its own" in caplog.text
finally:
listener.join = original_join # type: ignore[method-assign]
listener.is_alive = original_is_alive # type: ignore[method-assign]

View File

@ -1,58 +1,139 @@
"""
Regression tests for the `_TokenData` TypedDict used by
`libs.helper.TokenManager`.
Regression tests for `libs.helper.TokenManager`.
These tests guard the contract that every field a caller writes via
`generate_token` survives the TypedDict-validated round-trip performed
by `get_token_data`. Specifically, the `phase` field that the console
and web `forgot-password` + `change-email` controllers depend on for
the security check introduced in PR #35425 (GHSA-4q3w-q5mc-45rq) must
be preserved — otherwise downstream `if data.get("phase", "") != "reset"`
checks always fail with `InvalidTokenError`.
`TokenManager` is the storage primitive shared by multiple auth flows, so it
must preserve every metadata field written by the caller. Business-specific
validation now happens at the callsite boundary (for example,
`AccountService.get_change_email_data`), not inside `TokenManager`.
"""
import json
from types import SimpleNamespace
# pyright: reportPrivateUsage=false
from libs.helper import _token_data_adapter
import pytest
from pydantic import ValidationError
import libs.helper as helper_module
from libs.helper import TokenManager
def test_token_data_adapter_preserves_phase_field() -> None:
"""`phase` written by callers like generate_reset_password_token must
survive the TypedDict-validated round-trip in get_token_data.
def _build_fake_redis(storage: dict[str, str]):
def store_value(key: str, _ttl: int, value: str) -> bool:
storage[key] = value
return True
Regression: PR #34380 introduced `_TokenData` but did not list
`phase`, so the TypeAdapter silently dropped it and the security
gate from PR #35425 (GHSA-4q3w-q5mc-45rq) always failed.
"""
payload = {
"account_id": None,
"email": "user@example.com",
"token_type": "reset_password",
"code": "123456",
"phase": "reset",
}
data = dict(_token_data_adapter.validate_json(json.dumps(payload)))
def load_value(key: str) -> str | None:
return storage.get(key)
assert data.get("phase") == "reset", (
"phase field was stripped by the _TokenData TypedDict adapter; "
"the forgot-password phase-bound check (PR #35425) will always fail."
return SimpleNamespace(
setex=store_value,
get=load_value,
delete=lambda *_args, **_kwargs: None,
)
def test_token_data_adapter_preserves_change_email_payload() -> None:
"""Sanity round-trip for the change-email flow: every field set by
`generate_change_email_token` must come back, including the phase
string the controller branches on."""
payload = {
"account_id": "acc-1",
"email": "new@example.com",
"token_type": "change_email",
"code": "654321",
"old_email": "old@example.com",
"phase": "verify_old_email",
}
data = dict(_token_data_adapter.validate_json(json.dumps(payload)))
def test_token_manager_roundtrip_preserves_untyped_metadata_keys(monkeypatch: pytest.MonkeyPatch) -> None:
"""`TokenManager` must round-trip arbitrary metadata keys without silently
dropping fields such as `phase`, `email_change_phase`, or future auth
payload extensions.
"""
storage: dict[str, str] = {}
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
token = TokenManager.generate_token(
email="user@example.com",
token_type="change_email",
additional_data={
"code": "654321",
"old_email": "old@example.com",
"phase": "legacy-phase",
"email_change_phase": "old_email",
"custom_marker": "preserve-me",
},
)
data = TokenManager.get_token_data(token, "change_email")
assert data is not None
assert data.get("phase") == "legacy-phase"
assert data.get("email_change_phase") == "old_email"
assert data.get("custom_marker") == "preserve-me"
def test_token_manager_roundtrip_uses_explicit_email_with_account(monkeypatch: pytest.MonkeyPatch) -> None:
"""When both `account` and `email` are supplied, the token should bind the
stable `account_id` from the account and the target email from the explicit
email argument.
"""
storage: dict[str, str] = {}
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
account = SimpleNamespace(id="acc-1", email="old@example.com")
token = TokenManager.generate_token(
account=account,
email="new@example.com",
token_type="change_email",
additional_data={
"code": "654321",
"old_email": "old@example.com",
"email_change_phase": "new_email",
},
)
data = TokenManager.get_token_data(token, "change_email")
assert data is not None
assert data.get("account_id") == "acc-1"
assert data.get("email") == "new@example.com"
assert data.get("old_email") == "old@example.com"
assert data.get("phase") == "verify_old_email"
assert data.get("email_change_phase") == "new_email"
def test_token_manager_roundtrip_still_validates_declared_fields(monkeypatch: pytest.MonkeyPatch) -> None:
"""Unknown fields should be preserved, but declared baseline fields should
still be validated by `_token_data_adapter`.
"""
storage = {
"change_email:token:token-123": json.dumps(
{
"token_type": "change_email",
"account_id": "acc-1",
"email": ["not-a-string"],
"code": "654321",
"old_email": "old@example.com",
"email_change_phase": "old_email",
}
)
}
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
with pytest.raises(ValidationError):
TokenManager.get_token_data("token-123", "change_email")
def test_token_manager_roundtrip_validates_email_change_phase_as_string(monkeypatch: pytest.MonkeyPatch) -> None:
"""`email_change_phase` is part of the shared baseline schema, so obviously
malformed discriminator values should fail before the change-email-specific
union parsing at the callsite boundary.
"""
storage = {
"change_email:token:token-456": json.dumps(
{
"token_type": "change_email",
"account_id": "acc-1",
"email": "new@example.com",
"code": "654321",
"old_email": "old@example.com",
"email_change_phase": ["not-a-string"],
}
)
}
monkeypatch.setattr(helper_module, "redis_client", _build_fake_redis(storage))
with pytest.raises(ValidationError):
TokenManager.get_token_data("token-456", "change_email")

186
api/uv.lock generated
View File

@ -51,8 +51,8 @@ members = [
"dify-vdb-weaviate",
]
overrides = [
{ name = "litellm", specifier = ">=1.83.7" },
{ name = "pyarrow", specifier = ">=18.0.0" },
{ name = "litellm", specifier = ">=1.83.10,<2.0.0" },
{ name = "pyarrow", specifier = ">=23.0.1,<24.0.0" },
]
[[package]]
@ -423,7 +423,7 @@ wheels = [
[[package]]
name = "azure-storage-blob"
version = "12.28.0"
version = "12.29.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core" },
@ -431,9 +431,9 @@ dependencies = [
{ name = "isodate" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/71/24/072ba8e27b0e2d8fec401e9969b429d4f5fc4c8d4f0f05f4661e11f7234a/azure_storage_blob-12.28.0.tar.gz", hash = "sha256:e7d98ea108258d29aa0efbfd591b2e2075fa1722a2fae8699f0b3c9de11eff41", size = 604225, upload-time = "2026-01-06T23:48:57.282Z" }
sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/3a/6ef2047a072e54e1142718d433d50e9514c999a58f51abfff7902f3a72f8/azure_storage_blob-12.28.0-py3-none-any.whl", hash = "sha256:00fb1db28bf6a7b7ecaa48e3b1d5c83bfadacc5a678b77826081304bd87d6461", size = 431499, upload-time = "2026-01-06T23:48:58.995Z" },
{ url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823, upload-time = "2026-05-15T03:35:01.837Z" },
]
[[package]]
@ -595,29 +595,29 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.6"
version = "1.43.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0a/37/78c630d1308964aa9abf44951d9c4df776546ff37251ec2434944e205c4e/boto3-1.43.6.tar.gz", hash = "sha256:e6315effaf12b890b99956e6f8e2c3000a3f64e4ee91943cec3895ce9a836afb", size = 113153, upload-time = "2026-05-07T20:49:59.694Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ff/27/ae1a71e945ce7bde39b0677b252fe7d8a0ad7fa3d6b724d78b81469c08fe/boto3-1.43.10.tar.gz", hash = "sha256:27342e5d5f6170fcc8d1e21cdd939af2448d58ac56b08d494250eaad998e30c7", size = 113159, upload-time = "2026-05-18T20:42:34.454Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/e2/3c2eef44f55eafab256836d1d9479bd6a74f70c26cbfdc0639a0e23e4327/boto3-1.43.6-py3-none-any.whl", hash = "sha256:179601ec2992726a718053bf41e43c223ceba397d31ceab11f64d9c910d9fc3a", size = 140502, upload-time = "2026-05-07T20:49:57.8Z" },
{ url = "https://files.pythonhosted.org/packages/0e/1b/439234598449f846b17333e67ec63c3dd8f8880c13de9089383b4bab58c3/boto3-1.43.10-py3-none-any.whl", hash = "sha256:83918184d95967e4c6e9ed1e9a2f58250b291e6ea2cb847ab0825d52596b39e5", size = 140534, upload-time = "2026-05-18T20:42:32.009Z" },
]
[[package]]
name = "boto3-stubs"
version = "1.43.2"
version = "1.43.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore-stubs" },
{ name = "types-s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8a/7f/399bcdeaa60a89aafe5292c8364c313177d22b886dffc1bd7b56fe817900/boto3_stubs-1.43.2.tar.gz", hash = "sha256:0d46636f3e761a92070114b39a76b154c5da6c5794c890e1440a7f191bf1ff2e", size = 102658, upload-time = "2026-05-01T20:31:36.963Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/67/969b5bc0581ef6f22b2371bdc33d66a50fe907993e0528cb34a7f3fa59f7/boto3_stubs-1.43.10.tar.gz", hash = "sha256:05575658b827e7a12a45629e1a987317e1e679dccabe5ebde5a2101528c76cf8", size = 102665, upload-time = "2026-05-18T20:47:00.528Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/da/df/17647562444b2047ca325eaaf2fea738571822b7b4efdaa6bacf0fd4fff9/boto3_stubs-1.43.2-py3-none-any.whl", hash = "sha256:941f2907236223a1209704eaf708d3cdf1ecc8695618c558f9fb9e23e90c513b", size = 70653, upload-time = "2026-05-01T20:31:30.057Z" },
{ url = "https://files.pythonhosted.org/packages/2b/42/90eb347eda878b15def1f0bb0971779b1829a0fc6b9c0fe3ff823de09833/boto3_stubs-1.43.10-py3-none-any.whl", hash = "sha256:0ce5dec421a45ef1b8ddd32e68d0b8bb78ea7447c5ddf8231420ccaa6e71e0f5", size = 70667, upload-time = "2026-05-18T20:46:54.359Z" },
]
[package.optional-dependencies]
@ -627,16 +627,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
version = "1.43.6"
version = "1.43.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/a7/23d0f5028011455096a1eeac0ddf3cbe147b3e855e127342f8202552194d/botocore-1.43.6.tar.gz", hash = "sha256:b1e395b347356860398da42e61c808cf1e34b6fa7180cf2b9d87d986e1a06ba0", size = 15336070, upload-time = "2026-05-07T20:49:48.14Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e2/4e/c127dd0628c551f10cb890e279a9c0e367523b880c4cd3e81a1e76886174/botocore-1.43.10.tar.gz", hash = "sha256:2f4af585b41dbccdfc9f49677d7bd72d713a12ef89a1dc9c8538a927649498bf", size = 15365344, upload-time = "2026-05-18T20:42:21.562Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/c8/6f47223840e8d8cfa8c9f7c0ec1b77970417f257fc885169ff4f6326ce09/botocore-1.43.6-py3-none-any.whl", hash = "sha256:b6d1fdbc6f65a5fe0b7e947823aa37535d3f39f3ba4d21110fab1f55bbbcc04b", size = 15017094, upload-time = "2026-05-07T20:49:44.964Z" },
{ url = "https://files.pythonhosted.org/packages/a6/0e/41f64d6c267edf03f4fe8f461edc4c644243e77c8d5a1fef1e0166ac4ed0/botocore-1.43.10-py3-none-any.whl", hash = "sha256:8a0176d8c2f8bebe95d4f923a824a1ace04b02f360e220681c388e097f32c3b6", size = 15043571, upload-time = "2026-05-18T20:42:16.664Z" },
]
[[package]]
@ -1049,7 +1049,7 @@ wheels = [
[[package]]
name = "cos-python-sdk-v5"
version = "1.9.42"
version = "1.9.43"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
@ -1058,9 +1058,9 @@ dependencies = [
{ name = "six" },
{ name = "xmltodict" },
]
sdist = { url = "https://files.pythonhosted.org/packages/40/e3/b903b4acde334510f481d126a686bc4013710c00e2af34bff369511329ac/cos_python_sdk_v5-1.9.42.tar.gz", hash = "sha256:2a01d1868f50c5a70771f2b67da868f1dc6c6f3890f8009715313834404decc4", size = 102670, upload-time = "2026-04-23T11:08:27.949Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/73/3d5321fa6c0fe14ababd5e4a8d02941785b54a9b1ba4e99336b227cba223/cos_python_sdk_v5-1.9.43.tar.gz", hash = "sha256:ff661561686356f4cff02af03a63eca27607edef2edd233f9cdcd1ca2125357b", size = 103216, upload-time = "2026-05-13T12:01:53.765Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/bf/4ea660bb79d91fd41ba394605eccffd3d0943ed547b3fe2bdc6c7a52d2d1/cos_python_sdk_v5-1.9.42-py3-none-any.whl", hash = "sha256:02e583a1094e1794e6c0f56618d5190eb9eb7bfe75909f1dfac41bbee46e46c5", size = 98375, upload-time = "2026-04-23T11:05:14.519Z" },
{ url = "https://files.pythonhosted.org/packages/c6/dd/b6cbe0ddd04c0543195e089bd962f5e890218e621dfb652781997860eda5/cos_python_sdk_v5-1.9.43-py3-none-any.whl", hash = "sha256:2623db720d9d1aac01faf5ad5a422008a4a0475a852c8413a56b0a8415f647aa", size = 98826, upload-time = "2026-05-13T12:01:51.383Z" },
]
[[package]]
@ -1291,17 +1291,17 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=5" },
{ name = "typing-extensions", specifier = ">=4.12.2" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
]
provides-extras = ["server"]
@ -1609,51 +1609,51 @@ vdb-xinference = [
[package.metadata]
requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = ">=0.9.44,<1.0.0" },
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.3.0" },
{ name = "boto3", specifier = ">=1.43.6" },
{ name = "celery", specifier = ">=5.6.3" },
{ name = "croniter", specifier = ">=6.2.2" },
{ name = "bleach", specifier = ">=6.3.0,<7.0.0" },
{ name = "boto3", specifier = ">=1.43.10,<2.0.0" },
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
{ name = "dify-agent", directory = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "~=0.7.0" },
{ name = "fastopenapi", extras = ["flask"], specifier = "==0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
{ name = "flask-cors", specifier = ">=6.0.2" },
{ name = "flask-login", specifier = ">=0.6.3,<1.0.0" },
{ name = "flask-cors", specifier = ">=6.0.2,<7.0.0" },
{ name = "flask-login", specifier = "==0.6.3" },
{ name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" },
{ name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" },
{ name = "flask-restx", specifier = ">=1.3.2,<2.0.0" },
{ name = "gevent", specifier = ">=26.4.0" },
{ name = "gevent-websocket", specifier = ">=0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0" },
{ name = "gevent", specifier = ">=26.4.0,<26.5.0" },
{ name = "gevent-websocket", specifier = "==0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
{ name = "graphon", specifier = "~=0.4.0" },
{ name = "gunicorn", specifier = ">=26.0.0" },
{ name = "httpx", extras = ["socks"], specifier = ">=0.28.1,<1.0.0" },
{ name = "httpx-sse", specifier = "~=0.4.0" },
{ name = "json-repair", specifier = "~=0.59.4" },
{ name = "opentelemetry-distro", specifier = ">=0.62b1,<1.0.0" },
{ name = "opentelemetry-instrumentation-celery", specifier = ">=0.62b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-flask", specifier = ">=0.62b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-httpx", specifier = ">=0.62b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-redis", specifier = ">=0.62b0,<1.0.0" },
{ name = "opentelemetry-instrumentation-sqlalchemy", specifier = ">=0.62b0,<1.0.0" },
{ name = "graphon", specifier = "==0.4.0" },
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
{ name = "httpx-sse", specifier = "==0.4.3" },
{ name = "json-repair", specifier = "==0.59.4" },
{ name = "opentelemetry-distro", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-celery", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-flask", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-httpx", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-redis", specifier = "==0.62b1" },
{ name = "opentelemetry-instrumentation-sqlalchemy", specifier = "==0.62b1" },
{ name = "opentelemetry-propagator-b3", specifier = ">=1.41.1,<2.0.0" },
{ name = "psycogreen", specifier = ">=1.0.2" },
{ name = "psycopg2-binary", specifier = ">=2.9.12" },
{ name = "python-socketio", specifier = ">=5.13.0" },
{ name = "readabilipy", specifier = ">=0.3.0,<1.0.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0" },
{ name = "psycogreen", specifier = ">=1.0.2,<2.0.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.12,<3.0.0" },
{ name = "python-socketio", specifier = ">=5.13.0,<6.0.0" },
{ name = "readabilipy", specifier = "==0.3.0" },
{ name = "redis", extras = ["hiredis"], specifier = ">=7.4.0,<8.0.0" },
{ name = "resend", specifier = ">=2.27.0,<3.0.0" },
{ name = "sendgrid", specifier = ">=6.12.5" },
{ name = "sseclient-py", specifier = ">=1.8.0" },
{ name = "sendgrid", specifier = ">=6.12.5,<7.0.0" },
{ name = "sseclient-py", specifier = ">=1.8.0,<2.0.0" },
]
[package.metadata.requires-dev]
dev = [
{ name = "boto3-stubs", specifier = ">=1.43.2" },
{ name = "boto3-stubs", specifier = ">=1.43.10" },
{ name = "celery-types", specifier = ">=0.23.0" },
{ name = "coverage", specifier = ">=7.13.4" },
{ name = "dotenv-linter", specifier = ">=0.7.0" },
@ -1716,19 +1716,19 @@ dev = [
{ name = "xinference-client", specifier = ">=2.7.0" },
]
storage = [
{ name = "azure-storage-blob", specifier = ">=12.28.0" },
{ name = "bce-python-sdk", specifier = ">=0.9.71" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.42" },
{ name = "esdk-obs-python", specifier = ">=3.22.2" },
{ name = "google-cloud-storage", specifier = ">=3.10.1" },
{ name = "opendal", specifier = ">=0.46.0" },
{ name = "oss2", specifier = ">=2.19.1" },
{ name = "supabase", specifier = ">=2.30.0" },
{ name = "tos", specifier = ">=2.9.0" },
{ name = "azure-storage-blob", specifier = ">=12.29.0,<13.0.0" },
{ name = "bce-python-sdk", specifier = "==0.9.71" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.43,<2.0.0" },
{ name = "esdk-obs-python", specifier = ">=3.22.2,<4.0.0" },
{ name = "google-cloud-storage", specifier = ">=3.10.1,<4.0.0" },
{ name = "opendal", specifier = "==0.46.0" },
{ name = "oss2", specifier = ">=2.19.1,<3.0.0" },
{ name = "supabase", specifier = ">=2.30.0,<3.0.0" },
{ name = "tos", specifier = ">=2.9.0,<3.0.0" },
]
tools = [
{ name = "cloudscraper", specifier = ">=1.2.71" },
{ name = "nltk", specifier = ">=3.9.1" },
{ name = "cloudscraper", specifier = ">=1.2.71,<2.0.0" },
{ name = "nltk", specifier = ">=3.9.1,<4.0.0" },
]
trace-aliyun = [{ name = "dify-trace-aliyun", editable = "providers/trace/trace-aliyun" }]
trace-all = [
@ -1810,7 +1810,7 @@ vdb-upstash = [{ name = "dify-vdb-upstash", editable = "providers/vdb/vdb-upstas
vdb-vastbase = [{ name = "dify-vdb-vastbase", editable = "providers/vdb/vdb-vastbase" }]
vdb-vikingdb = [{ name = "dify-vdb-vikingdb", editable = "providers/vdb/vdb-vikingdb" }]
vdb-weaviate = [{ name = "dify-vdb-weaviate", editable = "providers/vdb/vdb-weaviate" }]
vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0" }]
vdb-xinference = [{ name = "xinference-client", specifier = ">=2.7.0,<3.0.0" }]
[[package]]
name = "dify-trace-aliyun"
@ -1840,7 +1840,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "arize-phoenix-otel", specifier = "~=0.15.0" }]
requires-dist = [{ name = "arize-phoenix-otel", specifier = "==0.15.0" }]
[[package]]
name = "dify-trace-langfuse"
@ -1862,7 +1862,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "langsmith", specifier = "~=0.7.30" }]
requires-dist = [{ name = "langsmith", specifier = "==0.8.5" }]
[[package]]
name = "dify-trace-mlflow"
@ -1873,7 +1873,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "mlflow-skinny", specifier = ">=3.11.1" }]
requires-dist = [{ name = "mlflow-skinny", specifier = ">=3.11.1,<4.0.0" }]
[[package]]
name = "dify-trace-opik"
@ -1914,7 +1914,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "weave", specifier = ">=0.52.36" }]
requires-dist = [{ name = "weave", specifier = "==0.52.36" }]
[[package]]
name = "dify-vdb-alibabacloud-mysql"
@ -1925,7 +1925,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "mysql-connector-python", specifier = ">=9.3.0" }]
requires-dist = [{ name = "mysql-connector-python", specifier = ">=9.3.0,<10.0.0" }]
[[package]]
name = "dify-vdb-analyticdb"
@ -1940,8 +1940,8 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "alibabacloud-gpdb20160503", specifier = "~=5.2.0" },
{ name = "alibabacloud-tea-openapi", specifier = "~=0.4.3" },
{ name = "clickhouse-connect", specifier = "~=0.15.0" },
{ name = "alibabacloud-tea-openapi", specifier = "==0.4.4" },
{ name = "clickhouse-connect", specifier = "==0.15.1" },
]
[[package]]
@ -1975,7 +1975,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "clickzetta-connector-python", specifier = ">=0.8.102" }]
requires-dist = [{ name = "clickzetta-connector-python", specifier = "==0.8.104" }]
[[package]]
name = "dify-vdb-couchbase"
@ -2008,7 +2008,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "holo-search-sdk", specifier = ">=0.4.2" }]
requires-dist = [{ name = "holo-search-sdk", specifier = "==0.4.2" }]
[[package]]
name = "dify-vdb-huawei-cloud"
@ -2030,7 +2030,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "intersystems-irispython", specifier = ">=5.1.0" }]
requires-dist = [{ name = "intersystems-irispython", specifier = ">=5.1.0,<6.0.0" }]
[[package]]
name = "dify-vdb-lindorm"
@ -2044,7 +2044,7 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "opensearch-py", specifier = "==3.1.0" },
{ name = "tenacity", specifier = ">=8.0.0" },
{ name = "tenacity", specifier = ">=8.0.0,<9.0.0" },
]
[[package]]
@ -2056,7 +2056,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "mo-vector", specifier = "~=0.1.13" }]
requires-dist = [{ name = "mo-vector", specifier = "==0.1.13" }]
[[package]]
name = "dify-vdb-milvus"
@ -2078,7 +2078,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "clickhouse-connect", specifier = "~=0.15.0" }]
requires-dist = [{ name = "clickhouse-connect", specifier = "==0.15.1" }]
[[package]]
name = "dify-vdb-oceanbase"
@ -2091,8 +2091,8 @@ dependencies = [
[package.metadata]
requires-dist = [
{ name = "mysql-connector-python", specifier = ">=9.3.0" },
{ name = "pyobvector", specifier = "~=0.2.17" },
{ name = "mysql-connector-python", specifier = ">=9.3.0,<10.0.0" },
{ name = "pyobvector", specifier = "==0.2.25" },
]
[[package]]
@ -2131,7 +2131,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "~=0.2.2" }]
requires-dist = [{ name = "pgvecto-rs", extras = ["sqlalchemy"], specifier = "==0.2.2" }]
[[package]]
name = "dify-vdb-pgvector"
@ -2224,7 +2224,7 @@ dependencies = [
]
[package.metadata]
requires-dist = [{ name = "pyobvector", specifier = "~=0.2.17" }]
requires-dist = [{ name = "pyobvector", specifier = "==0.2.25" }]
[[package]]
name = "dify-vdb-vikingdb"
@ -3660,7 +3660,7 @@ wheels = [
[[package]]
name = "langsmith"
version = "0.7.38"
version = "0.8.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "httpx" },
@ -3673,9 +3673,9 @@ dependencies = [
{ name = "xxhash" },
{ name = "zstandard" },
]
sdist = { url = "https://files.pythonhosted.org/packages/af/c9/b3e54cfcb480876dfe33ecfdd64feeb621a86d9e6f4a6b9eb46851807018/langsmith-0.7.38.tar.gz", hash = "sha256:0db529b768d66c45f22fe959a0af7151342704fefafdecf3c60b14097c14fdb1", size = 4431914, upload-time = "2026-04-29T00:21:42.865Z" }
sdist = { url = "https://files.pythonhosted.org/packages/17/eb/8883d1158c743d0aac350f09df7880714d27283497e8c80bb9fe3480f165/langsmith-0.8.5.tar.gz", hash = "sha256:3615243d99c12f4047f13042bdc05a373dce232d106a6511b3ca7b48c5af1c2c", size = 4462348, upload-time = "2026-05-15T21:31:41.093Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/86/bc/a19d0a6d5575c637796675831dbef3555568e84d913f14ec579f92162ffa/langsmith-0.7.38-py3-none-any.whl", hash = "sha256:9c400ad508c0e4edc37bd55987047c6b8aac36ddd55f6096e3806f4d6a100618", size = 392310, upload-time = "2026-04-29T00:21:40.534Z" },
{ url = "https://files.pythonhosted.org/packages/23/85/968c88a63e32a59b3e5c68afd2fe114ce0708a125db0be1a85efc25fb2ea/langsmith-0.8.5-py3-none-any.whl", hash = "sha256:efc779f9d450dcaf9d97bc8894f4926276509d6e730e05289af9a64debce06ae", size = 399564, upload-time = "2026-05-15T21:31:39.046Z" },
]
[[package]]
@ -6443,11 +6443,11 @@ wheels = [
[[package]]
name = "tenacity"
version = "9.1.2"
version = "8.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/d4/2b0cd0fe285e14b36db076e78c93766ff1d529d70408bd1d2a5a84f1d929/tenacity-9.1.2.tar.gz", hash = "sha256:1169d376c297e7de388d18b4481760d478b0e99a777cad3a9c86e556f4b697cb", size = 48036, upload-time = "2025-04-02T08:25:09.966Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/30/643397144bfbfec6f6ef821f36f33e57d35946c44a2352d3c9f0ae847619/tenacity-9.1.2-py3-none-any.whl", hash = "sha256:f77bf36710d8b73a50b2dd155c97b870017ad21afe6ab300326b0371b3b05138", size = 28248, upload-time = "2025-04-02T08:25:07.678Z" },
{ url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" },
]
[[package]]

View File

@ -3,23 +3,23 @@ name = "dify-agent"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.12"
requires-python = ">=3.12,<4.0"
dependencies = [
"httpx>=0.28.1",
"httpx==0.28.1",
"pydantic>=2.12.5,<2.13",
"pydantic-ai-slim>=1.85.1",
"typing-extensions>=4.12.2",
"pydantic-ai-slim>=1.85.1,<2.0.0",
"typing-extensions>=4.12.2,<5.0.0",
]
[project.optional-dependencies]
server = [
"fastapi>=0.136.0",
"graphon~=0.2.2",
"jsonschema>=4.23.0",
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1",
"pydantic-settings>=2.12.0",
"redis>=5",
"uvicorn[standard]>=0.38.0",
"fastapi==0.136.0",
"graphon==0.2.2",
"jsonschema>=4.23.0,<5.0.0",
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
"pydantic-settings>=2.12.0,<3.0.0",
"redis>=7.4.0,<8.0.0",
"uvicorn[standard]==0.46.0",
]
[tool.setuptools.packages.find]

22
dify-agent/uv.lock generated
View File

@ -1,6 +1,6 @@
version = 1
revision = 3
requires-python = ">=3.12"
requires-python = ">=3.12, <4.0"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform == 'emscripten'",
@ -610,17 +610,17 @@ docs = [
[package.metadata]
requires-dist = [
{ name = "fastapi", marker = "extra == 'server'", specifier = ">=0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "~=0.2.2" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0" },
{ name = "fastapi", marker = "extra == 'server'", specifier = "==0.136.0" },
{ name = "graphon", marker = "extra == 'server'", specifier = "==0.2.2" },
{ name = "httpx", specifier = "==0.28.1" },
{ name = "jsonschema", marker = "extra == 'server'", specifier = ">=4.23.0,<5.0.0" },
{ name = "pydantic", specifier = ">=2.12.5,<2.13" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=5" },
{ name = "typing-extensions", specifier = ">=4.12.2" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = ">=0.38.0" },
{ name = "pydantic-ai-slim", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
]
provides-extras = ["server"]

View File

@ -1,7 +1,6 @@
# ------------------------------------------------------------------
# Essential defaults for Docker Compose deployments.
# Only include variables required for services to start.
# Do not add optional variables to this file.
#
# For a default deployment, copy this file to .env and run:
# docker compose up -d
@ -119,6 +118,7 @@ CELERY_TASK_ANNOTATIONS=null
EVENT_BUS_REDIS_URL=
EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
EVENT_BUS_REDIS_USE_CLUSTERS=false
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
# Web and app limits
WEB_API_CORS_ALLOW_ORIGINS=*

View File

@ -625,8 +625,6 @@ services:
REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}
networks:
- ssrf_proxy_network
- default

View File

@ -215,8 +215,6 @@ services:
REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}
ports:
- "${EXPOSE_SSRF_PROXY_PORT:-3128}:${SSRF_HTTP_PORT:-3128}"
- "${EXPOSE_SANDBOX_PORT:-8194}:${SANDBOX_PORT:-8194}"

View File

@ -631,8 +631,6 @@ services:
REVERSE_PROXY_PORT: ${SSRF_REVERSE_PROXY_PORT:-8194}
SANDBOX_HOST: ${SSRF_SANDBOX_HOST:-sandbox}
SANDBOX_PORT: ${SANDBOX_PORT:-8194}
SSRF_PROXY_ALLOW_PRIVATE_IPS: ${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS: ${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}
networks:
- ssrf_proxy_network
- default

View File

@ -473,3 +473,6 @@ MILVUS_ENABLE_HYBRID_SEARCH=False
# Human Input Task Configuration
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
# uv cache dir
UV_CACHE_DIR=/tmp/uv_cache

View File

@ -8,8 +8,6 @@ SSRF_HTTP_PORT=3128
SSRF_COREDUMP_DIR=/var/spool/squid
SSRF_REVERSE_PROXY_PORT=8194
SSRF_SANDBOX_HOST=sandbox
SSRF_PROXY_ALLOW_PRIVATE_IPS=
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=
SSRF_DEFAULT_TIME_OUT=5
SSRF_DEFAULT_CONNECT_TIME_OUT=5
SSRF_DEFAULT_READ_TIME_OUT=5

View File

@ -113,8 +113,6 @@ SSRF_HTTP_PORT=3128
SSRF_COREDUMP_DIR=/var/spool/squid
SSRF_REVERSE_PROXY_PORT=8194
SSRF_SANDBOX_HOST=sandbox
SSRF_PROXY_ALLOW_PRIVATE_IPS=
SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=
# ------------------------------
# Environment Variables for weaviate Service
@ -242,4 +240,4 @@ LOGSTORE_DUAL_READ_ENABLED=true
# Control flag for whether to write the `graph` field to LogStore.
# If LOGSTORE_ENABLE_PUT_GRAPH_FIELD is "true", write the full `graph` field;
# otherwise write an empty {} instead. Defaults to writing the `graph` field.
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true
LOGSTORE_ENABLE_PUT_GRAPH_FIELD=true

View File

@ -26,34 +26,6 @@ tail -F /var/log/squid/error.log 2>/dev/null &
tail -F /var/log/squid/store.log 2>/dev/null &
tail -F /var/log/squid/cache.log 2>/dev/null &
ALLOW_PRIVATE_CONF=/etc/squid/dify_allow_private.conf
write_optional_private_allowlist() {
local env_name="$1"
local acl_name="$2"
local acl_type="$3"
local raw_values="${!env_name:-}"
raw_values="${raw_values//,/ }"
if [ -z "${raw_values//[[:space:]]/}" ]; then
return
fi
printf 'acl %s %s' "$acl_name" "$acl_type" >> "$ALLOW_PRIVATE_CONF"
for value in $raw_values; do
printf ' %s' "$value" >> "$ALLOW_PRIVATE_CONF"
done
printf '\nhttp_access allow client_localnet %s\n' "$acl_name" >> "$ALLOW_PRIVATE_CONF"
}
{
echo "# Generated by docker-entrypoint.sh."
echo "# Allows selected private targets before the default private-network deny rule."
} > "$ALLOW_PRIVATE_CONF"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_IPS" "dify_allowed_private_networks" "dst"
write_optional_private_allowlist "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS" "dify_allowed_private_domains" "dstdomain"
# Replace environment variables in the template and output to the squid.conf
echo "[ENTRYPOINT] replacing environment variables in the template"
awk '{

View File

@ -1,23 +1,11 @@
acl client_localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN)
acl client_localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN)
acl client_localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN)
acl client_localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines
acl client_localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN)
acl client_localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN)
acl client_localnet src fc00::/7 # RFC 4193 local private network range
acl client_localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines
acl to_private_networks dst 0.0.0.0/32
acl to_private_networks dst 10.0.0.0/8
acl to_private_networks dst 100.64.0.0/10
acl to_private_networks dst 127.0.0.0/8
acl to_private_networks dst 169.254.0.0/16
acl to_private_networks dst 172.16.0.0/12
acl to_private_networks dst 192.168.0.0/16
acl to_private_networks dst 224.0.0.0/4
acl to_private_networks dst 240.0.0.0/4
acl to_private_networks dst ::1/128
acl to_private_networks dst fc00::/7
acl to_private_networks dst fe80::/10
acl localnet src 0.0.0.1-0.255.255.255 # RFC 1122 "this" network (LAN)
acl localnet src 10.0.0.0/8 # RFC 1918 local private network (LAN)
acl localnet src 100.64.0.0/10 # RFC 6598 shared address space (CGN)
acl localnet src 169.254.0.0/16 # RFC 3927 link-local (directly plugged) machines
acl localnet src 172.16.0.0/12 # RFC 1918 local private network (LAN)
acl localnet src 192.168.0.0/16 # RFC 1918 local private network (LAN)
acl localnet src fc00::/7 # RFC 4193 local private network range
acl localnet src fe80::/10 # RFC 4291 link-local (directly plugged) machines
acl SSL_ports port 443
# acl SSL_ports port 1025-65535 # Enable the configuration to resolve this issue: https://github.com/langgenius/dify/issues/12792
acl Safe_ports port 80 # http
@ -31,27 +19,19 @@ acl Safe_ports port 488 # gss-http
acl Safe_ports port 591 # filemaker
acl Safe_ports port 777 # multiling http
acl CONNECT method CONNECT
acl sandbox_reverse_proxy_port localport ${REVERSE_PROXY_PORT}
acl allowed_domains dstdomain .marketplace.dify.ai
http_port ${HTTP_PORT}
http_port ${REVERSE_PROXY_PORT} accel vhost
cache_peer ${SANDBOX_HOST} parent ${SANDBOX_PORT} 0 no-query originserver
http_access allow allowed_domains
http_access deny !Safe_ports
http_access deny CONNECT !SSL_ports
http_access allow localhost manager
http_access deny manager
http_access allow sandbox_reverse_proxy_port
include /etc/squid/dify_allow_private.conf
http_access deny to_private_networks
http_access allow allowed_domains
http_access allow client_localnet
http_access allow localhost
include /etc/squid/conf.d/*.conf
http_access deny all
tcp_outgoing_address 0.0.0.0
################################## Proxy Server ################################
http_port ${HTTP_PORT}
coredump_dir ${COREDUMP_DIR}
refresh_pattern ^ftp: 1440 20% 10080
refresh_pattern ^gopher: 1440 0% 1440
@ -68,6 +48,10 @@ refresh_pattern . 0 20% 4320
# cache_peer 172.1.1.1 parent 3128 0 no-query no-digest no-netdb-exchange default
################################## Reverse Proxy To Sandbox ################################
http_port ${REVERSE_PROXY_PORT} accel vhost
cache_peer ${SANDBOX_HOST} parent ${SANDBOX_PORT} 0 no-query originserver
acl src_all src all
http_access allow src_all
# Unless the option's size is increased, an error will occur when uploading more than two files.
client_request_buffer_max_size 100 MB
@ -119,3 +103,4 @@ access_log daemon:/var/log/squid/access.log dify_log
# Access log to track concurrent requests and timeouts
logfile_rotate 10

View File

@ -1,143 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
IMAGE="${SSRF_PROXY_TEST_IMAGE:-ubuntu/squid:latest}"
CLIENT_IMAGE="${SSRF_PROXY_TEST_CLIENT_IMAGE:-busybox:latest}"
CONTAINER_NAME="${SSRF_PROXY_TEST_CONTAINER:-dify-ssrf-proxy-test-$$}"
SANDBOX_CONTAINER_NAME="${CONTAINER_NAME}-sandbox"
NETWORK_NAME="${SSRF_PROXY_TEST_NETWORK:-dify-ssrf-proxy-test-$$}"
RUN_PUBLIC_CHECK="${SSRF_PROXY_TEST_PUBLIC_CHECK:-true}"
cleanup() {
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true
docker rm -f "$SANDBOX_CONTAINER_NAME" >/dev/null 2>&1 || true
docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true
}
http_code_for() {
local proxy_url="$1"
local target_url="$2"
local output
output="$(
docker run \
--rm \
--network "$NETWORK_NAME" \
--env "http_proxy=$proxy_url" \
--env "https_proxy=$proxy_url" \
"$CLIENT_IMAGE" \
wget -S -O /dev/null -T 10 "$target_url" 2>&1 || true
)"
printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }'
}
direct_http_code_for() {
local target_url="$1"
local output
output="$(
docker run \
--rm \
--network "$NETWORK_NAME" \
"$CLIENT_IMAGE" \
wget -S -O /dev/null -T 10 "$target_url" 2>&1 || true
)"
printf '%s\n' "$output" | awk '$1 ~ /^HTTP\// { code = $2 } END { print code }'
}
assert_private_target_blocked() {
local proxy_url="$1"
local target_url="$2"
local status_code
status_code="$(http_code_for "$proxy_url" "$target_url")"
if [[ "$status_code" != "403" ]]; then
echo "Expected $target_url to be blocked with HTTP 403, got ${status_code:-no response}."
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
assert_public_target_allowed() {
local proxy_url="$1"
local target_url="$2"
local status_code
status_code="$(http_code_for "$proxy_url" "$target_url")"
if [[ ! "$status_code" =~ ^(2|3)[0-9][0-9]$ ]]; then
echo "Expected $target_url to remain reachable, got ${status_code:-no response}."
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
}
assert_reverse_proxy_allowed() {
local target_url="$1"
local status_code
status_code="$(direct_http_code_for "$target_url")"
if [[ ! "$status_code" =~ ^2[0-9][0-9]$ ]]; then
echo "Expected sandbox reverse proxy $target_url to remain reachable, got ${status_code:-no response}."
docker logs "$CONTAINER_NAME" >&2 || true
docker logs "$SANDBOX_CONTAINER_NAME" >&2 || true
exit 1
fi
}
trap cleanup EXIT
cleanup
docker network create "$NETWORK_NAME" >/dev/null
docker run \
--detach \
--name "$SANDBOX_CONTAINER_NAME" \
--network "$NETWORK_NAME" \
--network-alias sandbox \
"$CLIENT_IMAGE" \
sh -c "mkdir -p /www && echo ok > /www/index.html && httpd -f -p 8194 -h /www" \
>/dev/null
docker run \
--detach \
--name "$CONTAINER_NAME" \
--entrypoint sh \
--network "$NETWORK_NAME" \
--volume "$ROOT_DIR/docker/ssrf_proxy/squid.conf.template:/etc/squid/squid.conf.template:ro" \
--volume "$ROOT_DIR/docker/ssrf_proxy/docker-entrypoint.sh:/docker-entrypoint-mount.sh:ro" \
--env HTTP_PORT=3128 \
--env COREDUMP_DIR=/var/spool/squid \
--env REVERSE_PROXY_PORT=8194 \
--env SANDBOX_HOST=sandbox \
--env SANDBOX_PORT=8194 \
--env "SSRF_PROXY_ALLOW_PRIVATE_IPS=${SSRF_PROXY_ALLOW_PRIVATE_IPS:-}" \
--env "SSRF_PROXY_ALLOW_PRIVATE_DOMAINS=${SSRF_PROXY_ALLOW_PRIVATE_DOMAINS:-}" \
"$IMAGE" \
-c "cp /docker-entrypoint-mount.sh /docker-entrypoint.sh && sed -i 's/\r$//' /docker-entrypoint.sh && chmod +x /docker-entrypoint.sh && /docker-entrypoint.sh" \
>/dev/null
proxy_url="http://$CONTAINER_NAME:3128"
for _ in {1..30}; do
probe_status="$(http_code_for "$proxy_url" "http://127.0.0.1:80/")"
if [[ -n "$probe_status" ]]; then
break
fi
sleep 1
done
if [[ -z "${probe_status:-}" ]]; then
echo "Squid proxy did not respond to probes."
docker logs "$CONTAINER_NAME" >&2 || true
exit 1
fi
assert_private_target_blocked "$proxy_url" "http://127.0.0.1:80/"
assert_private_target_blocked "$proxy_url" "http://169.254.169.254/latest/meta-data/"
if [[ "$RUN_PUBLIC_CHECK" == "true" ]]; then
assert_public_target_allowed "$proxy_url" "http://example.com/"
fi
assert_reverse_proxy_allowed "http://$CONTAINER_NAME:8194/"

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "dify",
"type": "module",
"private": true,
"packageManager": "pnpm@11.1.1",
"packageManager": "pnpm@11.1.3",
"engines": {
"node": "^22.22.1"
},

View File

@ -1,40 +0,0 @@
# Contracts
## API OpenAPI Readiness
<!-- api-openapi-readiness:start -->
<!-- This section is auto-generated by scripts/generate-api-readiness-readme.mjs. Do not edit between the markers. -->
Snapshot generated from `packages/contracts/generated/api/readiness.json` after running `pnpm -C packages/contracts gen-api-contract-from-openapi`.
Are we OpenAPI ready? **No.** Current generated API contracts are **35.4% ready**.
| Surface | Ready | Not ready | Total | Ready % |
| --------- | ------: | --------: | ------: | --------: |
| console | 205 | 383 | 588 | 34.9% |
| service | 28 | 60 | 88 | 31.8% |
| web | 21 | 20 | 41 | 51.2% |
| **total** | **254** | **463** | **717** | **35.4%** |
Readiness here means the generated contract operation is not marked with:
> Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint.
The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, when a mutating controller reads a JSON body that is not documented as a request body, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate.
<!-- api-openapi-readiness:end -->
## How to Improve Readiness
Improve the ready percentage by fixing the backend annotations that produce loose generated types, then regenerating the contracts.
- Add accurate request body schemas for endpoints that currently generate loose object types.
- Add accurate 2xx response schemas for endpoints that return JSON payloads.
- Use 204 responses for endpoints that intentionally return no body.
- Avoid untyped dictionaries, raw objects, or `additionalProperties: true` responses unless the API really returns an arbitrary object.
- Regenerate with `pnpm -C packages/contracts gen-api-contract` and use this README to verify the updated percentage.
Do not remove the generated warning just to increase the number. The warning should disappear because the backend OpenAPI output became accurate enough for callers to migrate safely.

View File

@ -1,17 +0,0 @@
{
"surfaces": {
"console": {
"notReady": 383,
"total": 588
},
"service": {
"notReady": 60,
"total": 88
},
"web": {
"notReady": 20,
"total": 41
}
},
"warning": "Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate."
}

View File

@ -90,7 +90,6 @@ type ApiOperationContext = {
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const apiOpenApiDir = path.resolve(currentDir, 'openapi')
const apiReadinessStatsPath = path.resolve(currentDir, 'generated/api/readiness.json')
const apiControllersDir = path.resolve(currentDir, '../../api/controllers')
const operationMethods = new Set(['delete', 'get', 'patch', 'post', 'put'])
@ -750,6 +749,10 @@ const recordApiReadiness = (surface: string, isReady: boolean) => {
stats.notReady += 1
}
const formatPercent = (ready: number, total: number) => {
return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%`
}
const normalizeOperations = (document: SwaggerDocument, surface: string) => {
const definitions = document.definitions ??= {}
@ -792,18 +795,29 @@ const normalizeApiSwagger = (document: SwaggerDocument, surface: string) => {
return document
}
const writeApiReadinessStats = () => {
const printApiReadinessStats = () => {
const sortedSurfaces = Object.entries(apiReadinessStats)
.sort(([left], [right]) => left.localeCompare(right))
fs.mkdirSync(path.dirname(apiReadinessStatsPath), { recursive: true })
fs.writeFileSync(
apiReadinessStatsPath,
`${JSON.stringify({
surfaces: Object.fromEntries(sortedSurfaces),
warning: inaccurateGeneratedContractDescription,
}, null, 2)}\n`,
const totals = sortedSurfaces.reduce(
(summary, [, stats]) => {
summary.notReady += stats.notReady
summary.total += stats.total
return summary
},
{ notReady: 0, total: 0 },
)
const totalReady = totals.total - totals.notReady
const rows = sortedSurfaces.map(([surface, stats]) => {
const ready = stats.total - stats.notReady
return ` ${surface}: ${ready}/${stats.total} ready (${formatPercent(ready, stats.total)}), ${stats.notReady} not ready`
})
console.log([
'API OpenAPI readiness:',
...rows,
` total: ${totalReady}/${totals.total} ready (${formatPercent(totalReady, totals.total)}), ${totals.notReady} not ready`,
].join('\n'))
}
const topLevelPathSegment = (routePath: string) => {
@ -933,7 +947,7 @@ const createApiJobs = (spec: ApiSpec): ApiJob[] => {
}
const apiJobs = apiSpecs.flatMap(createApiJobs)
writeApiReadinessStats()
printApiReadinessStats()
const createApiConfig = (job: ApiJob): UserConfig => ({
input: job.document,

View File

@ -15,9 +15,8 @@
},
"scripts": {
"gen-api-contract": "pnpm gen-api-openapi && pnpm gen-api-contract-from-openapi",
"gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api && pnpm gen-api-readiness-readme",
"gen-api-contract-from-openapi": "node -e \"fs.rmSync('generated/api', { recursive: true, force: true })\" && openapi-ts -f openapi-ts.api.config.ts && vp fmt generated/api && eslint --fix generated/api",
"gen-api-openapi": "uv run --project ../../api ../../api/dev/generate_swagger_specs.py --output-dir openapi",
"gen-api-readiness-readme": "node scripts/generate-api-readiness-readme.mjs && eslint --fix README.md",
"gen-enterprise-contract": "openapi-ts -f openapi-ts.enterprise.config.ts",
"type-check": "tsgo"
},

View File

@ -1,94 +0,0 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const packageDir = path.resolve(currentDir, '..')
const readinessStatsPath = path.resolve(packageDir, 'generated/api/readiness.json')
const readmePath = path.resolve(packageDir, 'README.md')
const readinessStartMarker = '<!-- api-openapi-readiness:start -->'
const readinessEndMarker = '<!-- api-openapi-readiness:end -->'
const formatPercent = (ready, total) => {
return total === 0 ? '0.0%' : `${((ready / total) * 100).toFixed(1)}%`
}
const collectStats = () => {
if (!fs.existsSync(readinessStatsPath)) {
throw new Error(
`Missing API readiness stats: ${readinessStatsPath}. Run "pnpm -C packages/contracts gen-api-contract-from-openapi" first.`,
)
}
return JSON.parse(fs.readFileSync(readinessStatsPath, 'utf8'))
}
const tableRow = (surface, ready, notReady, total) => {
return `| ${surface} | ${ready} | ${notReady} | ${total} | ${formatPercent(ready, total)} |`
}
const renderReadinessSection = (stats) => {
const rows = Object.entries(stats.surfaces)
.sort(([left], [right]) => left.localeCompare(right))
.map(([surface, stat]) => tableRow(surface, stat.total - stat.notReady, stat.notReady, stat.total))
const totals = Object.values(stats.surfaces).reduce(
(summary, stat) => {
summary.notReady += stat.notReady
summary.total += stat.total
return summary
},
{ notReady: 0, total: 0 },
)
const totalReady = totals.total - totals.notReady
if (totals.total === 0)
throw new Error(`No API readiness stats found in ${readinessStatsPath}`)
return `${readinessStartMarker}
<!-- This section is auto-generated by scripts/generate-api-readiness-readme.mjs. Do not edit between the markers. -->
Snapshot generated from \`packages/contracts/generated/api/readiness.json\` after running \`pnpm -C packages/contracts gen-api-contract-from-openapi\`.
Are we OpenAPI ready? **No.** Current generated API contracts are **${formatPercent(totalReady, totals.total)} ready**.
| Surface | Ready | Not ready | Total | Ready % |
| --- | ---: | ---: | ---: | ---: |
${rows.join('\n')}
| **total** | **${totalReady}** | **${totals.notReady}** | **${totals.total}** | **${formatPercent(totalReady, totals.total)}** |
Readiness here means the generated contract operation is not marked with:
> ${stats.warning}
Operations marked with that warning should not be migrated to blindly. Prefer fixing backend OpenAPI annotations first so the generated contract has accurate request and response types, then migrate callers endpoint by endpoint.
The current heuristic marks an operation as not ready when a request body or success response that should have a body contains a loose object type, when a mutating controller reads a JSON body that is not documented as a request body, or when an operation has no documented 2xx response. 204, 205, and 304 responses are treated as bodyless when the request type is otherwise accurate.
${readinessEndMarker}
`
}
const updateReadme = (readinessSection) => {
const readme = fs.readFileSync(readmePath, 'utf8')
const startIndex = readme.indexOf(readinessStartMarker)
const endIndex = readme.indexOf(readinessEndMarker)
if (startIndex === -1 || endIndex === -1 || endIndex < startIndex) {
throw new Error(
`Missing readiness markers in ${readmePath}. Expected ${readinessStartMarker} and ${readinessEndMarker}.`,
)
}
const nextReadme = [
readme.slice(0, startIndex),
readinessSection,
readme.slice(endIndex + readinessEndMarker.length),
].join('')
fs.writeFileSync(readmePath, nextReadme)
}
updateReadme(renderReadinessSection(collectStats()))

View File

@ -65,6 +65,10 @@
"types": "./src/form/index.tsx",
"import": "./src/form/index.tsx"
},
"./input": {
"types": "./src/input/index.tsx",
"import": "./src/input/index.tsx"
},
"./meter": {
"types": "./src/meter/index.tsx",
"import": "./src/meter/index.tsx"
@ -73,6 +77,14 @@
"types": "./src/number-field/index.tsx",
"import": "./src/number-field/index.tsx"
},
"./radio": {
"types": "./src/radio/index.tsx",
"import": "./src/radio/index.tsx"
},
"./radio-group": {
"types": "./src/radio-group/index.tsx",
"import": "./src/radio-group/index.tsx"
},
"./popover": {
"types": "./src/popover/index.tsx",
"import": "./src/popover/index.tsx"

View File

@ -3,8 +3,8 @@
import type { Field as BaseFieldNS } from '@base-ui/react/field'
import type { VariantProps } from 'class-variance-authority'
import { Field as BaseField } from '@base-ui/react/field'
import { cva } from 'class-variance-authority'
import { cn } from '../cn'
import { inputVariants } from '../form-control-shared'
export type FieldRootProps
= Omit<BaseFieldNS.Root.Props, 'className'>
@ -62,37 +62,11 @@ export function FieldLabel({
)
}
const fieldControlVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type FieldControlSize = NonNullable<VariantProps<typeof fieldControlVariants>['size']>
export type FieldControlSize = NonNullable<VariantProps<typeof inputVariants>['size']>
export type FieldControlProps
= Omit<BaseFieldNS.Control.Props, 'className' | 'size'>
& VariantProps<typeof fieldControlVariants>
& VariantProps<typeof inputVariants>
& {
className?: string
}
@ -106,7 +80,7 @@ export function FieldControl({
}: FieldControlProps) {
return (
<BaseField.Control
className={cn(fieldControlVariants({ size }), className)}
className={cn(inputVariants({ size }), className)}
{...props}
/>
)

View File

@ -0,0 +1,30 @@
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
export const inputVariants = cva(
[
'w-full appearance-none border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
'placeholder:text-components-input-text-placeholder',
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
'motion-reduce:transition-none',
],
{
variants: {
size: {
small: 'rounded-md px-2 py-[3px] system-xs-regular',
medium: 'rounded-lg px-3 py-[7px] system-sm-regular',
large: 'rounded-[10px] px-4 py-[7px] system-md-regular',
},
},
defaultVariants: {
size: 'medium',
},
},
)
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>

View File

@ -0,0 +1,83 @@
import { render } from 'vitest-browser-react'
import { FieldControl, FieldError, FieldLabel, FieldRoot } from '../../field'
import { Form } from '../../form'
import { Input } from '../index'
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
describe('Input', () => {
it('should render a labelled Base UI input with design-system classes', async () => {
const screen = await render(
<label>
Workspace name
<Input name="workspaceName" defaultValue="Dify" />
</label>,
)
const input = screen.getByRole('textbox', { name: 'Workspace name' })
await expect.element(input).toHaveValue('Dify')
await expect.element(input).toHaveClass('rounded-lg', 'py-[7px]', 'system-sm-regular')
})
it('should apply size variants shared with FieldControl', async () => {
const screen = await render(
<>
<label>
Small input
<Input size="small" />
</label>
<div>
Large field
<FieldRoot name="largeField">
<FieldLabel>Large field</FieldLabel>
<FieldControl size="large" />
</FieldRoot>
</div>
</>,
)
await expect.element(screen.getByRole('textbox', { name: 'Small input' })).toHaveClass('rounded-md', 'py-[3px]', 'system-xs-regular')
await expect.element(screen.getByRole('textbox', { name: 'Large field' })).toHaveClass('rounded-[10px]', 'py-[7px]', 'system-md-regular')
})
it('should use FieldRoot invalid state', async () => {
const screen = await render(
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Repository URL</FieldLabel>
<Input defaultValue="github.com/langgenius" />
</FieldRoot>,
)
const input = screen.getByRole('textbox', { name: 'Repository URL' })
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
await expect.element(input).toHaveClass('data-invalid:border-components-input-border-destructive')
})
it('should integrate with FieldRoot and Base UI Form validation', async () => {
const onFormSubmit = vi.fn()
const screen = await render(
<Form aria-label="account form" onFormSubmit={onFormSubmit}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" required />
<FieldError match="valueMissing">Email is required.</FieldError>
</FieldRoot>
<button type="submit">Save</button>
</Form>,
)
const input = screen.getByRole('textbox', { name: 'Email' })
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
await vi.waitFor(async () => {
await expect.element(screen.getByText('Email is required.')).toBeInTheDocument()
await expect.element(input).toHaveAttribute('aria-invalid', 'true')
await expect.element(input).toHaveAttribute('data-invalid')
})
expect(onFormSubmit).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,124 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { Button } from '../button'
import {
FieldDescription,
FieldError,
FieldLabel,
FieldRoot,
} from '../field'
import { Form } from '../form'
import { Input } from './index'
const meta = {
title: 'Base/Form/Input',
component: Input,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'A standalone text input primitive built on Base UI Input. Use it for labelled text boxes outside FieldControl, and keep FieldControl for full FieldRoot form composition.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof Input>
export default meta
type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<div className="w-80">
<label htmlFor="workspace-name" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
Workspace name
</label>
<Input
id="workspace-name"
name="workspaceName"
autoComplete="organization"
placeholder="e.g. Acme workspace…"
/>
</div>
),
}
export const Sizes: Story = {
render: () => (
<div className="grid w-80 gap-3">
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-input">
Small
<Input id="small-input" size="small" name="smallInput" placeholder="e.g. tag…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-input">
Medium
<Input id="medium-input" name="mediumInput" placeholder="e.g. Production API…" autoComplete="off" />
</label>
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-input">
Large
<Input id="large-input" size="large" name="largeInput" placeholder="e.g. Customer portal…" autoComplete="off" />
</label>
</div>
),
}
export const States: Story = {
render: () => (
<div className="grid w-80 gap-3">
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="placeholder-state">Placeholder</label>
<Input id="placeholder-state" name="placeholderState" placeholder="e.g. Search datasets…" autoComplete="off" />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="filled-state">Filled</label>
<Input id="filled-state" name="filledState" defaultValue="Customer knowledge base" autoComplete="off" />
</div>
<div className="grid gap-1">
<FieldRoot name="repositoryUrl" invalid>
<FieldLabel>Invalid</FieldLabel>
<Input
id="invalid-state"
type="url"
inputMode="url"
defaultValue="github.com/langgenius"
autoComplete="off"
spellCheck={false}
/>
<FieldError match>Enter a full URL including https://.</FieldError>
</FieldRoot>
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="disabled-state">Disabled</label>
<Input id="disabled-state" disabled name="disabledEmail" type="email" inputMode="email" placeholder="name@example.com…" autoComplete="email" spellCheck={false} />
</div>
<div className="grid gap-1">
<label className="text-text-secondary system-sm-medium" htmlFor="readonly-state">Read-only</label>
<Input id="readonly-state" readOnly name="endpoint" type="url" inputMode="url" defaultValue="https://api.example.com" autoComplete="url" spellCheck={false} />
</div>
</div>
),
}
export const WithField: Story = {
render: () => (
<Form aria-label="Account form" className="grid w-80 gap-4" onFormSubmit={() => undefined}>
<FieldRoot name="email">
<FieldLabel>Email</FieldLabel>
<Input type="email" inputMode="email" required autoComplete="email" placeholder="name@example.com…" spellCheck={false} />
<FieldDescription>Used for account notifications.</FieldDescription>
<FieldError match="valueMissing">Email is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid email address.</FieldError>
</FieldRoot>
<FieldRoot name="repositoryUrl">
<FieldLabel>Repository URL</FieldLabel>
<Input type="url" inputMode="url" required autoComplete="off" placeholder="https://github.com/langgenius/dify…" spellCheck={false} />
<FieldDescription>Use the full GitHub repository URL.</FieldDescription>
<FieldError match="valueMissing">Repository URL is required.</FieldError>
<FieldError match="typeMismatch">Enter a valid URL.</FieldError>
</FieldRoot>
<div className="flex justify-end">
<Button type="submit" variant="primary">Save Settings</Button>
</div>
</Form>
),
}

View File

@ -0,0 +1,31 @@
'use client'
import type { Input as BaseInputNS } from '@base-ui/react/input'
import type { VariantProps } from 'class-variance-authority'
import { Input as BaseInput } from '@base-ui/react/input'
import { cn } from '../cn'
import { inputVariants } from '../form-control-shared'
export type InputSize = NonNullable<VariantProps<typeof inputVariants>['size']>
export type InputProps
= Omit<BaseInputNS.Props, 'className' | 'size'>
& VariantProps<typeof inputVariants>
& {
className?: string
}
export type InputChangeEventDetails = BaseInputNS.ChangeEventDetails
export function Input({
className,
size = 'medium',
...props
}: InputProps) {
return (
<BaseInput
className={cn(inputVariants({ size }), className)}
{...props}
/>
)
}

View File

@ -0,0 +1,82 @@
import { useState } from 'react'
import { render } from 'vitest-browser-react'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { Radio } from '../../radio'
import { RadioGroup } from '../index'
const clickElement = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
describe('RadioGroup', () => {
it('should manage a controlled single selection', async () => {
function StorageDemo() {
const [value, setValue] = useState('ssd')
return (
<FieldRoot name="storageType">
<FieldsetRoot render={<RadioGroup value={value} onValueChange={setValue} />}>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Radio value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Radio value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}
const screen = await render(<StorageDemo />)
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'true')
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
await vi.waitFor(async () => {
await expect.element(screen.getByRole('radio', { name: 'SSD' })).toHaveAttribute('aria-checked', 'false')
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
})
})
it('should compose with Dify UI Field and Fieldset without losing labels', async () => {
const onValueChange = vi.fn()
const screen = await render(
<FieldRoot name="storageType">
<FieldsetRoot render={<RadioGroup value="ssd" onValueChange={onValueChange} />}>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel>
<Radio value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel>
<Radio value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>,
)
await expect.element(screen.getByRole('radiogroup', { name: 'Storage type' })).toBeInTheDocument()
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
clickElement(hdd.element())
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
})
})

View File

@ -0,0 +1,217 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import { useState } from 'react'
import { RadioGroup } from '.'
import {
FieldDescription,
FieldItem,
FieldLabel,
FieldRoot,
} from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { Radio, RadioControl, RadioRoot } from '../radio'
const meta = {
title: 'Base/Form/RadioGroup',
component: RadioGroup,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'RadioGroup primitive built on Base UI. For normal form rows, compose FieldRoot, FieldsetRoot, FieldLabel, RadioGroup, and Radio. For option cards, make the card itself a RadioRoot with variant="unstyled" and render RadioControl inside it.',
},
},
},
tags: ['autodocs'],
} satisfies Meta<typeof RadioGroup>
export default meta
type Story = StoryObj<typeof meta>
function StandardFormRowsDemo() {
const [value, setValue] = useState('vector')
return (
<FieldRoot name="retrievalIndex" className="w-80">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
)}
>
<FieldsetLegend>Retrieval index</FieldsetLegend>
{[
{ value: 'vector', label: 'Vector storage' },
{ value: 'keyword', label: 'Keyword index' },
{ value: 'hybrid', label: 'Hybrid retrieval' },
].map(option => (
<FieldItem key={option.value}>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value={option.value} />
{option.label}
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const StandardFormRows: Story = {
render: () => <StandardFormRowsDemo />,
parameters: {
docs: {
description: {
story: 'Default form composition. Most product code should use this shape: RadioGroup owns value, FieldsetLegend names the group, and FieldLabel makes each row clickable.',
},
},
},
}
function BooleanInlineDemo() {
const [value, setValue] = useState(true)
return (
<FieldRoot name="streaming" className="w-80">
<FieldsetRoot
render={(
<RadioGroup<boolean> value={value} onValueChange={setValue} className="gap-3" />
)}
>
<FieldsetLegend>Streaming output</FieldsetLegend>
<div className="flex items-center gap-3">
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={true} />
True
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-1.5 system-sm-regular text-text-secondary">
<Radio value={false} />
False
</FieldLabel>
</FieldItem>
</div>
</FieldsetRoot>
</FieldRoot>
)
}
export const BooleanInline: Story = {
render: () => <BooleanInlineDemo />,
parameters: {
docs: {
description: {
story: 'Compact boolean radio fields. This is the pattern used by model parameters and dynamic boolean schema fields.',
},
},
},
}
function OptionCardsDemo() {
const [value, setValue] = useState('default')
return (
<FieldRoot name="promptMode" className="w-100">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-stretch gap-3" />
)}
>
<FieldsetLegend>Prompt mode</FieldsetLegend>
{[
{
value: 'default',
title: 'Default prompt',
description: 'Use the built-in prompt for consistent output.',
},
{
value: 'custom',
title: 'Custom prompt',
description: 'Write a prompt for this app and keep full control.',
},
].map(option => (
<RadioRoot
key={option.value}
value={option.value}
variant="unstyled"
nativeButton
render={<button type="button" />}
className="w-full rounded-xl border border-components-option-card-option-border bg-components-option-card-option-bg p-4 text-left transition-colors hover:bg-state-base-hover data-checked:border-components-option-card-option-selected-border data-checked:bg-components-option-card-option-selected-bg"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="system-sm-semibold text-text-primary">
{option.title}
</div>
<div className="mt-1 system-xs-regular text-text-tertiary">
{option.description}
</div>
</div>
<RadioControl aria-hidden="true" />
</div>
</RadioRoot>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const OptionCards: Story = {
render: () => <OptionCardsDemo />,
parameters: {
docs: {
description: {
story: 'Use RadioRoot with variant="unstyled" when the entire option card is the radio. RadioControl renders the visual dot inside the card.',
},
},
},
}
function DynamicFormFieldDemo() {
const options = [
{ value: 'automatic', label: 'Automatic' },
{ value: 'high_quality', label: 'High quality' },
{ value: 'economy', label: 'Economy' },
]
const [selected, setSelected] = useState('automatic')
return (
<FieldRoot name="generation_mode" className="flex w-80 flex-col gap-2">
<FieldDescription className="body-xs-regular text-text-tertiary">
This mirrors Dify dynamic form fields where radio options are controlled by schema and persisted as a single value.
</FieldDescription>
<FieldsetRoot
render={(
<RadioGroup
value={selected}
onValueChange={setSelected}
className="flex-col items-start gap-2 rounded-lg border border-components-panel-border bg-components-panel-bg p-3"
/>
)}
>
<FieldsetLegend className="system-sm-medium text-text-secondary">
Generation mode
</FieldsetLegend>
{options.map(option => (
<FieldItem key={option.value}>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value={option.value} />
{option.label}
</FieldLabel>
</FieldItem>
))}
</FieldsetRoot>
</FieldRoot>
)
}
export const DynamicFormField: Story = {
render: () => <DynamicFormFieldDemo />,
parameters: {
docs: {
description: {
story: 'Matches Dify form composition: Field and Fieldset provide group labeling while RadioGroup owns controlled single-selection state.',
},
},
},
}

View File

@ -0,0 +1,23 @@
'use client'
import type { RadioGroup as BaseRadioGroupNS } from '@base-ui/react/radio-group'
import { RadioGroup as BaseRadioGroup } from '@base-ui/react/radio-group'
import { cn } from '../cn'
export type RadioGroupProps<Value = string>
= Omit<BaseRadioGroupNS.Props<Value>, 'className'>
& {
className?: string
}
export function RadioGroup<Value = string>({
className,
...props
}: RadioGroupProps<Value>) {
return (
<BaseRadioGroup
className={cn('flex items-center gap-2', className)}
{...props}
/>
)
}

View File

@ -0,0 +1,178 @@
import type { ComponentProps, ReactNode } from 'react'
import { render } from 'vitest-browser-react'
import { FieldItem, FieldLabel, FieldRoot } from '../../field'
import { FieldsetLegend, FieldsetRoot } from '../../fieldset'
import { RadioGroup } from '../../radio-group'
import {
Radio,
RadioControl,
RadioIndicator,
RadioRoot,
RadioSkeleton,
} from '../index'
const clickElement = (element: HTMLElement | SVGElement) => {
element.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }))
}
type TestRadioGroupProps = ComponentProps<typeof RadioGroup> & {
children: ReactNode
label: string
name?: string
}
function TestRadioGroup({
children,
label,
name = 'radioField',
...props
}: TestRadioGroupProps) {
return (
<FieldRoot name={name}>
<FieldsetRoot render={<RadioGroup {...props} />}>
<FieldsetLegend>{label}</FieldsetLegend>
{children}
</FieldsetRoot>
</FieldRoot>
)
}
type TestRadioOptionProps = ComponentProps<typeof Radio> & {
children: ReactNode
}
function TestRadioOption({
children,
...props
}: TestRadioOptionProps) {
return (
<FieldItem>
<FieldLabel>
<Radio {...props} />
{children}
</FieldLabel>
</FieldItem>
)
}
describe('Radio', () => {
it('should render unchecked and checked radios with Base UI semantics', async () => {
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>,
)
const ssd = screen.getByRole('radio', { name: 'SSD' })
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(ssd).toHaveAttribute('aria-checked', 'true')
await expect.element(ssd).toHaveAttribute('data-checked', '')
await expect.element(ssd).toHaveClass('data-checked:border-components-radio-border-checked')
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
await expect.element(hdd).toHaveAttribute('data-unchecked', '')
})
it('should call onValueChange and update uncontrolled state when selected', async () => {
const onValueChange = vi.fn()
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>,
)
clickElement(screen.getByRole('radio', { name: 'HDD' }).element())
expect(onValueChange).toHaveBeenCalledTimes(1)
expect(onValueChange.mock.calls[0]?.[0]).toBe('hdd')
await expect.element(screen.getByRole('radio', { name: 'HDD' })).toHaveAttribute('aria-checked', 'true')
})
it('should ignore interaction when disabled', async () => {
const onValueChange = vi.fn()
const screen = await render(
<TestRadioGroup defaultValue="ssd" label="Storage type" onValueChange={onValueChange}>
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd" disabled>HDD</TestRadioOption>
</TestRadioGroup>,
)
const hdd = screen.getByRole('radio', { name: 'HDD' })
await expect.element(hdd).toHaveAttribute('data-disabled', '')
await expect.element(hdd).toHaveClass('data-disabled:cursor-not-allowed')
clickElement(hdd.element())
expect(onValueChange).not.toHaveBeenCalled()
await expect.element(hdd).toHaveAttribute('aria-checked', 'false')
})
it('should submit the selected group value through the hidden input', async () => {
const screen = await render(
<form>
<TestRadioGroup defaultValue="ssd" label="Storage type" name="storageType">
<TestRadioOption value="ssd">SSD</TestRadioOption>
<TestRadioOption value="hdd">HDD</TestRadioOption>
</TestRadioGroup>
</form>,
)
const form = screen.container.querySelector<HTMLFormElement>('form')
expect(form).not.toBeNull()
if (!form)
return
const data = new FormData(form)
expect(data.get('storageType')).toBe('ssd')
})
it('should support custom compound composition with RadioRoot and RadioIndicator', async () => {
const screen = await render(
<TestRadioGroup defaultValue="custom" label="Custom">
<FieldItem>
<FieldLabel>
<RadioRoot value="custom" className="custom-root">
<RadioIndicator className="custom-indicator" keepMounted />
</RadioRoot>
Custom
</FieldLabel>
</FieldItem>
</TestRadioGroup>,
)
await expect.element(screen.getByRole('radio', { name: 'Custom' })).toHaveClass('custom-root')
expect(screen.container.querySelector('.custom-indicator')).toBeInTheDocument()
})
it('should support unstyled roots with a visual RadioControl for option cards', async () => {
const screen = await render(
<RadioGroup defaultValue="card" aria-label="Card choice">
<RadioRoot
value="card"
variant="unstyled"
nativeButton
render={<button type="button" className="custom-card" />}
>
<span>Card option</span>
<RadioControl className="custom-control" />
</RadioRoot>
</RadioGroup>,
)
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveClass('custom-card')
expect(screen.container.querySelector('.custom-control')).toBeInTheDocument()
await expect.element(screen.getByRole('radio', { name: 'Card option' })).toHaveAttribute('data-checked', '')
})
})
describe('RadioSkeleton', () => {
it('should render a visual placeholder without radio semantics', async () => {
const screen = await render(<RadioSkeleton />)
const skeleton = screen.container.querySelector<HTMLElement>('.rounded-full')
expect(screen.container.querySelector('[role="radio"]')).not.toBeInTheDocument()
await expect.element(skeleton).toHaveClass('rounded-full', 'opacity-20')
})
})

View File

@ -0,0 +1,147 @@
import type { Meta, StoryObj } from '@storybook/react-vite'
import type { ComponentProps } from 'react'
import { useState } from 'react'
import {
Radio,
RadioSkeleton,
} from '.'
import { FieldItem, FieldLabel, FieldRoot } from '../field'
import { FieldsetLegend, FieldsetRoot } from '../fieldset'
import { RadioGroup } from '../radio-group'
const meta = {
title: 'Base/Form/Radio',
component: Radio,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Radio primitive built on Base UI. It preserves RadioGroup selection, hidden input, disabled, and form semantics while applying the Dify 16px radio design from Figma. Import from `@langgenius/dify-ui/radio` and place radios inside `RadioGroup` from `@langgenius/dify-ui/radio-group`.',
},
},
},
tags: ['autodocs'],
args: {
disabled: false,
value: 'ssd',
},
argTypes: {
disabled: {
control: 'boolean',
description: 'Disables user interaction and exposes Base UI disabled state attributes.',
},
},
} satisfies Meta<typeof Radio>
export default meta
type Story = StoryObj<typeof meta>
function RadioDemo(args: Partial<ComponentProps<typeof Radio>>) {
const [value, setValue] = useState('ssd')
return (
<FieldRoot name="storageType">
<FieldsetRoot
render={(
<RadioGroup value={value} onValueChange={setValue} className="flex-col items-start gap-3" />
)}
>
<FieldsetLegend>Storage type</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio {...args} value="ssd" />
SSD
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio {...args} value="hdd" />
HDD
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
)
}
export const Default: Story = {
render: args => <RadioDemo {...args} />,
args: {
disabled: false,
},
}
export const Disabled: Story = {
args: {
value: 'checked',
},
render: () => (
<FieldRoot name="disabledStates">
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
<FieldsetLegend>Disabled states</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="unchecked" disabled />
Disabled unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" disabled />
Disabled checked
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
),
}
export const StateMatrix: Story = {
args: {
value: 'checked',
},
render: () => (
<div className="flex flex-col gap-3">
<FieldRoot name="radioStates">
<FieldsetRoot render={<RadioGroup value="checked" className="flex-col items-start gap-3" />}>
<FieldsetLegend>Radio states</FieldsetLegend>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="unchecked" />
Unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" />
Checked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="disabled-unchecked" disabled />
Disabled unchecked
</FieldLabel>
</FieldItem>
<FieldItem>
<FieldLabel className="flex items-center gap-2 system-sm-medium text-text-secondary">
<Radio value="checked" disabled />
Disabled checked
</FieldLabel>
</FieldItem>
</FieldsetRoot>
</FieldRoot>
<div className="flex items-center gap-2 system-sm-medium text-text-secondary">
<RadioSkeleton aria-hidden="true" />
Skeleton
</div>
</div>
),
parameters: {
docs: {
description: {
story: 'The full visual matrix for Dify radio states. State styling comes from Base UI data attributes such as data-checked and data-disabled.',
},
},
},
}

View File

@ -0,0 +1,105 @@
'use client'
import type { Radio as BaseRadioNS } from '@base-ui/react/radio'
import type { HTMLAttributes } from 'react'
import { Radio as BaseRadio } from '@base-ui/react/radio'
import { cn } from '../cn'
const radioRootClassName = cn(
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-full p-0 transition-colors motion-reduce:transition-none',
'border border-components-radio-border bg-components-radio-bg shadow-xs shadow-shadow-shadow-3',
'hover:border-components-radio-border-hover hover:bg-components-radio-bg-hover',
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0',
'data-checked:border-[5px] data-checked:border-components-radio-border-checked data-checked:hover:border-components-radio-border-checked-hover',
'data-disabled:cursor-not-allowed data-disabled:border-components-radio-border-disabled data-disabled:bg-components-radio-bg-disabled',
'data-disabled:hover:border-components-radio-border-disabled data-disabled:hover:bg-components-radio-bg-disabled',
'data-disabled:data-checked:border-[5px] data-disabled:data-checked:border-components-radio-border-checked-disabled',
'data-disabled:data-checked:hover:border-components-radio-border-checked-disabled',
)
const radioIndicatorClassName = 'flex items-center justify-center data-unchecked:hidden before:size-1.5 before:rounded-full before:bg-current'
const radioControlClassName = radioRootClassName
const radioSkeletonClassName = 'size-4 shrink-0 rounded-full bg-text-quaternary opacity-20'
export type RadioRootProps<Value = string>
= Omit<BaseRadioNS.Root.Props<Value>, 'className'>
& {
className?: string
variant?: 'control' | 'unstyled'
}
export function RadioRoot<Value = string>({
className,
variant = 'control',
...props
}: RadioRootProps<Value>) {
return (
<BaseRadio.Root
className={cn(variant === 'control' && radioRootClassName, className)}
{...props}
/>
)
}
export type RadioIndicatorProps
= Omit<BaseRadioNS.Indicator.Props, 'className' | 'children'>
& {
className?: string
}
export function RadioIndicator({
className,
...props
}: RadioIndicatorProps) {
return (
<BaseRadio.Indicator
className={cn(radioIndicatorClassName, className)}
{...props}
/>
)
}
export type RadioControlProps
= Omit<RadioIndicatorProps, 'keepMounted'>
export function RadioControl({
className,
...props
}: RadioControlProps) {
return (
<BaseRadio.Indicator
keepMounted
className={cn(radioControlClassName, className)}
{...props}
/>
)
}
export type RadioProps<Value = string>
= Omit<RadioRootProps<Value>, 'children'>
export function Radio<Value = string>({
...props
}: RadioProps<Value>) {
return <RadioRoot {...props} />
}
export type RadioSkeletonProps
= Omit<HTMLAttributes<HTMLDivElement>, 'className'>
& {
className?: string
}
export function RadioSkeleton({
className,
...props
}: RadioSkeletonProps) {
return (
<div
className={cn(radioSkeletonClassName, className)}
{...props}
/>
)
}

3351
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -29,6 +29,7 @@ overrides:
'@lexical/code': npm:lexical-code-no-prism@0.41.0
'@monaco-editor/loader': 1.7.0
brace-expansion@>=2.0.0 <2.0.3: 2.0.3
brace-expansion@>=5.0.0 <5.0.6: ^5.0.6
canvas: ^3.2.2
dompurify@<3.4.0: ^3.4.0
dompurify@<=3.3.3: ^3.3.4
@ -53,26 +54,27 @@ overrides:
svgo@>=3.0.0 <3.3.3: 3.3.3
tar@<=7.5.10: 7.5.11
undici@>=7.0.0 <7.24.0: 7.24.0
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
vite: npm:@voidzero-dev/vite-plus-core@0.1.21
vitest: npm:@voidzero-dev/vite-plus-test@0.1.21
ws@>=8.0.0 <8.20.1: ^8.20.1
yaml@>=2.0.0 <2.8.3: 2.8.3
yauzl@<3.2.1: 3.2.1
catalog:
'@amplitude/analytics-browser': 2.42.2
'@amplitude/plugin-session-replay-browser': 1.30.3
'@amplitude/analytics-browser': 2.42.3
'@amplitude/plugin-session-replay-browser': 1.30.4
'@antfu/eslint-config': 9.0.0
'@base-ui/react': 1.4.1
'@chromatic-com/storybook': 5.1.2
'@cucumber/cucumber': 12.8.3
'@base-ui/react': 1.5.0
'@chromatic-com/storybook': 5.2.1
'@cucumber/cucumber': 12.9.0
'@egoist/tailwindcss-icons': 1.9.2
'@emoji-mart/data': 1.2.1
'@eslint-react/eslint-plugin': 5.7.7
'@eslint-react/eslint-plugin': 5.8.1
'@eslint/js': 10.0.1
'@floating-ui/react': 0.27.19
'@formatjs/intl-localematcher': 0.8.7
'@formatjs/intl-localematcher': 0.8.8
'@heroicons/react': 2.2.0
'@hey-api/openapi-ts': 0.97.1
'@hono/node-server': 2.0.2
'@hey-api/openapi-ts': 0.97.2
'@hono/node-server': 2.0.3
'@iconify-json/heroicons': 1.2.3
'@iconify-json/ri': 1.2.10
'@lexical/code': 0.44.0
@ -96,46 +98,46 @@ catalog:
'@remixicon/react': 4.9.0
'@rgrove/parse-xml': 4.2.0
'@sentry/react': 10.53.1
'@storybook/addon-docs': 10.3.6
'@storybook/addon-links': 10.3.6
'@storybook/addon-onboarding': 10.3.6
'@storybook/addon-themes': 10.3.6
'@storybook/nextjs-vite': 10.3.6
'@storybook/react': 10.3.6
'@storybook/react-vite': 10.3.6
'@storybook/addon-docs': 10.4.0
'@storybook/addon-links': 10.4.0
'@storybook/addon-onboarding': 10.4.0
'@storybook/addon-themes': 10.4.0
'@storybook/nextjs-vite': 10.4.0
'@storybook/react': 10.4.0
'@storybook/react-vite': 10.4.0
'@streamdown/math': 1.0.2
'@svgdotjs/svg.js': 3.2.5
'@t3-oss/env-nextjs': 0.13.11
'@tailwindcss/postcss': 4.3.0
'@tailwindcss/typography': 0.5.19
'@tailwindcss/vite': 4.3.0
'@tanstack/eslint-plugin-query': 5.100.10
'@tanstack/react-devtools': 0.10.3
'@tanstack/eslint-plugin-query': 5.100.11
'@tanstack/react-devtools': 0.10.5
'@tanstack/react-form': 1.32.0
'@tanstack/react-form-devtools': 0.2.27
'@tanstack/react-hotkeys': 0.10.0
'@tanstack/react-query': 5.100.10
'@tanstack/react-query-devtools': 5.100.10
'@tanstack/react-query': 5.100.11
'@tanstack/react-query-devtools': 5.100.11
'@tanstack/react-virtual': 3.13.24
'@testing-library/dom': 10.4.1
'@testing-library/jest-dom': 6.9.1
'@testing-library/react': 16.3.2
'@testing-library/user-event': 14.6.1
'@tsslint/cli': 3.1.2
'@tsslint/compat-eslint': 3.1.2
'@tsslint/config': 3.1.2
'@tsslint/cli': 3.1.3
'@tsslint/compat-eslint': 3.1.3
'@tsslint/config': 3.1.3
'@types/js-cookie': 3.0.6
'@types/js-yaml': 4.0.9
'@types/negotiator': 0.6.4
'@types/node': 25.7.0
'@types/node': 25.9.0
'@types/qs': 6.15.1
'@types/react': 19.2.14
'@types/react-dom': 19.2.3
'@types/sortablejs': 1.15.9
'@typescript-eslint/eslint-plugin': 8.59.3
'@typescript-eslint/parser': 8.59.3
'@typescript/native-preview': 7.0.0-dev.20260512.1
'@vitejs/plugin-react': 6.0.1
'@typescript-eslint/eslint-plugin': 8.59.4
'@typescript-eslint/parser': 8.59.4
'@typescript/native-preview': 7.0.0-dev.20260518.1
'@vitejs/plugin-react': 6.0.2
'@vitejs/plugin-rsc': 0.5.26
'@vitest/coverage-v8': 4.1.6
abcjs: 6.6.3
@ -153,41 +155,41 @@ catalog:
cron-parser: 5.5.0
dayjs: 1.11.20
decimal.js: 10.6.0
dompurify: 3.4.2
echarts: 6.0.0
dompurify: 3.4.5
echarts: 6.1.0-rc.2
echarts-for-react: 3.0.6
elkjs: 0.11.1
embla-carousel-autoplay: 8.6.0
embla-carousel-react: 8.6.0
emoji-mart: 5.6.0
es-toolkit: 1.46.1
eslint: 10.3.0
eslint-markdown: 0.9.0
eslint: 10.4.0
eslint-markdown: 0.9.1
eslint-plugin-better-tailwindcss: 4.5.0
eslint-plugin-hyoban: 0.14.1
eslint-plugin-markdown-preferences: 0.41.1
eslint-plugin-no-barrel-files: 1.3.1
eslint-plugin-react-refresh: 0.5.2
eslint-plugin-sonarjs: 4.0.3
eslint-plugin-storybook: 10.3.6
eslint-plugin-storybook: 10.4.0
fast-deep-equal: 3.1.3
fuse.js: 7.3.0
happy-dom: 20.9.0
hast-util-to-jsx-runtime: 2.3.6
hono: 4.12.18
hono: 4.12.19
html-entities: 2.6.0
html-to-image: 1.11.13
i18next: 26.1.0
i18next: 26.2.0
i18next-resources-to-backend: 1.2.1
iconify-import-svg: 0.2.0
immer: 11.1.8
jotai: 2.20.0
js-audio-recorder: 1.0.7
js-cookie: 3.0.5
js-cookie: 3.0.7
js-yaml: 4.1.1
jsonschema: 1.5.0
katex: 0.16.45
knip: 6.13.1
katex: 0.16.47
knip: 6.14.1
ky: 2.0.2
lamejs: 1.2.1
lexical: 0.44.0
@ -203,7 +205,7 @@ catalog:
playwright: 1.60.0
postcss: 8.5.14
qrcode.react: 4.2.0
qs: 6.15.1
qs: 6.15.2
react: 19.2.6
react-18-input-autosize: 3.0.0
react-dom: 19.2.6
@ -225,23 +227,23 @@ catalog:
socket.io-client: 4.8.3
sortablejs: 1.15.7
std-semver: 1.0.8
storybook: 10.3.6
storybook: 10.4.0
streamdown: 2.5.0
string-ts: 2.3.1
tailwind-merge: 3.6.0
tailwindcss: 4.3.0
tldts: 7.0.30
tsx: 4.21.0
tsx: 4.22.2
typescript: 6.0.3
uglify-js: 3.19.3
unist-util-visit: 5.1.0
use-context-selector: 2.0.0
uuid: 14.0.0
vinext: 0.0.49
vite: npm:@voidzero-dev/vite-plus-core@0.1.20
vite-plugin-inspect: 12.0.0-beta.1
vite-plus: 0.1.20
vitest: npm:@voidzero-dev/vite-plus-test@0.1.20
vinext: 0.0.50
vite: npm:@voidzero-dev/vite-plus-core@0.1.21
vite-plugin-inspect: 12.0.0-beta.2
vite-plus: 0.1.21
vitest: npm:@voidzero-dev/vite-plus-test@0.1.21
vitest-browser-react: 2.2.0
vitest-canvas-mock: 1.1.4
zod: 4.4.3

View File

@ -167,7 +167,7 @@ The Dify community can be found on [Discord community], where you can ask questi
[Storybook]: https://storybook.js.org
[Vite+]: https://viteplus.dev
[Vitest]: https://vitest.dev
[index.spec.tsx]: ./app/components/base/radio/__tests__/index.spec.tsx
[index.spec.tsx]: ./app/components/base/action-button/__tests__/index.spec.tsx
[pnpm]: https://pnpm.io
[vinext]: https://github.com/cloudflare/vinext
[web/docs/test.md]: ./docs/test.md

View File

@ -253,11 +253,11 @@ const Panel: FC = () => {
<TracingIcon size="md" />
<div className="mx-2 system-sm-semibold text-text-secondary">{t(`${I18N_PREFIX}.title`, { ns: 'app' })}</div>
<div className="rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
<RiEqualizer2Line className="size-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
<div className="rounded-md p-1">
<RiArrowDownDoubleLine className="h-4 w-4 text-text-tertiary" />
<RiArrowDownDoubleLine className="size-4 text-text-tertiary" />
</div>
</div>
</ConfigButton>
@ -297,7 +297,7 @@ const Panel: FC = () => {
</div>
{InUseProviderIcon && <InUseProviderIcon className="ml-1 h-4" />}
<div className="ml-2 rounded-md p-1">
<RiEqualizer2Line className="h-4 w-4 text-text-tertiary" />
<RiEqualizer2Line className="size-4 text-text-tertiary" />
</div>
<Divider type="vertical" className="h-3.5" />
</div>

View File

@ -649,7 +649,7 @@ const ProviderConfigModal: FC<Props> = ({
href={docURL[type]}
>
<span>{t(`${I18N_PREFIX}.viewDocsLink`, { ns: 'app', key: t(`tracing.${type}.title`, { ns: 'app' }) })}</span>
<LinkExternal02 className="h-3 w-3" />
<LinkExternal02 className="size-3" />
</a>
<div className="flex items-center">
{isEdit && (
@ -683,7 +683,7 @@ const ProviderConfigModal: FC<Props> = ({
</div>
<div className="border-t-[0.5px] border-divider-regular">
<div className="flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary">
<Lock01 className="mr-1 h-3 w-3 text-text-tertiary" />
<Lock01 className="mr-1 size-3 text-text-tertiary" />
{t('modelProvider.encrypted.front', { ns: 'common' })}
<a
className="mx-1 text-primary-600"

View File

@ -88,7 +88,7 @@ const ProviderPanel: FC<Props> = ({
<div className="flex items-center justify-between space-x-1">
{hasConfigured && (
<div className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs" onClick={viewBtnClick}>
<View className="h-3 w-3" />
<View className="size-3" />
<div className="text-xs font-medium">{t(`${I18N_PREFIX}.view`, { ns: 'app' })}</div>
</div>
)}
@ -96,7 +96,7 @@ const ProviderPanel: FC<Props> = ({
className="flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs"
onClick={handleConfigBtnClick}
>
<RiEqualizer2Line className="h-3 w-3" />
<RiEqualizer2Line className="size-3" />
<div className="text-xs font-medium">{t(`${I18N_PREFIX}.config`, { ns: 'app' })}</div>
</div>
</div>

View File

@ -21,7 +21,7 @@ const TracingIcon: FC<Props> = ({
const sizeClass = sizeClassMap[size]
return (
<div className={cn(className, sizeClass, 'bg-primary-500 shadow-md')}>
<Icon className="h-full w-full" />
<Icon className="size-full" />
</div>
)
}

View File

@ -2,7 +2,7 @@ import WorkflowApp from '@/app/components/workflow-app'
const Page = () => {
return (
<div className="h-full w-full overflow-x-auto">
<div className="size-full overflow-x-auto">
<WorkflowApp />
</div>
)

View File

@ -3,7 +3,7 @@ import RagPipeline from '@/app/components/rag-pipeline'
const PipelinePage = () => {
return (
<div className="h-full w-full overflow-x-auto">
<div className="size-full overflow-x-auto">
<RagPipeline />
</div>
)

View File

@ -3,7 +3,7 @@ import * as React from 'react'
import { AppInitializer } from '@/app/components/app-initializer'
import InSiteMessageNotification from '@/app/components/app/in-site-message/notification'
import AmplitudeProvider from '@/app/components/base/amplitude'
import GA, { GaType } from '@/app/components/base/ga'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { GotoAnything } from '@/app/components/goto-anything'
import Header from '@/app/components/header'
@ -19,7 +19,7 @@ import RoleRouteGuard from './role-route-guard'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<AppInitializer>
<AppContextProvider>

View File

@ -99,11 +99,11 @@ const FormContent = () => {
if (success) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
<RiCheckboxCircleFill className="size-8 text-text-success" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
@ -127,11 +127,11 @@ const FormContent = () => {
if (expired) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
@ -155,11 +155,11 @@ const FormContent = () => {
if (submitted) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiInformation2Fill className="size-8 text-text-accent" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
@ -183,11 +183,11 @@ const FormContent = () => {
if (rateLimitExceeded) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
@ -209,11 +209,11 @@ const FormContent = () => {
if (!formData) {
return (
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
<div className={cn('flex size-full flex-col items-center justify-center')}>
<div className="max-w-[640px] min-w-[480px]">
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-xs">
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
<div className="flex size-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
<RiErrorWarningFill className="size-8 text-text-destructive" />
</div>
<div className="grow">
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>

View File

@ -82,7 +82,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
if (userCanAccessApp && !userCanAccessApp.result) {
return (
<div className="flex h-full flex-col items-center justify-center gap-y-2">
<AppUnavailable className="h-auto w-auto" code={403} unknownReason="no permission." />
<AppUnavailable className="size-auto" code={403} unknownReason="no permission." />
<span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>{t('userProfile.logout', { ns: 'common' })}</span>
</div>
)

View File

@ -94,7 +94,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
if (message) {
return (
<div className="flex h-full flex-col items-center justify-center gap-y-4">
<AppUnavailable className="h-auto w-auto" code={code || t('common.appUnavailable', { ns: 'share' })} unknownReason={message} />
<AppUnavailable className="size-auto" code={code || t('common.appUnavailable', { ns: 'share' })} unknownReason={message} />
<span className="cursor-pointer system-sm-regular text-text-tertiary" onClick={backToHome}>{code === '403' ? t('userProfile.logout', { ns: 'common' }) : t('login.backToHome', { ns: 'share' })}</span>
</div>
)

View File

@ -59,8 +59,8 @@ export default function CheckCode() {
return (
<div className="flex flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg">
<RiMailSendFill className="h-6 w-6 text-2xl" />
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg">
<RiMailSendFill className="size-6 text-2xl" />
</div>
<div className="pt-2 pb-4">
<h2 className="title-4xl-semi-bold text-text-primary">{t('checkCode.checkYourEmail', { ns: 'login' })}</h2>

View File

@ -61,8 +61,8 @@ export default function CheckCode() {
return (
<div className="flex flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiLockPasswordLine className="h-6 w-6 text-2xl text-text-accent-light-mode-only" />
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiLockPasswordLine className="size-6 text-2xl text-text-accent-light-mode-only" />
</div>
<div className="pt-2 pb-4">
<h2 className="title-4xl-semi-bold text-text-primary">{t('resetPassword', { ns: 'login' })}</h2>

View File

@ -164,8 +164,8 @@ const ChangePasswordForm = () => {
{showSuccess && (
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className="h-6 w-6 text-text-success" />
<div className="mb-3 flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className="size-6 text-text-success" />
</div>
<h2 className="title-4xl-semi-bold text-text-primary">
{t('passwordChangedTip', { ns: 'login' })}

View File

@ -97,8 +97,8 @@ export default function CheckCode() {
return (
<div className="flex w-[400px] flex-col gap-3">
<div className="inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiMailSendFill className="h-6 w-6 text-2xl text-text-accent-light-mode-only" />
<div className="inline-flex size-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg">
<RiMailSendFill className="size-6 text-2xl text-text-accent-light-mode-only" />
</div>
<div className="pt-2 pb-4">
<h2 className="title-4xl-semi-bold text-text-primary">{t('checkCode.checkYourEmail', { ns: 'login' })}</h2>

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