mirror of
https://github.com/langgenius/dify.git
synced 2026-05-21 01:07:03 +08:00
Compare commits
33 Commits
laipz8200/
...
codex/inpu
| Author | SHA1 | Date | |
|---|---|---|---|
| 48904952ca | |||
| 9f9cb4d17e | |||
| 0fbaee46a5 | |||
| 35bce2b3d7 | |||
| dc92130b29 | |||
| 3aa63d9665 | |||
| 166c869fe9 | |||
| 7d0d9019d8 | |||
| d646bcf257 | |||
| e3b45a48eb | |||
| 848c15a265 | |||
| be8627233d | |||
| 1fe8b7fb1d | |||
| 5a585c8618 | |||
| cc9b90a5ae | |||
| b64d4b53ca | |||
| 5cdf4e405b | |||
| 7cb14cb4cc | |||
| de38bba99b | |||
| f04d809426 | |||
| 7ed3c7c500 | |||
| 77f1aeb1ac | |||
| 7bc5c89e3c | |||
| 718ab8433e | |||
| 8f197c5a0a | |||
| 0295862d0d | |||
| 2b2a5824c1 | |||
| 468cc19e68 | |||
| 77333e57a7 | |||
| f52491e2c1 | |||
| 05408af8a1 | |||
| d3ae074456 | |||
| 0b48a7e991 |
@ -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
|
||||
|
||||
|
||||
@ -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
44
.github/CODEOWNERS
vendored
@ -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
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@ -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
|
||||
|
||||
2
.github/workflows/api-tests.yml
vendored
2
.github/workflows/api-tests.yml
vendored
@ -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
|
||||
|
||||
12
.github/workflows/build-push.yml
vendored
12
.github/workflows/build-push.yml
vendored
@ -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"
|
||||
|
||||
18
.github/workflows/docker-build.yml
vendored
18
.github/workflows/docker-build.yml
vendored
@ -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"
|
||||
|
||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -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 }}
|
||||
|
||||
6
.github/workflows/web-tests.yml
vendored
6
.github/workflows/web-tests.yml
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
25
api/Dockerfile.dockerignore
Normal file
25
api/Dockerfile.dockerignore
Normal 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
|
||||
@ -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],
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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)."
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
186
api/uv.lock
generated
@ -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]]
|
||||
|
||||
@ -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
22
dify-agent/uv.lock
generated
@ -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"]
|
||||
|
||||
|
||||
@ -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=*
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
@ -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 '{
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
@ -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"
|
||||
},
|
||||
|
||||
@ -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.
|
||||
@ -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."
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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()))
|
||||
@ -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"
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
|
||||
30
packages/dify-ui/src/form-control-shared.ts
Normal file
30
packages/dify-ui/src/form-control-shared.ts
Normal 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']>
|
||||
83
packages/dify-ui/src/input/__tests__/index.spec.tsx
Normal file
83
packages/dify-ui/src/input/__tests__/index.spec.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
124
packages/dify-ui/src/input/index.stories.tsx
Normal file
124
packages/dify-ui/src/input/index.stories.tsx
Normal 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>
|
||||
),
|
||||
}
|
||||
31
packages/dify-ui/src/input/index.tsx
Normal file
31
packages/dify-ui/src/input/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
82
packages/dify-ui/src/radio-group/__tests__/index.spec.tsx
Normal file
82
packages/dify-ui/src/radio-group/__tests__/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
217
packages/dify-ui/src/radio-group/index.stories.tsx
Normal file
217
packages/dify-ui/src/radio-group/index.stories.tsx
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
23
packages/dify-ui/src/radio-group/index.tsx
Normal file
23
packages/dify-ui/src/radio-group/index.tsx
Normal 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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
178
packages/dify-ui/src/radio/__tests__/index.spec.tsx
Normal file
178
packages/dify-ui/src/radio/__tests__/index.spec.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
147
packages/dify-ui/src/radio/index.stories.tsx
Normal file
147
packages/dify-ui/src/radio/index.stories.tsx
Normal 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.',
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
105
packages/dify-ui/src/radio/index.tsx
Normal file
105
packages/dify-ui/src/radio/index.tsx
Normal 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
3351
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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' })}
|
||||
|
||||
@ -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
Reference in New Issue
Block a user