Compare commits

..
Author SHA1 Message Date
hj24 0b96062be0 fix: save and sync partner cookie failed 2026-04-01 23:47:02 +08:00
6237 changed files with 129214 additions and 284600 deletions
@@ -367,7 +367,7 @@ For each extraction:
┌────────────────────────────────────────┐ ┌────────────────────────────────────────┐
│ 1. Extract code │ │ 1. Extract code │
│ 2. Run: pnpm lint:fix │ │ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check │ 3. Run: pnpm type-check:tsgo
│ 4. Run: pnpm test │ │ 4. Run: pnpm test │
│ 5. Test functionality manually │ │ 5. Test functionality manually │
│ 6. PASS? → Next extraction │ │ 6. PASS? → Next extraction │
@@ -1,79 +0,0 @@
---
name: e2e-cucumber-playwright
description: Write, update, or review Dify end-to-end tests under `e2e/` that use Cucumber, Gherkin, and Playwright. Use when the task involves `.feature` files, `features/step-definitions/`, `features/support/`, `DifyWorld`, scenario tags, locator/assertion choices, or E2E testing best practices for this repository.
---
# Dify E2E Cucumber + Playwright
Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) as the canonical guide for local architecture and conventions, then apply Playwright/Cucumber best practices only where they fit the current suite.
## Scope
- Use this skill for `.feature` files, Cucumber step definitions, `DifyWorld`, hooks, tags, and E2E review work under `e2e/`.
- Do not use this skill for Vitest or React Testing Library work under `web/`; use `frontend-testing` instead.
- Do not use this skill for backend test or API review tasks under `api/`.
## Read Order
1. Read [`e2e/AGENTS.md`](../../../e2e/AGENTS.md) first.
2. Read only the files directly involved in the task:
- target `.feature` files under `e2e/features/`
- related step files under `e2e/features/step-definitions/`
- `e2e/features/support/hooks.ts` and `e2e/features/support/world.ts` when session lifecycle or shared state matters
- `e2e/scripts/run-cucumber.ts` and `e2e/cucumber.config.ts` when tags or execution flow matter
3. Read [`references/playwright-best-practices.md`](references/playwright-best-practices.md) only when locator, assertion, isolation, or waiting choices are involved.
4. Read [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md) only when scenario wording, step granularity, tags, or expression design are involved.
5. Re-check official docs with Context7 before introducing a new Playwright or Cucumber pattern.
## Local Rules
- `e2e/` uses Cucumber for scenarios and Playwright as the browser layer.
- `DifyWorld` is the per-scenario context object. Type `this` as `DifyWorld` and use `async function`, not arrow functions.
- Keep glue organized by capability under `e2e/features/step-definitions/`; use `common/` only for broadly reusable steps.
- Browser session behavior comes from `features/support/hooks.ts`:
- default: authenticated session with shared storage state
- `@unauthenticated`: clean browser context
- `@authenticated`: readability/selective-run tag only unless implementation changes
- `@fresh`: only for `e2e:full*` flows
- Do not import Playwright Test runner patterns that bypass the current Cucumber + `DifyWorld` architecture unless the task is explicitly about changing that architecture.
## Workflow
1. Rebuild local context.
- Inspect the target feature area.
- Reuse an existing step when wording and behavior already match.
- Add a new step only for a genuinely new user action or assertion.
- Keep edits close to the current capability folder unless the step is broadly reusable.
2. Write behavior-first scenarios.
- Describe user-observable behavior, not DOM mechanics.
- Keep each scenario focused on one workflow or outcome.
- Keep scenarios independent and re-runnable.
3. Write step definitions in the local style.
- Keep one step to one user-visible action or one assertion.
- Prefer Cucumber Expressions such as `{string}` and `{int}`.
- Scope locators to stable containers when the page has repeated elements.
- Avoid page-object layers or extra helper abstractions unless repeated complexity clearly justifies them.
4. Use Playwright in the local style.
- Prefer user-facing locators: `getByRole`, `getByLabel`, `getByPlaceholder`, `getByText`, then `getByTestId` for explicit contracts.
- Use web-first `expect(...)` assertions.
- Do not use `waitForTimeout`, manual polling, or raw visibility checks when a locator action or retrying assertion already expresses the behavior.
5. Validate narrowly.
- Run the narrowest tagged scenario or flow that exercises the change.
- Run `pnpm -C e2e check`.
- Broaden verification only when the change affects hooks, tags, setup, or shared step semantics.
## Review Checklist
- Does the scenario describe behavior rather than implementation?
- Does it fit the current session model, tags, and `DifyWorld` usage?
- Should an existing step be reused instead of adding a new one?
- Are locators user-facing and assertions web-first?
- Does the change introduce hidden coupling across scenarios, tags, or instance state?
- Does it document or implement behavior that differs from the real hooks or configuration?
Lead findings with correctness, flake risk, and architecture drift.
## References
- [`references/playwright-best-practices.md`](references/playwright-best-practices.md)
- [`references/cucumber-best-practices.md`](references/cucumber-best-practices.md)
@@ -1,4 +0,0 @@
interface:
display_name: "E2E Cucumber + Playwright"
short_description: "Write and review Dify E2E scenarios."
default_prompt: "Use $e2e-cucumber-playwright to write or review a Dify E2E scenario under e2e/."
@@ -1,93 +0,0 @@
# Cucumber Best Practices For Dify E2E
Use this reference when writing or reviewing Gherkin scenarios, step definitions, parameter expressions, and step reuse in Dify's `e2e/` suite.
Official sources:
- https://cucumber.io/docs/guides/10-minute-tutorial/
- https://cucumber.io/docs/cucumber/step-definitions/
- https://cucumber.io/docs/cucumber/cucumber-expressions/
## What Matters Most
### 1. Treat scenarios as executable specifications
Cucumber scenarios should describe examples of behavior, not test implementation recipes.
Apply it like this:
- write what the user does and what should happen
- avoid UI-internal wording such as selector details, DOM structure, or component names
- keep language concrete enough that the scenario reads like living documentation
### 2. Keep scenarios focused
A scenario should usually prove one workflow or business outcome. If a scenario wanders across several unrelated behaviors, split it.
In Dify's suite, this means:
- one capability-focused scenario per feature path
- no long setup chains when existing bootstrap or reusable steps already cover them
- no hidden dependency on another scenario's side effects
### 3. Reuse steps, but only when behavior really matches
Good reuse reduces duplication. Bad reuse hides meaning.
Prefer reuse when:
- the user action is genuinely the same
- the expected outcome is genuinely the same
- the wording stays natural across features
Write a new step when:
- the behavior is materially different
- reusing the old wording would make the scenario misleading
- a supposedly generic step would become an implementation-detail wrapper
### 4. Prefer Cucumber Expressions
Use Cucumber Expressions for parameters unless regex is clearly necessary.
Common examples:
- `{string}` for labels, names, and visible text
- `{int}` for counts
- `{float}` for decimal values
- `{word}` only when the value is truly a single token
Keep expressions readable. If a step needs complicated parsing logic, first ask whether the scenario wording should be simpler.
### 5. Keep step definitions thin and meaningful
Step definitions are glue between Gherkin and automation, not a second abstraction language.
For Dify:
- type `this` as `DifyWorld`
- use `async function`
- keep each step to one user-visible action or assertion
- rely on `DifyWorld` and existing support code for shared context
- avoid leaking cross-scenario state
### 6. Use tags intentionally
Tags should communicate run scope or session semantics, not become ad hoc metadata.
In Dify's current suite:
- capability tags group related scenarios
- `@unauthenticated` changes session behavior
- `@authenticated` is descriptive/selective, not a behavior switch by itself
- `@fresh` belongs to reset/full-install flows only
If a proposed tag implies behavior, verify that hooks or runner configuration actually implement it.
## Review Questions
- Does the scenario read like a real example of product behavior?
- Are the steps behavior-oriented instead of implementation-oriented?
- Is a reused step still truthful in this feature?
- Is a new tag documenting real behavior, or inventing semantics that the suite does not implement?
- Would a new reader understand the outcome without opening the step-definition file?
@@ -1,96 +0,0 @@
# Playwright Best Practices For Dify E2E
Use this reference when writing or reviewing locator, assertion, isolation, or synchronization logic for Dify's Cucumber-based E2E suite.
Official sources:
- https://playwright.dev/docs/best-practices
- https://playwright.dev/docs/locators
- https://playwright.dev/docs/test-assertions
- https://playwright.dev/docs/browser-contexts
## What Matters Most
### 1. Keep scenarios isolated
Playwright's model is built around clean browser contexts so one test does not leak into another. In Dify's suite, that principle maps to per-scenario session setup in `features/support/hooks.ts` and `DifyWorld`.
Apply it like this:
- do not depend on another scenario having run first
- do not persist ad hoc scenario state outside `DifyWorld`
- do not couple ordinary scenarios to `@fresh` behavior
- when a flow needs special auth/session semantics, express that through the existing tag model or explicit hook changes
### 2. Prefer user-facing locators
Playwright recommends built-in locators that reflect what users perceive on the page.
Preferred order in this repository:
1. `getByRole`
2. `getByLabel`
3. `getByPlaceholder`
4. `getByText`
5. `getByTestId` when an explicit test contract is the most stable option
Avoid raw CSS/XPath selectors unless no stable user-facing contract exists and adding one is not practical.
Also remember:
- repeated content usually needs scoping to a stable container
- exact text matching is often too brittle when role/name or label already exists
- `getByTestId` is acceptable when semantics are weak but the contract is intentional
### 3. Use web-first assertions
Playwright assertions auto-wait and retry. Prefer them over manual state inspection.
Prefer:
- `await expect(page).toHaveURL(...)`
- `await expect(locator).toBeVisible()`
- `await expect(locator).toBeHidden()`
- `await expect(locator).toBeEnabled()`
- `await expect(locator).toHaveText(...)`
Avoid:
- `expect(await locator.isVisible()).toBe(true)`
- custom polling loops for DOM state
- `waitForTimeout` as synchronization
If a condition genuinely needs custom retry logic, use Playwright's polling/assertion tools deliberately and keep that choice local and explicit.
### 4. Let actions wait for actionability
Locator actions already wait for the element to be actionable. Do not preface every click/fill with extra timing logic unless the action needs a specific visible/ready assertion for clarity.
Good pattern:
- assert a meaningful visible state when that is part of the behavior
- then click/fill/select via locator APIs
Bad pattern:
- stack arbitrary waits before every action
- wait on unstable implementation details instead of the visible state the user cares about
### 5. Match debugging to the current suite
Playwright's wider ecosystem supports traces and rich debugging tools. Dify's current suite already captures:
- full-page screenshots
- page HTML
- console errors
- page errors
Use the existing artifact flow by default. If a task is specifically about improving diagnostics, confirm the change fits the current Cucumber architecture before importing broader Playwright tooling.
## Review Questions
- Would this locator survive DOM refactors that do not change user-visible behavior?
- Is this assertion using Playwright's retrying semantics?
- Is any explicit wait masking a real readiness problem?
- Does this code preserve per-scenario isolation?
- Is a new abstraction really needed, or does it bypass the existing `DifyWorld` + step-definition model?
@@ -1,6 +1,6 @@
--- ---
name: frontend-query-mutation name: frontend-query-mutation
description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions()/mutationOptions() directly or extract a helper or use-* hook, configuring oRPC experimental_defaults/default options, handling conditional queries, cache updates/invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers. description: Guide for implementing Dify frontend query and mutation patterns with TanStack Query and oRPC. Trigger when creating or updating contracts in web/contract, wiring router composition, consuming consoleQuery or marketplaceQuery in components or services, deciding whether to call queryOptions() directly or extract a helper or use-* hook, handling conditional queries, cache invalidation, mutation error handling, or migrating legacy service calls to contract-first query and mutation helpers.
--- ---
# Frontend Query & Mutation # Frontend Query & Mutation
@@ -9,24 +9,22 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
- Keep contract as the single source of truth in `web/contract/*`. - Keep contract as the single source of truth in `web/contract/*`.
- Prefer contract-shaped `queryOptions()` and `mutationOptions()`. - Prefer contract-shaped `queryOptions()` and `mutationOptions()`.
- Keep default cache behavior with `consoleQuery`/`marketplaceQuery` setup, and keep business orchestration in feature vertical hooks when direct contract calls are not enough. - Keep invalidation and mutation flow knowledge in the service layer.
- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination.
- Keep abstractions minimal to preserve TypeScript inference. - Keep abstractions minimal to preserve TypeScript inference.
## Workflow ## Workflow
1. Identify the change surface. 1. Identify the change surface.
- Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape. - Read `references/contract-patterns.md` for contract files, router composition, client helpers, and query or mutation call-site shape.
- Read `references/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations. - Read `references/runtime-rules.md` for conditional queries, invalidation, error handling, and legacy migrations.
- Read both references when a task spans contract shape and runtime behavior. - Read both references when a task spans contract shape and runtime behavior.
2. Implement the smallest abstraction that fits the task. 2. Implement the smallest abstraction that fits the task.
- Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site. - Default to direct `useQuery(...)` or `useMutation(...)` calls with oRPC helpers at the call site.
- Extract a small shared query helper only when multiple call sites share the same extra options. - Extract a small shared query helper only when multiple call sites share the same extra options.
- Create or keep feature hooks only for real orchestration or shared domain behavior. - Create `web/service/use-{domain}.ts` only for orchestration or shared domain behavior.
- When touching thin `web/service/use-*` wrappers, migrate them away when feasible.
3. Preserve Dify conventions. 3. Preserve Dify conventions.
- Keep contract inputs in `{ params, query?, body? }` shape. - Keep contract inputs in `{ params, query?, body? }` shape.
- Bind default cache updates/invalidation in `createTanstackQueryUtils(...experimental_defaults...)`; use feature hooks only for workflows that cannot be expressed as default operation behavior. - Bind invalidation in the service-layer mutation definition.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required. - Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
## Files Commonly Touched ## Files Commonly Touched
@@ -35,7 +33,7 @@ description: Guide for implementing Dify frontend query and mutation patterns wi
- `web/contract/marketplace.ts` - `web/contract/marketplace.ts`
- `web/contract/router.ts` - `web/contract/router.ts`
- `web/service/client.ts` - `web/service/client.ts`
- legacy `web/service/use-*.ts` files when migrating wrappers away - `web/service/use-*.ts`
- component and hook call sites using `consoleQuery` or `marketplaceQuery` - component and hook call sites using `consoleQuery` or `marketplaceQuery`
## References ## References
@@ -1,4 +1,4 @@
interface: interface:
display_name: "Frontend Query & Mutation" display_name: "Frontend Query & Mutation"
short_description: "Dify TanStack Query, oRPC, and default option patterns" short_description: "Dify TanStack Query and oRPC patterns"
default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, oRPC default options, conditional queries, cache updates/invalidation, or legacy query/mutation migrations." default_prompt: "Use this skill when implementing or reviewing Dify frontend contracts, query and mutation call sites, conditional queries, invalidation, or legacy query/mutation migrations."
@@ -7,7 +7,6 @@
- Core workflow - Core workflow
- Query usage decision rule - Query usage decision rule
- Mutation usage decision rule - Mutation usage decision rule
- Thin hook decision rule
- Anti-patterns - Anti-patterns
- Contract rules - Contract rules
- Type export - Type export
@@ -56,13 +55,9 @@ const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
1. Default to direct `*.queryOptions(...)` usage at the call site. 1. Default to direct `*.queryOptions(...)` usage at the call site.
2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook. 2. If 3 or more call sites share the same extra options, extract a small query helper, not a `use-*` passthrough hook.
3. Create or keep feature hooks only for orchestration. 3. Create `web/service/use-{domain}.ts` only for orchestration.
- Combine multiple queries or mutations. - Combine multiple queries or mutations.
- Share domain-level derived state or invalidation helpers. - Share domain-level derived state or invalidation helpers.
- Prefer `web/features/{domain}/hooks/*` for feature-owned workflows.
4. Treat `web/service/use-{domain}.ts` as legacy.
- Do not create new thin service wrappers for oRPC contracts.
- When touching existing wrappers, inline direct `consoleQuery` or `marketplaceQuery` consumption when the wrapper is only a passthrough.
```typescript ```typescript
const invoicesBaseQueryOptions = () => const invoicesBaseQueryOptions = () =>
@@ -79,37 +74,11 @@ const invoiceQuery = useQuery({
1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`. 1. Default to mutation helpers from `consoleQuery` or `marketplaceQuery`, for example `useMutation(consoleQuery.billing.bindPartnerStack.mutationOptions(...))`.
2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic. 2. If the mutation flow is heavily custom, use oRPC clients as `mutationFn`, for example `consoleClient.xxx` or `marketplaceClient.xxx`, instead of handwritten non-oRPC mutation logic.
```typescript
const createTagMutation = useMutation(consoleQuery.tags.create.mutationOptions())
```
## Thin Hook Decision Rule
Remove thin hooks when they only rename a single oRPC query or mutation helper.
Keep hooks when they orchestrate business behavior across multiple operations, own local workflow state, or normalize a feature-specific API.
Prefer feature vertical hooks for kept orchestration. Do not move new contract-first wrappers into `web/service/use-*`.
Use:
```typescript
const deleteTagMutation = useMutation(consoleQuery.tags.delete.mutationOptions())
```
Keep:
```typescript
const applyTagBindingsMutation = useApplyTagBindingsMutation()
```
`useApplyTagBindingsMutation` is acceptable because it coordinates bind and unbind requests, computes deltas, and exposes a feature-level workflow rather than a single endpoint passthrough.
## Anti-Patterns ## Anti-Patterns
- Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`. - Do not wrap `useQuery` with `options?: Partial<UseQueryOptions>`.
- Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case. - Do not split local `queryKey` and `queryFn` when oRPC `queryOptions` already exists and fits the use case.
- Do not create thin `use-*` passthrough hooks for a single endpoint. - Do not create thin `use-*` passthrough hooks for a single endpoint.
- Do not create business-layer helpers whose only purpose is to call `consoleQuery.xxx.mutationOptions()` or `queryOptions()`.
- Do not introduce new `web/service/use-*` files for oRPC contract passthroughs.
- These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection. - These patterns can degrade inference, especially around `throwOnError` and `select`, and add unnecessary indirection.
## Contract Rules ## Contract Rules
@@ -3,7 +3,6 @@
## Table of Contents ## Table of Contents
- Conditional queries - Conditional queries
- oRPC default options
- Cache invalidation - Cache invalidation
- Key API guide - Key API guide
- `mutate` vs `mutateAsync` - `mutate` vs `mutateAsync`
@@ -36,50 +35,9 @@ function useBadAccessMode(appId: string | undefined) {
} }
``` ```
## oRPC Default Options
Use `experimental_defaults` in `createTanstackQueryUtils` when a contract operation should always carry shared TanStack Query behavior, such as default stale time, mutation cache writes, or invalidation.
Place defaults at the query utility creation point in `web/service/client.ts`:
```typescript
export const consoleQuery = createTanstackQueryUtils(consoleClient, {
path: ['console'],
experimental_defaults: {
tags: {
create: {
mutationOptions: {
onSuccess: (tag, _variables, _result, context) => {
context.client.setQueryData(
consoleQuery.tags.list.queryKey({
input: {
query: {
type: tag.type,
},
},
}),
(oldTags: Tag[] | undefined) => oldTags ? [tag, ...oldTags] : oldTags,
)
},
},
},
},
},
})
```
Rules:
- Keep defaults inline in the `consoleQuery` or `marketplaceQuery` initialization when they need sibling oRPC key builders.
- Do not create a wrapper function solely to host `createTanstackQueryUtils`.
- Do not split defaults into a vertical feature file if that forces handwritten operation paths such as `generateOperationKey(['console', ...])`.
- Keep feature-level orchestration in the feature vertical; keep query utility lifecycle defaults with the query utility.
- Prefer call-site callbacks for UI feedback only; shared cache behavior belongs in oRPC defaults when it is tied to a contract operation.
## Cache Invalidation ## Cache Invalidation
Bind shared invalidation in oRPC defaults when it is tied to a contract operation. Bind invalidation in the service-layer mutation definition.
Use feature vertical hooks only for multi-operation workflows or domain orchestration that cannot live in a single operation default.
Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate. Components may add UI feedback in call-site callbacks, but they should not decide which queries to invalidate.
Use: Use:
@@ -91,7 +49,7 @@ Use:
Do not use deprecated `useInvalid` from `use-base.ts`. Do not use deprecated `useInvalid` from `use-base.ts`.
```typescript ```typescript
// Feature orchestration owns cache invalidation only when defaults are not enough. // Service layer owns cache invalidation.
export const useUpdateAccessMode = () => { export const useUpdateAccessMode = () => {
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -106,7 +64,7 @@ export const useUpdateAccessMode = () => {
// Component only adds UI behavior. // Component only adds UI behavior.
updateAccessMode({ appId, mode }, { updateAccessMode({ appId, mode }, {
onSuccess: () => toast.success('...'), onSuccess: () => Toast.notify({ type: 'success', message: '...' }),
}) })
// Avoid putting invalidation knowledge in the component. // Avoid putting invalidation knowledge in the component.
@@ -156,7 +114,10 @@ try {
router.push(`/orders/${order.id}`) router.push(`/orders/${order.id}`)
} }
catch (error) { catch (error) {
toast.error(error instanceof Error ? error.message : 'Unknown error') Toast.notify({
type: 'error',
message: error instanceof Error ? error.message : 'Unknown error',
})
} }
``` ```
@@ -166,7 +127,7 @@ When touching old code, migrate it toward these rules:
| Old pattern | New pattern | | Old pattern | New pattern |
|---|---| |---|---|
| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration | | `useInvalid(key)` in service layer | `queryClient.invalidateQueries(...)` inside mutation `onSuccess` |
| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook | | component-triggered invalidation after mutation | move invalidation into the service-layer mutation definition |
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` | | imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` | | `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |
+3 -3
View File
@@ -200,7 +200,7 @@ When assigned to test a directory/path, test **ALL content** within that path:
-**Import real project components** directly (including base components and siblings) -**Import real project components** directly (including base components and siblings)
-**Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers -**Only mock**: API services (`@/service/*`), `next/navigation`, complex context providers
-**DO NOT mock** base components (`@/app/components/base/*`) or dify-ui primitives (`@langgenius/dify-ui/*`) -**DO NOT mock** base components (`@/app/components/base/*`)
-**DO NOT mock** sibling/child components in the same directory -**DO NOT mock** sibling/child components in the same directory
> See [Test Structure Template](#test-structure-template) for correct import/mock patterns. > See [Test Structure Template](#test-structure-template) for correct import/mock patterns.
@@ -325,12 +325,12 @@ For more detailed information, refer to:
### Reference Examples in Codebase ### Reference Examples in Codebase
- `web/utils/classnames.spec.ts` - Utility function tests - `web/utils/classnames.spec.ts` - Utility function tests
- `web/app/components/base/radio/__tests__/index.spec.tsx` - Component tests - `web/app/components/base/button/index.spec.tsx` - Component tests
- `web/__mocks__/provider-context.ts` - Mock factory example - `web/__mocks__/provider-context.ts` - Mock factory example
### Project Configuration ### Project Configuration
- `web/vite.config.ts` - Vite/Vitest configuration - `web/vitest.config.ts` - Vitest configuration
- `web/vitest.setup.ts` - Test environment setup - `web/vitest.setup.ts` - Test environment setup
- `web/scripts/analyze-component.js` - Component analysis tool - `web/scripts/analyze-component.js` - Component analysis tool
- Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files. - Modules are not mocked automatically. Global mocks live in `web/vitest.setup.ts` (for example `react-i18next`, `next/image`); mock other modules like `ky` or `mime` locally in test files.
@@ -36,7 +36,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
### Integration vs Mocking ### Integration vs Mocking
- [ ] **DO NOT mock base components or dify-ui primitives** (base `Loading`, `Input`, `Badge`; dify-ui `Button`, `Tooltip`, `Dialog`, etc.) - [ ] **DO NOT mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
- [ ] Import real project components instead of mocking - [ ] Import real project components instead of mocking
- [ ] Only mock: API calls, complex context providers, third-party libs with side effects - [ ] Only mock: API calls, complex context providers, third-party libs with side effects
- [ ] Prefer integration testing when using single spec file - [ ] Prefer integration testing when using single spec file
@@ -73,7 +73,7 @@ Use this checklist when generating or reviewing tests for Dify frontend componen
### Mocks ### Mocks
- [ ] **DO NOT mock base components or dify-ui primitives** (`@/app/components/base/*` or `@langgenius/dify-ui/*`) - [ ] **DO NOT mock base components** (`@/app/components/base/*`)
- [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`) - [ ] `vi.clearAllMocks()` in `beforeEach` (not `afterEach`)
- [ ] Shared mock state reset in `beforeEach` - [ ] Shared mock state reset in `beforeEach`
- [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations - [ ] i18n uses global mock (auto-loaded in `web/vitest.setup.ts`); only override locally for custom translations
@@ -127,7 +127,7 @@ For the current file being tested:
- [ ] Run full directory test: `pnpm test path/to/directory/` - [ ] Run full directory test: `pnpm test path/to/directory/`
- [ ] Check coverage report: `pnpm test:coverage` - [ ] Check coverage report: `pnpm test:coverage`
- [ ] Run `pnpm lint:fix` on all test files - [ ] Run `pnpm lint:fix` on all test files
- [ ] Run `pnpm type-check` - [ ] Run `pnpm type-check:tsgo`
## Common Issues to Watch ## Common Issues to Watch
@@ -2,27 +2,29 @@
## ⚠️ Important: What NOT to Mock ## ⚠️ Important: What NOT to Mock
### DO NOT Mock Base Components or dify-ui Primitives ### DO NOT Mock Base Components
**Never mock components from `@/app/components/base/` or from `@langgenius/dify-ui/*`** such as: **Never mock components from `@/app/components/base/`** such as:
- Legacy base (`@/app/components/base/*`): `Loading`, `Spinner`, `Input`, `Badge`, `Tag` - `Loading`, `Spinner`
- dify-ui primitives (`@langgenius/dify-ui/*`): `Button`, `Tooltip`, `Dialog`, `Popover`, `DropdownMenu`, `ContextMenu`, `Select`, `AlertDialog`, `Toast` - `Button`, `Input`, `Select`
- `Tooltip`, `Modal`, `Dropdown`
- `Icon`, `Badge`, `Tag`
**Why?** **Why?**
- These components have their own dedicated tests - Base components will have their own dedicated tests
- Mocking them creates false positives (tests pass but real integration fails) - Mocking them creates false positives (tests pass but real integration fails)
- Using real components tests actual integration behavior - Using real components tests actual integration behavior
```typescript ```typescript
// ❌ WRONG: Don't mock base components or dify-ui primitives // ❌ WRONG: Don't mock base components
vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>) vi.mock('@/app/components/base/loading', () => () => <div>Loading</div>)
vi.mock('@langgenius/dify-ui/button', () => ({ Button: ({ children }: any) => <button>{children}</button> })) vi.mock('@/app/components/base/button', () => ({ children }: any) => <button>{children}</button>)
// ✅ CORRECT: Import and use the real components // ✅ CORRECT: Import and use real base components
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import { Button } from '@langgenius/dify-ui/button' import Button from '@/app/components/base/button'
// They will render normally in tests // They will render normally in tests
``` ```
@@ -317,7 +319,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
### ✅ DO ### ✅ DO
1. **Use real base components and dify-ui primitives** - Import from `@/app/components/base/` or `@langgenius/dify-ui/*` directly 1. **Use real base components** - Import from `@/app/components/base/` directly
1. **Use real project components** - Prefer importing over mocking 1. **Use real project components** - Prefer importing over mocking
1. **Use real Zustand stores** - Set test state via `store.setState()` 1. **Use real Zustand stores** - Set test state via `store.setState()`
1. **Reset mocks in `beforeEach`**, not `afterEach` 1. **Reset mocks in `beforeEach`**, not `afterEach`
@@ -328,7 +330,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
### ❌ DON'T ### ❌ DON'T
1. **Don't mock base components or dify-ui primitives** (`Loading`, `Input`, `Button`, `Tooltip`, `Dialog`, etc.) 1. **Don't mock base components** (`Loading`, `Button`, `Tooltip`, etc.)
1. **Don't mock Zustand store modules** - Use real stores with `setState()` 1. **Don't mock Zustand store modules** - Use real stores with `setState()`
1. Don't mock components you can import directly 1. Don't mock components you can import directly
1. Don't create overly simplified mocks that miss conditional logic 1. Don't create overly simplified mocks that miss conditional logic
@@ -340,7 +342,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
``` ```
Need to use a component in test? Need to use a component in test?
├─ Is it from @/app/components/base/* or @langgenius/dify-ui/*? ├─ Is it from @/app/components/base/*?
│ └─ YES → Import real component, DO NOT mock │ └─ YES → Import real component, DO NOT mock
├─ Is it a project component? ├─ Is it a project component?
-1
View File
@@ -1 +0,0 @@
../../.agents/skills/e2e-cucumber-playwright
+1 -1
View File
@@ -7,7 +7,7 @@ cd web && pnpm install
pipx install uv pipx install uv
echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc echo "alias start-api=\"cd $WORKSPACE_ROOT/api && uv run python -m flask run --host 0.0.0.0 --port=5001 --debug\"" >> ~/.bashrc
echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention\"" >> ~/.bashrc echo "alias start-worker=\"cd $WORKSPACE_ROOT/api && uv run python -m celery -A app.celery worker -P threads -c 1 --loglevel INFO -Q dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_executor,retention\"" >> ~/.bashrc
echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc echo "alias start-web=\"cd $WORKSPACE_ROOT/web && pnpm dev:inspect\"" >> ~/.bashrc
echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc echo "alias start-web-prod=\"cd $WORKSPACE_ROOT/web && pnpm build && pnpm start\"" >> ~/.bashrc
echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc echo "alias start-containers=\"cd $WORKSPACE_ROOT/docker && docker-compose -f docker-compose.middleware.yaml -p dify --env-file middleware.env up -d\"" >> ~/.bashrc
-3
View File
@@ -6,9 +6,6 @@
* @crazywoola @laipz8200 @Yeuoly * @crazywoola @laipz8200 @Yeuoly
# ESLint suppression file is maintained by autofix.ci pruning.
/eslint-suppressions.json
# CODEOWNERS file # CODEOWNERS file
/.github/CODEOWNERS @laipz8200 @crazywoola /.github/CODEOWNERS @laipz8200 @crazywoola
+1 -1
View File
@@ -4,7 +4,7 @@ runs:
using: composite using: composite
steps: steps:
- name: Setup Vite+ - name: Setup Vite+
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0 uses: voidzero-dev/setup-vp@20553a7a7429c429a74894104a2835d7fed28a72 # v1.3.0
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
cache: true cache: true
+100
View File
@@ -1,6 +1,106 @@
version: 2 version: 2
updates: updates:
- package-ecosystem: "pip"
directory: "/api"
open-pull-requests-limit: 10
schedule:
interval: "weekly"
groups:
flask:
patterns:
- "flask"
- "flask-*"
- "werkzeug"
- "gunicorn"
google:
patterns:
- "google-*"
- "googleapis-*"
opentelemetry:
patterns:
- "opentelemetry-*"
pydantic:
patterns:
- "pydantic"
- "pydantic-*"
llm:
patterns:
- "langfuse"
- "langsmith"
- "litellm"
- "mlflow*"
- "opik"
- "weave*"
- "arize*"
- "tiktoken"
- "transformers"
database:
patterns:
- "sqlalchemy"
- "psycopg2*"
- "psycogreen"
- "redis*"
- "alembic*"
storage:
patterns:
- "boto3*"
- "botocore*"
- "azure-*"
- "bce-*"
- "cos-python-*"
- "esdk-obs-*"
- "google-cloud-storage"
- "opendal"
- "oss2"
- "supabase*"
- "tos*"
vdb:
patterns:
- "alibabacloud*"
- "chromadb"
- "clickhouse-*"
- "clickzetta-*"
- "couchbase"
- "elasticsearch"
- "opensearch-py"
- "oracledb"
- "pgvect*"
- "pymilvus"
- "pymochow"
- "pyobvector"
- "qdrant-client"
- "intersystems-*"
- "tablestore"
- "tcvectordb"
- "tidb-vector"
- "upstash-*"
- "volcengine-*"
- "weaviate-*"
- "xinference-*"
- "mo-vector"
- "mysql-connector-*"
dev:
patterns:
- "coverage"
- "dotenv-linter"
- "faker"
- "lxml-stubs"
- "basedpyright"
- "ruff"
- "pytest*"
- "types-*"
- "boto3-stubs"
- "hypothesis"
- "pandas-stubs"
- "scipy-stubs"
- "import-linter"
- "celery-types"
- "mypy*"
- "pyrefly"
python-packages:
patterns:
- "*"
- package-ecosystem: "uv" - package-ecosystem: "uv"
directory: "/api" directory: "/api"
open-pull-requests-limit: 10 open-pull-requests-limit: 10
+1 -7
View File
@@ -1,9 +1,3 @@
web: web:
- changed-files: - changed-files:
- any-glob-to-any-file: - any-glob-to-any-file: 'web/**'
- 'web/**'
- 'packages/**'
- 'package.json'
- 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml'
- '.nvmrc'
+4 -5
View File
@@ -7,7 +7,6 @@
## Summary ## Summary
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. --> <!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
<!-- If this PR was created by an automated agent, add `From <Tool Name>` as the final line of the description. Example: `From Codex`. -->
## Screenshots ## Screenshots
@@ -18,7 +17,7 @@
## Checklist ## Checklist
- [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs) - [ ] This change requires a documentation update, included: [Dify Document](https://github.com/langgenius/dify-docs)
- [ ] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!) - [x] I understand that this PR may be closed in case there was no previous discussion or issues. (This doesn't apply to typos!)
- [ ] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change. - [x] I've added a test for each change that was introduced, and I tried as much as possible to make a single atomic change.
- [ ] I've updated the documentation accordingly. - [x] I've updated the documentation accordingly.
- [ ] I ran `make lint && make type-check` (backend) and `cd web && pnpm exec vp staged` (frontend) to appease the lint gods - [x] I ran `make lint` and `make type-check` (backend) and `cd web && npx lint-staged` (frontend) to appease the lint gods
-82
View File
@@ -1,82 +0,0 @@
import { execFileSync } from 'node:child_process'
import fs from 'node:fs'
import path from 'node:path'
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const outputPath = process.env.I18N_CHANGES_OUTPUT_PATH || '/tmp/i18n-changes.json'
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const currentJson = readCurrentJson(fileStem)
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = currentJson || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: currentJson === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
outputPath,
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)
+19
View File
@@ -0,0 +1,19 @@
name: Anti-Slop PR Check
on:
pull_request_target:
types: [opened, edited, synchronize]
permissions:
pull-requests: write
contents: read
jobs:
anti-slop:
runs-on: ubuntu-latest
steps:
- uses: peakoss/anti-slop@85daca1880e9e1af197fc06ea03349daf08f4202 # v0.2.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
close-pr: false
failure-add-pr-labels: "needs-revision"
+10 -10
View File
@@ -16,7 +16,7 @@ concurrency:
jobs: jobs:
api-unit: api-unit:
name: API Unit Tests name: API Unit Tests
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
env: env:
COVERAGE_FILE: coverage-unit COVERAGE_FILE: coverage-unit
defaults: defaults:
@@ -35,7 +35,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -54,7 +54,7 @@ jobs:
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
- name: Upload unit coverage data - name: Upload unit coverage data
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: api-coverage-unit name: api-coverage-unit
path: coverage-unit path: coverage-unit
@@ -62,7 +62,7 @@ jobs:
api-integration: api-integration:
name: API Integration Tests name: API Integration Tests
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
env: env:
COVERAGE_FILE: coverage-integration COVERAGE_FILE: coverage-integration
STORAGE_TYPE: opendal STORAGE_TYPE: opendal
@@ -84,7 +84,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -98,14 +98,14 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
./docker/init-env.sh cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports
run: sh .github/workflows/expose_service_ports.sh run: sh .github/workflows/expose_service_ports.sh
- name: Set up Sandbox - name: Set up Sandbox
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with: with:
compose-file: | compose-file: |
docker/docker-compose.middleware.yaml docker/docker-compose.middleware.yaml
@@ -129,7 +129,7 @@ jobs:
api/tests/test_containers_integration_tests api/tests/test_containers_integration_tests
- name: Upload integration coverage data - name: Upload integration coverage data
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: api-coverage-integration name: api-coverage-integration
path: coverage-integration path: coverage-integration
@@ -137,7 +137,7 @@ jobs:
api-coverage: api-coverage:
name: API Coverage name: API Coverage
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
needs: needs:
- api-unit - api-unit
- api-integration - api-integration
@@ -156,7 +156,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: "3.12" python-version: "3.12"
+8 -8
View File
@@ -13,7 +13,7 @@ permissions:
jobs: jobs:
autofix: autofix:
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Complete merge group check - name: Complete merge group check
if: github.event_name == 'merge_group' if: github.event_name == 'merge_group'
@@ -25,7 +25,7 @@ jobs:
- name: Check Docker Compose inputs - name: Check Docker Compose inputs
if: github.event_name != 'merge_group' if: github.event_name != 'merge_group'
id: docker-compose-changes id: docker-compose-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
docker/generate_docker_compose docker/generate_docker_compose
@@ -35,11 +35,10 @@ jobs:
- name: Check web inputs - name: Check web inputs
if: github.event_name != 'merge_group' if: github.event_name != 'merge_group'
id: web-changes id: web-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
web/** web/**
packages/**
package.json package.json
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
@@ -47,7 +46,7 @@ jobs:
- name: Check api inputs - name: Check api inputs
if: github.event_name != 'merge_group' if: github.event_name != 'merge_group'
id: api-changes id: api-changes
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
api/** api/**
@@ -57,7 +56,7 @@ jobs:
python-version: "3.11" python-version: "3.11"
- if: github.event_name != 'merge_group' - if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
- name: Generate Docker Compose - name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true' if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
@@ -113,13 +112,14 @@ jobs:
find . -name "*.py.bak" -type f -delete find . -name "*.py.bak" -type f -delete
- name: Setup web environment - name: Setup web environment
if: github.event_name != 'merge_group' if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web uses: ./.github/actions/setup-web
- name: ESLint autofix - name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true' if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: | run: |
cd web
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
- if: github.event_name != 'merge_group' - if: github.event_name != 'merge_group'
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4 uses: autofix-ci/action@7a166d7532b277f34e16238930461bf77f9d7ed8 # v1.3.3
+13 -39
View File
@@ -26,9 +26,6 @@ jobs:
build: build:
runs-on: ${{ matrix.runs_on }} runs-on: ${{ matrix.runs_on }}
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
permissions:
contents: read
id-token: write
strategy: strategy:
matrix: matrix:
include: include:
@@ -38,28 +35,28 @@ jobs:
build_context: "{{defaultContext}}:api" build_context: "{{defaultContext}}:api"
file: "Dockerfile" file: "Dockerfile"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-latest
- service_name: "build-api-arm64" - service_name: "build-api-arm64"
image_name_env: "DIFY_API_IMAGE_NAME" image_name_env: "DIFY_API_IMAGE_NAME"
artifact_context: "api" artifact_context: "api"
build_context: "{{defaultContext}}:api" build_context: "{{defaultContext}}:api"
file: "Dockerfile" file: "Dockerfile"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-24.04-arm
- service_name: "build-web-amd64" - service_name: "build-web-amd64"
image_name_env: "DIFY_WEB_IMAGE_NAME" image_name_env: "DIFY_WEB_IMAGE_NAME"
artifact_context: "web" artifact_context: "web"
build_context: "{{defaultContext}}" build_context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-latest
- service_name: "build-web-arm64" - service_name: "build-web-arm64"
image_name_env: "DIFY_WEB_IMAGE_NAME" image_name_env: "DIFY_WEB_IMAGE_NAME"
artifact_context: "web" artifact_context: "web"
build_context: "{{defaultContext}}" build_context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-24.04-arm
steps: steps:
- name: Prepare - name: Prepare
@@ -68,13 +65,13 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ env.DOCKERHUB_USER }} username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }} password: ${{ env.DOCKERHUB_TOKEN }}
- name: Set up Depot CLI - name: Set up Docker Buildx
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Extract metadata for Docker - name: Extract metadata for Docker
id: meta id: meta
@@ -84,15 +81,16 @@ jobs:
- name: Build Docker image - name: Build Docker image
id: build id: build
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }} context: ${{ matrix.build_context }}
file: ${{ matrix.file }} file: ${{ matrix.file }}
platforms: ${{ matrix.platform }} platforms: ${{ matrix.platform }}
build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }} build-args: COMMIT_SHA=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.revision'] }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true outputs: type=image,name=${{ env[matrix.image_name_env] }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ matrix.service_name }}
cache-to: type=gha,mode=max,scope=${{ matrix.service_name }}
- name: Export digest - name: Export digest
env: env:
@@ -103,40 +101,16 @@ jobs:
touch "/tmp/digests/${sanitized_digest}" touch "/tmp/digests/${sanitized_digest}"
- name: Upload digest - name: Upload digest
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: digests-${{ matrix.artifact_context }}-${{ env.PLATFORM_PAIR }} name: digests-${{ matrix.artifact_context }}-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/* path: /tmp/digests/*
if-no-files-found: error if-no-files-found: error
retention-days: 1 retention-days: 1
fork-build-validate:
if: github.repository != 'langgenius/dify'
runs-on: ubuntu-24.04
strategy:
matrix:
include:
- service_name: "validate-api-amd64"
build_context: "{{defaultContext}}:api"
file: "Dockerfile"
- service_name: "validate-web-amd64"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}
file: ${{ matrix.file }}
platforms: linux/amd64
create-manifest: create-manifest:
needs: build needs: build
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
strategy: strategy:
matrix: matrix:
@@ -156,7 +130,7 @@ jobs:
merge-multiple: true merge-multiple: true
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with: with:
username: ${{ env.DOCKERHUB_USER }} username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }} password: ${{ env.DOCKERHUB_TOKEN }}
+6 -28
View File
@@ -9,7 +9,7 @@ concurrency:
jobs: jobs:
db-migration-test-postgres: db-migration-test-postgres:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -19,7 +19,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: "3.12" python-version: "3.12"
@@ -40,7 +40,7 @@ jobs:
cp middleware.env.example middleware.env cp middleware.env.example middleware.env
- name: Set up Middlewares - name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with: with:
compose-file: | compose-file: |
docker/docker-compose.middleware.yaml docker/docker-compose.middleware.yaml
@@ -59,7 +59,7 @@ jobs:
run: uv run --directory api flask upgrade-db run: uv run --directory api flask upgrade-db
db-migration-test-mysql: db-migration-test-mysql:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: "3.12" python-version: "3.12"
@@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares - name: Set up Middlewares
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with: with:
compose-file: | compose-file: |
docker/docker-compose.middleware.yaml docker/docker-compose.middleware.yaml
@@ -110,28 +110,6 @@ jobs:
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
# hoverkraft-tech/[email protected] only waits for `docker compose up -d`
# to return (container processes started); it does not wait on healthcheck
# status. mysql:8.0's first-time init takes 15-30s, so without an explicit
# wait the migration runs while InnoDB is still initialising and gets
# killed with "Lost connection during query". Poll a real SELECT until it
# succeeds.
- name: Wait for MySQL to accept queries
run: |
set +e
for i in $(seq 1 60); do
if docker run --rm --network host mysql:8.0 \
mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
-e 'SELECT 1' >/dev/null 2>&1; then
echo "MySQL ready after ${i}s"
exit 0
fi
sleep 1
done
echo "MySQL not ready after 60s; dumping container logs:"
docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
exit 1
- name: Run DB Migration - name: Run DB Migration
env: env:
DEBUG: true DEBUG: true
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
if: | if: |
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent-dev' github.event.workflow_run.head_branch == 'deploy/agent-dev'
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
if: | if: |
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/dev' github.event.workflow_run.head_branch == 'deploy/dev'
+1 -1
View File
@@ -13,7 +13,7 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
if: | if: |
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/enterprise' github.event.workflow_run.head_branch == 'deploy/enterprise'
+1 -1
View File
@@ -10,7 +10,7 @@ on:
jobs: jobs:
deploy: deploy:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
if: | if: |
github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'build/feat/hitl' github.event.workflow_run.head_branch == 'build/feat/hitl'
+13 -37
View File
@@ -6,7 +6,12 @@ on:
- "main" - "main"
paths: paths:
- api/Dockerfile - api/Dockerfile
- web/docker/**
- web/Dockerfile - web/Dockerfile
- package.json
- pnpm-lock.yaml
- pnpm-workspace.yaml
- .nvmrc
concurrency: concurrency:
group: docker-build-${{ github.head_ref || github.run_id }} group: docker-build-${{ github.head_ref || github.run_id }}
@@ -14,59 +19,28 @@ concurrency:
jobs: jobs:
build-docker: build-docker:
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ${{ matrix.runs_on }} runs-on: ${{ matrix.runs_on }}
permissions:
contents: read
id-token: write
strategy: strategy:
matrix: matrix:
include: include:
- service_name: "api-amd64" - service_name: "api-amd64"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-latest
context: "{{defaultContext}}:api" context: "{{defaultContext}}:api"
file: "Dockerfile" file: "Dockerfile"
- service_name: "api-arm64" - service_name: "api-arm64"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-24.04-arm
context: "{{defaultContext}}:api" context: "{{defaultContext}}:api"
file: "Dockerfile" file: "Dockerfile"
- service_name: "web-amd64" - service_name: "web-amd64"
platform: linux/amd64 platform: linux/amd64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-latest
context: "{{defaultContext}}" context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"
- service_name: "web-arm64" - service_name: "web-arm64"
platform: linux/arm64 platform: linux/arm64
runs_on: depot-ubuntu-24.04-4 runs_on: ubuntu-24.04-arm
context: "{{defaultContext}}"
file: "web/Dockerfile"
steps:
- name: Set up Depot CLI
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
context: ${{ matrix.context }}
file: ${{ matrix.file }}
platforms: ${{ matrix.platform }}
build-docker-fork:
if: github.event.pull_request.head.repo.full_name != github.repository
runs-on: ubuntu-24.04
permissions:
contents: read
strategy:
matrix:
include:
- service_name: "api-amd64"
context: "{{defaultContext}}:api"
file: "Dockerfile"
- service_name: "web-amd64"
context: "{{defaultContext}}" context: "{{defaultContext}}"
file: "web/Dockerfile" file: "web/Dockerfile"
steps: steps:
@@ -74,9 +48,11 @@ jobs:
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image - name: Build Docker Image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with: with:
push: false push: false
context: ${{ matrix.context }} context: ${{ matrix.context }}
file: ${{ matrix.file }} file: ${{ matrix.file }}
platforms: linux/amd64 platforms: ${{ matrix.platform }}
cache-from: type=gha
cache-to: type=gha,mode=max
+1 -1
View File
@@ -7,7 +7,7 @@ jobs:
permissions: permissions:
contents: read contents: read
pull-requests: write pull-requests: write
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1 - uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with: with:
+12 -19
View File
@@ -23,7 +23,7 @@ concurrency:
jobs: jobs:
pre_job: pre_job:
name: Skip Duplicate Checks name: Skip Duplicate Checks
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
outputs: outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }} should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps: steps:
@@ -39,7 +39,7 @@ jobs:
name: Check Changed Files name: Check Changed Files
needs: pre_job needs: pre_job
if: needs.pre_job.outputs.should_skip != 'true' if: needs.pre_job.outputs.should_skip != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
outputs: outputs:
api-changed: ${{ steps.changes.outputs.api }} api-changed: ${{ steps.changes.outputs.api }}
e2e-changed: ${{ steps.changes.outputs.e2e }} e2e-changed: ${{ steps.changes.outputs.e2e }}
@@ -56,9 +56,7 @@ jobs:
- 'api/**' - 'api/**'
- '.github/workflows/api-tests.yml' - '.github/workflows/api-tests.yml'
- '.github/workflows/expose_service_ports.sh' - '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example' - 'docker/middleware.env.example'
- 'docker/docker-compose.middleware.yaml' - 'docker/docker-compose.middleware.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
@@ -67,7 +65,6 @@ jobs:
- 'docker/volumes/sandbox/conf/**' - 'docker/volumes/sandbox/conf/**'
web: web:
- 'web/**' - 'web/**'
- 'packages/**'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml' - 'pnpm-workspace.yaml'
@@ -80,7 +77,6 @@ jobs:
- 'api/uv.lock' - 'api/uv.lock'
- 'e2e/**' - 'e2e/**'
- 'web/**' - 'web/**'
- 'packages/**'
- 'package.json' - 'package.json'
- 'pnpm-lock.yaml' - 'pnpm-lock.yaml'
- 'pnpm-workspace.yaml' - 'pnpm-workspace.yaml'
@@ -92,12 +88,9 @@ jobs:
vdb: vdb:
- 'api/core/rag/datasource/**' - 'api/core/rag/datasource/**'
- 'api/tests/integration_tests/vdb/**' - 'api/tests/integration_tests/vdb/**'
- 'api/providers/vdb/*/tests/**'
- '.github/workflows/vdb-tests.yml' - '.github/workflows/vdb-tests.yml'
- '.github/workflows/expose_service_ports.sh' - '.github/workflows/expose_service_ports.sh'
- 'docker/.env.all'
- 'docker/.env.example' - 'docker/.env.example'
- 'docker/init-env.sh'
- 'docker/middleware.env.example' - 'docker/middleware.env.example'
- 'docker/docker-compose.yaml' - 'docker/docker-compose.yaml'
- 'docker/docker-compose-template.yaml' - 'docker/docker-compose-template.yaml'
@@ -143,7 +136,7 @@ jobs:
- pre_job - pre_job
- check-changes - check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true' if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Report skipped API tests - name: Report skipped API tests
run: echo "No API-related changes detected; skipping API tests." run: echo "No API-related changes detected; skipping API tests."
@@ -156,7 +149,7 @@ jobs:
- check-changes - check-changes
- api-tests-run - api-tests-run
- api-tests-skip - api-tests-skip
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Finalize API Tests status - name: Finalize API Tests status
env: env:
@@ -203,7 +196,7 @@ jobs:
- pre_job - pre_job
- check-changes - check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true' if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Report skipped web tests - name: Report skipped web tests
run: echo "No web-related changes detected; skipping web tests." run: echo "No web-related changes detected; skipping web tests."
@@ -216,7 +209,7 @@ jobs:
- check-changes - check-changes
- web-tests-run - web-tests-run
- web-tests-skip - web-tests-skip
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Finalize Web Tests status - name: Finalize Web Tests status
env: env:
@@ -262,7 +255,7 @@ jobs:
- pre_job - pre_job
- check-changes - check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true' if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Report skipped web full-stack e2e - name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E." run: echo "No E2E-related changes detected; skipping web full-stack E2E."
@@ -275,7 +268,7 @@ jobs:
- check-changes - check-changes
- web-e2e-run - web-e2e-run
- web-e2e-skip - web-e2e-skip
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Finalize Web Full-Stack E2E status - name: Finalize Web Full-Stack E2E status
env: env:
@@ -327,7 +320,7 @@ jobs:
- pre_job - pre_job
- check-changes - check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true' if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Report skipped VDB tests - name: Report skipped VDB tests
run: echo "No VDB-related changes detected; skipping VDB tests." run: echo "No VDB-related changes detected; skipping VDB tests."
@@ -340,7 +333,7 @@ jobs:
- check-changes - check-changes
- vdb-tests-run - vdb-tests-run
- vdb-tests-skip - vdb-tests-skip
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Finalize VDB Tests status - name: Finalize VDB Tests status
env: env:
@@ -386,7 +379,7 @@ jobs:
- pre_job - pre_job
- check-changes - check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true' if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Report skipped DB migration tests - name: Report skipped DB migration tests
run: echo "No migration-related changes detected; skipping DB migration tests." run: echo "No migration-related changes detected; skipping DB migration tests."
@@ -399,7 +392,7 @@ jobs:
- check-changes - check-changes
- db-migration-test-run - db-migration-test-run
- db-migration-test-skip - db-migration-test-skip
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Finalize DB Migration Test status - name: Finalize DB Migration Test status
env: env:
+13 -11
View File
@@ -12,7 +12,7 @@ permissions: {}
jobs: jobs:
comment: comment:
name: Comment PR with pyrefly diff name: Comment PR with pyrefly diff
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
permissions: permissions:
actions: read actions: read
contents: read contents: read
@@ -21,7 +21,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }} if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps: steps:
- name: Download pyrefly diff artifact - name: Download pyrefly diff artifact
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
@@ -49,7 +49,7 @@ jobs:
run: unzip -o pyrefly_diff.zip run: unzip -o pyrefly_diff.zip
- name: Post comment - name: Post comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
@@ -76,11 +76,13 @@ jobs:
diff += '\\n\\n... (truncated) ...'; diff += '\\n\\n... (truncated) ...';
} }
if (diff.trim()) { const body = diff.trim()
await github.rest.issues.createComment({ ? '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>'
issue_number: prNumber, : '### Pyrefly Diff\nNo changes detected.';
owner: context.repo.owner,
repo: context.repo.repo, await github.rest.issues.createComment({
body: '### Pyrefly Diff\n<details>\n<summary>base → PR</summary>\n\n```diff\n' + diff + '\n```\n</details>', issue_number: prNumber,
}); owner: context.repo.owner,
} repo: context.repo.repo,
body,
});
+4 -4
View File
@@ -10,7 +10,7 @@ permissions:
jobs: jobs:
pyrefly-diff: pyrefly-diff:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
permissions: permissions:
contents: read contents: read
issues: write issues: write
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup Python & UV - name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
@@ -66,7 +66,7 @@ jobs:
echo ${{ github.event.pull_request.number }} > pr_number.txt echo ${{ github.event.pull_request.number }} > pr_number.txt
- name: Upload pyrefly diff - name: Upload pyrefly diff
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: pyrefly_diff name: pyrefly_diff
path: | path: |
@@ -75,7 +75,7 @@ jobs:
- name: Comment PR with pyrefly diff - name: Comment PR with pyrefly diff
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }} if: ${{ github.event.pull_request.head.repo.full_name == github.repository && steps.line_count_check.outputs.same == 'false' }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
with: with:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
script: | script: |
@@ -1,118 +0,0 @@
name: Comment with Pyrefly Type Coverage
on:
workflow_run:
workflows:
- Pyrefly Type Coverage
types:
- completed
permissions: {}
jobs:
comment:
name: Comment PR with type coverage
runs-on: depot-ubuntu-24.04
permissions:
actions: read
contents: read
issues: write
pull-requests: write
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --project api --dev
- name: Download type coverage artifact
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
});
const match = artifacts.data.artifacts.find((artifact) =>
artifact.name === 'pyrefly_type_coverage'
);
if (!match) {
throw new Error('pyrefly_type_coverage artifact not found');
}
const download = await github.rest.actions.downloadArtifact({
owner: context.repo.owner,
repo: context.repo.repo,
artifact_id: match.id,
archive_format: 'zip',
});
fs.writeFileSync('pyrefly_type_coverage.zip', Buffer.from(download.data));
- name: Unzip artifact
run: unzip -o pyrefly_type_coverage.zip
- name: Render coverage markdown from structured data
id: render
run: |
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
--base base_report.json \
< pr_report.json)"
{
echo "### Pyrefly Type Coverage"
echo ""
echo "$comment_body"
} > /tmp/type_coverage_comment.md
- name: Post comment
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' });
let prNumber = null;
try {
prNumber = parseInt(fs.readFileSync('pr_number.txt', { encoding: 'utf8' }), 10);
} catch (err) {
const prs = context.payload.workflow_run.pull_requests || [];
if (prs.length > 0 && prs[0].number) {
prNumber = prs[0].number;
}
}
if (!prNumber) {
throw new Error('PR number not found in artifact or workflow_run payload');
}
// Update existing comment if one exists, otherwise create new
const { data: comments } = await github.rest.issues.listComments({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
});
const marker = '### Pyrefly Type Coverage';
const existing = comments.find(c => c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
comment_id: existing.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
} else {
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
}
-120
View File
@@ -1,120 +0,0 @@
name: Pyrefly Type Coverage
on:
pull_request:
paths:
- 'api/**/*.py'
permissions:
contents: read
jobs:
pyrefly-type-coverage:
runs-on: depot-ubuntu-24.04
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- name: Install dependencies
run: uv sync --project api --dev
- name: Run pyrefly report on PR branch
run: |
uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_pr.tmp && \
mv /tmp/pyrefly_report_pr.tmp /tmp/pyrefly_report_pr.json || \
echo '{}' > /tmp/pyrefly_report_pr.json
- name: Save helper script from base branch
run: |
git show ${{ github.event.pull_request.base.sha }}:api/libs/pyrefly_type_coverage.py > /tmp/pyrefly_type_coverage.py 2>/dev/null \
|| cp api/libs/pyrefly_type_coverage.py /tmp/pyrefly_type_coverage.py
- name: Checkout base branch
run: git checkout ${{ github.base_ref }}
- name: Run pyrefly report on base branch
run: |
uv run --directory api --dev pyrefly report 2>/dev/null > /tmp/pyrefly_report_base.tmp && \
mv /tmp/pyrefly_report_base.tmp /tmp/pyrefly_report_base.json || \
echo '{}' > /tmp/pyrefly_report_base.json
- name: Generate coverage comparison
id: coverage
run: |
comment_body="$(uv run --directory api python /tmp/pyrefly_type_coverage.py \
--base /tmp/pyrefly_report_base.json \
< /tmp/pyrefly_report_pr.json)"
{
echo "### Pyrefly Type Coverage"
echo ""
echo "$comment_body"
} | tee -a "$GITHUB_STEP_SUMMARY" > /tmp/type_coverage_comment.md
# Save structured data for the fork-PR comment workflow
cp /tmp/pyrefly_report_pr.json pr_report.json
cp /tmp/pyrefly_report_base.json base_report.json
- name: Save PR number
run: |
echo ${{ github.event.pull_request.number }} > pr_number.txt
- name: Upload type coverage artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pyrefly_type_coverage
path: |
pr_report.json
base_report.json
pr_number.txt
- name: Comment PR with type coverage
if: ${{ github.event.pull_request.head.repo.full_name == github.repository }}
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const fs = require('fs');
const marker = '### Pyrefly Type Coverage';
let body;
try {
body = fs.readFileSync('/tmp/type_coverage_comment.md', { encoding: 'utf8' });
} catch {
body = `${marker}\n\n_Coverage report unavailable._`;
}
const prNumber = context.payload.pull_request.number;
// Update existing comment if one exists, otherwise create new
const { data: comments } = await github.rest.issues.listComments({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
});
const existing = comments.find(c => c.body.startsWith(marker));
if (existing) {
await github.rest.issues.updateComment({
comment_id: existing.id,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
} else {
await github.rest.issues.createComment({
issue_number: prNumber,
owner: context.repo.owner,
repo: context.repo.repo,
body,
});
}
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
name: Validate PR title name: Validate PR title
permissions: permissions:
pull-requests: read pull-requests: read
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Complete merge group check - name: Complete merge group check
if: github.event_name == 'merge_group' if: github.event_name == 'merge_group'
+4 -4
View File
@@ -12,7 +12,7 @@ on:
jobs: jobs:
stale: stale:
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
permissions: permissions:
issues: write issues: write
pull-requests: write pull-requests: write
@@ -23,8 +23,8 @@ jobs:
days-before-issue-stale: 15 days-before-issue-stale: 15
days-before-issue-close: 3 days-before-issue-close: 3
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "Closed due to inactivity. If you have any questions, you can reopen it." stale-issue-message: "Close due to it's no longer active, if you have any questions, you can reopen it."
stale-pr-message: "Closed due to inactivity. If you have any questions, you can reopen it." stale-pr-message: "Close due to it's no longer active, if you have any questions, you can reopen it."
stale-issue-label: 'no-issue-activity' stale-issue-label: 'no-issue-activity'
stale-pr-label: 'no-pr-activity' stale-pr-label: 'no-pr-activity'
any-of-labels: '🌚 invalid,🙋‍♂️ question,wont-fix,no-issue-activity,no-pr-activity,💪 enhancement,🤔 cant-reproduce,🙏 help wanted' any-of-labels: 'duplicate,question,invalid,wontfix,no-issue-activity,no-pr-activity,enhancement,cant-reproduce,help-wanted'
+16 -21
View File
@@ -15,7 +15,7 @@ permissions:
jobs: jobs:
python-style: python-style:
name: Python Style name: Python Style
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -25,7 +25,7 @@ jobs:
- name: Check changed files - name: Check changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
api/** api/**
@@ -33,7 +33,7 @@ jobs:
- name: Setup UV and Python - name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: false enable-cache: false
python-version: "3.12" python-version: "3.12"
@@ -57,7 +57,7 @@ jobs:
web-style: web-style:
name: Web Style name: Web Style
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
defaults: defaults:
run: run:
working-directory: ./web working-directory: ./web
@@ -73,13 +73,10 @@ jobs:
- name: Check changed files - name: Check changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
web/** web/**
e2e/**
sdks/nodejs-client/**
packages/**
package.json package.json
pnpm-lock.yaml pnpm-lock.yaml
pnpm-workspace.yaml pnpm-workspace.yaml
@@ -94,28 +91,26 @@ jobs:
- name: Restore ESLint cache - name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore id: eslint-cache-restore
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/restore@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: .eslintcache path: web/.eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }} key: ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
restore-keys: | restore-keys: |
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}- ${{ runner.os }}-web-eslint-${{ hashFiles('web/package.json', 'pnpm-lock.yaml', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
- name: Web style check - name: Web style check
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
working-directory: . working-directory: ./web
run: vp run lint:ci run: vp run lint:ci
- name: Web tsslint - name: Web tsslint
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ./web working-directory: ./web
env:
NODE_OPTIONS: --max-old-space-size=4096
run: vp run lint:tss run: vp run lint:tss
- name: Web type check - name: Web type check
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
working-directory: . working-directory: ./web
run: vp run type-check run: vp run type-check
- name: Web dead code check - name: Web dead code check
@@ -125,14 +120,14 @@ jobs:
- name: Save ESLint cache - name: Save ESLint cache
if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true' if: steps.changed-files.outputs.any_changed == 'true' && success() && steps.eslint-cache-restore.outputs.cache-hit != 'true'
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 uses: actions/cache/save@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
with: with:
path: .eslintcache path: web/.eslintcache
key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }} key: ${{ steps.eslint-cache-restore.outputs.cache-primary-key }}
superlinter: superlinter:
name: SuperLinter name: SuperLinter
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
@@ -143,7 +138,7 @@ jobs:
- name: Check changed files - name: Check changed files
id: changed-files id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # v47.0.6 uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5
with: with:
files: | files: |
**.sh **.sh
@@ -154,7 +149,7 @@ jobs:
.editorconfig .editorconfig
- name: Super-linter - name: Super-linter
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0 uses: super-linter/super-linter/slim@61abc07d755095a68f4987d1c2c3d1d64408f1f9 # v8.5.0
if: steps.changed-files.outputs.any_changed == 'true' if: steps.changed-files.outputs.any_changed == 'true'
env: env:
BASH_SEVERITY: warning BASH_SEVERITY: warning
+2 -2
View File
@@ -17,7 +17,7 @@ concurrency:
jobs: jobs:
build: build:
name: unit test for Node.js SDK name: unit test for Node.js SDK
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
defaults: defaults:
run: run:
@@ -29,7 +29,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Use Node.js - name: Use Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with: with:
node-version: 22 node-version: 22
cache: '' cache: ''
+93 -12
View File
@@ -35,7 +35,7 @@ concurrency:
jobs: jobs:
translate: translate:
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
timeout-minutes: 120 timeout-minutes: 120
steps: steps:
@@ -68,7 +68,89 @@ jobs:
" web/i18n-config/languages.ts | sed 's/[[:space:]]*$//') " web/i18n-config/languages.ts | sed 's/[[:space:]]*$//')
generate_changes_json() { generate_changes_json() {
node .github/scripts/generate-i18n-changes.mjs node <<'NODE'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch (error) {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const currentJson = readCurrentJson(fileStem)
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = currentJson || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: currentJson === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
'/tmp/i18n-changes.json',
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)
NODE
} }
if [ "${{ github.event_name }}" = "repository_dispatch" ]; then if [ "${{ github.event_name }}" = "repository_dispatch" ]; then
@@ -158,7 +240,7 @@ jobs:
- name: Run Claude Code for Translation Sync - name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != '' if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111 uses: anthropics/claude-code-action@88c168b39e7e64da0286d812b6e9fbebb6708185 # v1.0.82
with: with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -188,7 +270,7 @@ jobs:
Tool rules: Tool rules:
- Use Read for repository files. - Use Read for repository files.
- Use Edit for JSON updates. - Use Edit for JSON updates.
- Use Bash only for `vp`. - Use Bash only for `pnpm`.
- Do not use Bash for `git`, `gh`, or branch management. - Do not use Bash for `git`, `gh`, or branch management.
Required execution plan: Required execution plan:
@@ -210,7 +292,7 @@ jobs:
- Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate. - Read the current English JSON file for any file that still exists so wording, placeholders, and surrounding terminology stay accurate.
- If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth. - If `Structured change set available` is `false`, treat this as a scoped full sync and use the current English files plus scoped checks as the source of truth.
4. Run a scoped pre-check before editing: 4. Run a scoped pre-check before editing:
- `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- Use this command as the source of truth for missing and extra keys inside the current scope. - Use this command as the source of truth for missing and extra keys inside the current scope.
5. Apply translations. 5. Apply translations.
- For every target language and scoped file: - For every target language and scoped file:
@@ -218,19 +300,19 @@ jobs:
- If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed. - If the locale file does not exist yet, create it with `Write` and then continue with `Edit` as needed.
- ADD missing keys. - ADD missing keys.
- UPDATE stale translations when the English value changed. - UPDATE stale translations when the English value changed.
- DELETE removed keys. Prefer `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope. - DELETE removed keys. Prefer `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }} --auto-remove` for extra keys so deletions stay in scope.
- Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names. - Preserve placeholders exactly: `{{variable}}`, `${variable}`, HTML tags, component tags, and variable names.
- Match the existing terminology and register used by each locale. - Match the existing terminology and register used by each locale.
- Prefer one Edit per file when stable, but prioritize correctness over batching. - Prefer one Edit per file when stable, but prioritize correctness over batching.
6. Verify only the edited files. 6. Verify only the edited files.
- Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>` - Run `pnpm --dir ${{ github.workspace }}/web lint:fix --quiet -- <relative edited i18n file paths>`
- Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}` - Run `pnpm --dir ${{ github.workspace }}/web run i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- If verification fails, fix the remaining problems before continuing. - If verification fails, fix the remaining problems before continuing.
7. Stop after the scoped locale files are updated and verification passes. 7. Stop after the scoped locale files are updated and verification passes.
- Do not create branches, commits, or pull requests. - Do not create branches, commits, or pull requests.
claude_args: | claude_args: |
--max-turns 120 --max-turns 120
--allowedTools "Read,Write,Edit,Bash(vp *),Bash(vp:*),Glob,Grep" --allowedTools "Read,Write,Edit,Bash(pnpm *),Bash(pnpm:*),Glob,Grep"
- name: Prepare branch metadata - name: Prepare branch metadata
id: pr_meta id: pr_meta
@@ -272,7 +354,6 @@ jobs:
- name: Create or update translation PR - name: Create or update translation PR
if: steps.pr_meta.outputs.has_changes == 'true' if: steps.pr_meta.outputs.has_changes == 'true'
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }} BRANCH_NAME: ${{ steps.pr_meta.outputs.branch_name }}
FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }} FILES_IN_SCOPE: ${{ steps.context.outputs.CHANGED_FILES }}
TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }} TARGET_LANGS: ${{ steps.context.outputs.TARGET_LANGS }}
@@ -321,8 +402,8 @@ jobs:
'', '',
'## Verification', '## Verification',
'', '',
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``, `- \`pnpm --dir web run i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
`- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``, `- \`pnpm --dir web lint:fix --quiet -- <edited i18n files>\``,
'', '',
'## Notes', '## Notes',
'', '',
+84 -3
View File
@@ -16,7 +16,7 @@ concurrency:
jobs: jobs:
trigger: trigger:
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 5
steps: steps:
@@ -42,7 +42,88 @@ jobs:
fi fi
export BASE_SHA HEAD_SHA CHANGED_FILES export BASE_SHA HEAD_SHA CHANGED_FILES
node .github/scripts/generate-i18n-changes.mjs node <<'NODE'
const { execFileSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const repoRoot = process.cwd()
const baseSha = process.env.BASE_SHA || ''
const headSha = process.env.HEAD_SHA || ''
const files = (process.env.CHANGED_FILES || '').split(/\s+/).filter(Boolean)
const englishPath = fileStem => path.join(repoRoot, 'web', 'i18n', 'en-US', `${fileStem}.json`)
const readCurrentJson = (fileStem) => {
const filePath = englishPath(fileStem)
if (!fs.existsSync(filePath))
return null
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
}
const readBaseJson = (fileStem) => {
if (!baseSha)
return null
try {
const relativePath = `web/i18n/en-US/${fileStem}.json`
const content = execFileSync('git', ['show', `${baseSha}:${relativePath}`], { encoding: 'utf8' })
return JSON.parse(content)
}
catch (error) {
return null
}
}
const compareJson = (beforeValue, afterValue) => JSON.stringify(beforeValue) === JSON.stringify(afterValue)
const changes = {}
for (const fileStem of files) {
const beforeJson = readBaseJson(fileStem) || {}
const afterJson = readCurrentJson(fileStem) || {}
const added = {}
const updated = {}
const deleted = []
for (const [key, value] of Object.entries(afterJson)) {
if (!(key in beforeJson)) {
added[key] = value
continue
}
if (!compareJson(beforeJson[key], value)) {
updated[key] = {
before: beforeJson[key],
after: value,
}
}
}
for (const key of Object.keys(beforeJson)) {
if (!(key in afterJson))
deleted.push(key)
}
changes[fileStem] = {
fileDeleted: readCurrentJson(fileStem) === null,
added,
updated,
deleted,
}
}
fs.writeFileSync(
'/tmp/i18n-changes.json',
JSON.stringify({
baseSha,
headSha,
files,
changes,
})
)
NODE
if [ -n "$CHANGED_FILES" ]; then if [ -n "$CHANGED_FILES" ]; then
echo "has_changes=true" >> "$GITHUB_OUTPUT" echo "has_changes=true" >> "$GITHUB_OUTPUT"
@@ -56,7 +137,7 @@ jobs:
- name: Trigger i18n sync workflow - name: Trigger i18n sync workflow
if: steps.detect.outputs.has_changes == 'true' if: steps.detect.outputs.has_changes == 'true'
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
env: env:
BASE_SHA: ${{ steps.detect.outputs.base_sha }} BASE_SHA: ${{ steps.detect.outputs.base_sha }}
HEAD_SHA: ${{ steps.detect.outputs.head_sha }} HEAD_SHA: ${{ steps.detect.outputs.head_sha }}
+5 -5
View File
@@ -16,7 +16,7 @@ jobs:
test: test:
name: Full VDB Tests name: Full VDB Tests
if: github.repository == 'langgenius/dify' if: github.repository == 'langgenius/dify'
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: python-version:
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true remove_tool_cache: true
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with: with:
enable-cache: true enable-cache: true
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -50,7 +50,7 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
./docker/init-env.sh cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports
@@ -65,7 +65,7 @@ jobs:
# tiflash # tiflash
- name: Set up Full Vector Store Matrix - name: Set up Full Vector Store Matrix
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with: with:
compose-file: | compose-file: |
docker/docker-compose.yaml docker/docker-compose.yaml
@@ -89,7 +89,7 @@ jobs:
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB) # - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py # run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
- name: Test Vector Stores - name: Test Vector Stores
run: uv run --project api bash dev/pytest/pytest_vdb.sh run: uv run --project api bash dev/pytest/pytest_vdb.sh
+9 -9
View File
@@ -13,7 +13,7 @@ concurrency:
jobs: jobs:
test: test:
name: VDB Smoke Tests name: VDB Smoke Tests
runs-on: depot-ubuntu-24.04 runs-on: ubuntu-latest
strategy: strategy:
matrix: matrix:
python-version: python-version:
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true remove_tool_cache: true
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: ${{ matrix.python-version }} python-version: ${{ matrix.python-version }}
@@ -47,7 +47,7 @@ jobs:
- name: Set up dotenvs - name: Set up dotenvs
run: | run: |
./docker/init-env.sh cp docker/.env.example docker/.env
cp docker/middleware.env.example docker/middleware.env cp docker/middleware.env.example docker/middleware.env
- name: Expose Service Ports - name: Expose Service Ports
@@ -62,7 +62,7 @@ jobs:
# tiflash # tiflash
- name: Set up Vector Stores for Smoke Coverage - name: Set up Vector Stores for Smoke Coverage
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0 uses: hoverkraft-tech/compose-action@4894d2492015c1774ee5a13a95b1072093087ec3 # v2.5.0
with: with:
compose-file: | compose-file: |
docker/docker-compose.yaml docker/docker-compose.yaml
@@ -81,12 +81,12 @@ jobs:
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
# - name: Check VDB Ready (TiDB) # - name: Check VDB Ready (TiDB)
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py # run: uv run --project api python api/tests/integration_tests/vdb/tidb_vector/check_tiflash_ready.py
- name: Test Vector Stores - name: Test Vector Stores
run: | run: |
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \ uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
api/providers/vdb/vdb-chroma/tests/integration_tests \ api/tests/integration_tests/vdb/chroma \
api/providers/vdb/vdb-pgvector/tests/integration_tests \ api/tests/integration_tests/vdb/pgvector \
api/providers/vdb/vdb-qdrant/tests/integration_tests \ api/tests/integration_tests/vdb/qdrant \
api/providers/vdb/vdb-weaviate/tests/integration_tests api/tests/integration_tests/vdb/weaviate
+4 -4
View File
@@ -13,7 +13,7 @@ concurrency:
jobs: jobs:
test: test:
name: Web Full-Stack E2E name: Web Full-Stack E2E
runs-on: depot-ubuntu-24.04-4 runs-on: ubuntu-latest
defaults: defaults:
run: run:
shell: bash shell: bash
@@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/setup-web uses: ./.github/actions/setup-web
- name: Setup UV and Python - name: Setup UV and Python
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0
with: with:
enable-cache: true enable-cache: true
python-version: "3.12" python-version: "3.12"
@@ -53,7 +53,7 @@ jobs:
- name: Upload Cucumber report - name: Upload Cucumber report
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: cucumber-report name: cucumber-report
path: e2e/cucumber-report path: e2e/cucumber-report
@@ -61,7 +61,7 @@ jobs:
- name: Upload E2E logs - name: Upload E2E logs
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: e2e-logs name: e2e-logs
path: e2e/.logs path: e2e/.logs
+3 -37
View File
@@ -16,7 +16,7 @@ concurrency:
jobs: jobs:
test: test:
name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }}) name: Web Tests (${{ matrix.shardIndex }}/${{ matrix.shardTotal }})
runs-on: depot-ubuntu-24.04-4 runs-on: ubuntu-latest
env: env:
VITEST_COVERAGE_SCOPE: app-components VITEST_COVERAGE_SCOPE: app-components
strategy: strategy:
@@ -43,7 +43,7 @@ jobs:
- name: Upload blob report - name: Upload blob report
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0
with: with:
name: blob-report-${{ matrix.shardIndex }} name: blob-report-${{ matrix.shardIndex }}
path: web/.vitest-reports/* path: web/.vitest-reports/*
@@ -54,7 +54,7 @@ jobs:
name: Merge Test Reports name: Merge Test Reports
if: ${{ !cancelled() }} if: ${{ !cancelled() }}
needs: [test] needs: [test]
runs-on: depot-ubuntu-24.04-4 runs-on: ubuntu-latest
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
defaults: defaults:
@@ -89,37 +89,3 @@ jobs:
flags: web flags: web
env: env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }} CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
dify-ui-test:
name: dify-ui Tests
runs-on: depot-ubuntu-24.04-4
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
defaults:
run:
shell: bash
working-directory: ./packages/dify-ui
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui tests
run: vp test run --coverage --silent=passed-only
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: packages/dify-ui/coverage
flags: dify-ui
env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
+1 -11
View File
@@ -203,7 +203,6 @@ sdks/python-client/dify_client.egg-info
.vscode/* .vscode/*
!.vscode/launch.json.template !.vscode/launch.json.template
!.vscode/settings.example.json
!.vscode/README.md !.vscode/README.md
api/.vscode api/.vscode
# vscode Code History Extension # vscode Code History Extension
@@ -213,15 +212,12 @@ api/.vscode
# pnpm # pnpm
/.pnpm-store /.pnpm-store
node_modules /node_modules
.vite-hooks/_ .vite-hooks/_
# plugin migrate # plugin migrate
plugins.jsonl plugins.jsonl
# generated API OpenAPI specs
packages/contracts/openapi/
# mise # mise
mise.toml mise.toml
@@ -240,15 +236,9 @@ scripts/stress-test/reports/
.playwright-mcp/ .playwright-mcp/
.serena/ .serena/
# vitest browser mode attachments (failure screenshots, traces, etc.)
.vitest-attachments/
**/__screenshots__/
# settings # settings
*.local.json *.local.json
*.local.md *.local.md
# Code Agent Folder # Code Agent Folder
.qoder/* .qoder/*
.eslintcache
+60 -5
View File
@@ -56,9 +56,64 @@ if $api_modified; then
fi fi
fi fi
if $skip_web_checks; then if $web_modified; then
echo "Git operation in progress, skipping web checks" if $skip_web_checks; then
exit 0 echo "Git operation in progress, skipping web checks"
fi exit 0
fi
vp staged echo "Running ESLint on web module"
if git diff --cached --quiet -- 'web/**/*.ts' 'web/**/*.tsx'; then
web_ts_modified=false
else
ts_diff_status=$?
if [ $ts_diff_status -eq 1 ]; then
web_ts_modified=true
else
echo "Unable to determine staged TypeScript changes (git exit code: $ts_diff_status)."
exit $ts_diff_status
fi
fi
cd ./web || exit 1
vp staged
if $web_ts_modified; then
echo "Running TypeScript type-check:tsgo"
if ! pnpm run type-check:tsgo; then
echo "Type check failed. Please run 'pnpm run type-check:tsgo' to fix the errors."
exit 1
fi
else
echo "No staged TypeScript changes detected, skipping type-check:tsgo"
fi
echo "Running unit tests check"
modified_files=$(git diff --cached --name-only -- utils | grep -v '\.spec\.ts$' || true)
if [ -n "$modified_files" ]; then
for file in $modified_files; do
test_file="${file%.*}.spec.ts"
echo "Checking for test file: $test_file"
# check if the test file exists
if [ -f "../$test_file" ]; then
echo "Detected changes in $file, running corresponding unit tests..."
pnpm run test "../$test_file"
if [ $? -ne 0 ]; then
echo "Unit tests failed. Please fix the errors before committing."
exit 1
fi
echo "Unit tests for $file passed."
else
echo "Warning: $file does not have a corresponding test file."
fi
done
echo "All unit tests for modified web/utils files have passed."
fi
cd ../
fi
+13 -2
View File
@@ -2,10 +2,21 @@
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "Python: API (gevent)", "name": "Python: Flask API",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/api/app.py", "module": "flask",
"env": {
"FLASK_APP": "app.py",
"FLASK_ENV": "development"
},
"args": [
"run",
"--host=0.0.0.0",
"--port=5001",
"--no-debugger",
"--no-reload"
],
"jinja": true, "jinja": true,
"justMyCode": true, "justMyCode": true,
"cwd": "${workspaceFolder}/api", "cwd": "${workspaceFolder}/api",
+1 -1
View File
@@ -30,7 +30,7 @@ The codebase is split into:
## Language Style ## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation. - **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types. - **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check:tsgo`, and avoid `any` types.
## General Practices ## General Practices
+5
View File
@@ -97,3 +97,8 @@ Feel free to reach out if you encounter any issues during the setup process.
## Getting Help ## Getting Help
If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat. If you ever get stuck or get a burning question while contributing, simply shoot your queries our way via the related GitHub issue, or hop onto our [Discord](https://discord.gg/8Tpq4AcN9c) for a quick chat.
## Automated Agent Contributions
> [!NOTE]
> If you are an automated agent just add 🤖🤖🤖 to the end of the PR title to opt-in.
+16 -10
View File
@@ -76,14 +76,7 @@ The easiest way to start the Dify server is through [Docker Compose](docker/dock
```bash ```bash
cd dify cd dify
cd docker cd docker
./init-env.sh cp .env.example .env
docker compose up -d
```
On Windows PowerShell, initialize `.env`, then run `docker compose up -d` from the `docker` directory.
```powershell
.\init-env.ps1
docker compose up -d docker compose up -d
``` ```
@@ -144,7 +137,20 @@ Star Dify on GitHub and be instantly notified of new releases.
### Custom configurations ### Custom configurations
If you need to customize the configuration, edit `docker/.env` after running the initialization script. The full reference remains in [`docker/.env.all`](docker/.env.all). After making any changes, re-run `docker compose up -d` from the `docker` directory. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments). If you need to customize the configuration, please refer to the comments in our [.env.example](docker/.env.example) file and update the corresponding values in your `.env` file. Additionally, you might need to make adjustments to the `docker-compose.yaml` file itself, such as changing image versions, port mappings, or volume mounts, based on your specific deployment environment and requirements. After making any changes, please re-run `docker compose up -d`. You can find the full list of available environment variables [here](https://docs.dify.ai/getting-started/install-self-hosted/environments).
#### Customizing Suggested Questions
You can now customize the "Suggested Questions After Answer" feature to better fit your use case. For example, to generate longer, more technical questions:
```bash
# In your .env file
SUGGESTED_QUESTIONS_PROMPT='Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: ["question1","question2","question3","question4","question5"]'
SUGGESTED_QUESTIONS_MAX_TOKENS=512
SUGGESTED_QUESTIONS_TEMPERATURE=0.3
```
See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions.
### Metrics Monitoring with Grafana ### Metrics Monitoring with Grafana
@@ -154,7 +160,7 @@ Import the dashboard to Grafana, using Dify's PostgreSQL database as data source
### Deployment with Kubernetes ### Deployment with Kubernetes
If you'd like to configure a highly available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes. If you'd like to configure a highly-available setup, there are community-contributed [Helm Charts](https://helm.sh/) and YAML files which allow Dify to be deployed on Kubernetes.
- [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify) - [Helm Chart by @LeoQuote](https://github.com/douban/charts/tree/master/charts/dify)
- [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm) - [Helm Chart by @BorisPolonsky](https://github.com/BorisPolonsky/dify-helm)
+16 -19
View File
@@ -33,9 +33,6 @@ TRIGGER_URL=http://localhost:5001
# The time in seconds after the signature is rejected # The time in seconds after the signature is rejected
FILES_ACCESS_TIMEOUT=300 FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
ENABLE_COLLABORATION_MODE=false
# Access token expiration time in minutes # Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60 ACCESS_TOKEN_EXPIRE_MINUTES=60
@@ -60,9 +57,6 @@ REDIS_SSL_CERTFILE=
REDIS_SSL_KEYFILE= REDIS_SSL_KEYFILE=
# Path to client private key file for SSL authentication # Path to client private key file for SSL authentication
REDIS_DB=0 REDIS_DB=0
# Optional global prefix for Redis keys, topics, streams, and Celery Redis transport artifacts.
# Leave empty to preserve current unprefixed behavior.
REDIS_KEY_PREFIX=
# redis Sentinel configuration. # redis Sentinel configuration.
REDIS_USE_SENTINEL=false REDIS_USE_SENTINEL=false
@@ -77,13 +71,6 @@ REDIS_USE_CLUSTERS=false
REDIS_CLUSTERS= REDIS_CLUSTERS=
REDIS_CLUSTERS_PASSWORD= REDIS_CLUSTERS_PASSWORD=
REDIS_RETRY_RETRIES=3
REDIS_RETRY_BACKOFF_BASE=1.0
REDIS_RETRY_BACKOFF_CAP=10.0
REDIS_SOCKET_TIMEOUT=5.0
REDIS_SOCKET_CONNECT_TIMEOUT=5.0
REDIS_HEALTH_CHECK_INTERVAL=30
# celery configuration # celery configuration
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1 CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
CELERY_BACKEND=redis CELERY_BACKEND=redis
@@ -115,7 +102,6 @@ S3_BUCKET_NAME=your-bucket-name
S3_ACCESS_KEY=your-access-key S3_ACCESS_KEY=your-access-key
S3_SECRET_KEY=your-secret-key S3_SECRET_KEY=your-secret-key
S3_REGION=your-region S3_REGION=your-region
S3_ADDRESS_STYLE=auto
# Workflow run and Conversation archive storage (S3-compatible) # Workflow run and Conversation archive storage (S3-compatible)
ARCHIVE_STORAGE_ENABLED=false ARCHIVE_STORAGE_ENABLED=false
@@ -659,11 +645,6 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
MARKETPLACE_ENABLED=true MARKETPLACE_ENABLED=true
MARKETPLACE_API_URL=https://marketplace.dify.ai MARKETPLACE_API_URL=https://marketplace.dify.ai
# Creators Platform configuration
CREATORS_PLATFORM_FEATURES_ENABLED=true
CREATORS_PLATFORM_API_URL=https://creators.dify.ai
CREATORS_PLATFORM_OAUTH_CLIENT_ID=
# Endpoint configuration # Endpoint configuration
ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id} ENDPOINT_URL_TEMPLATE=http://localhost:5002/e/{hook_id}
@@ -714,6 +695,22 @@ SWAGGER_UI_PATH=/swagger-ui.html
# Set to false to export dataset IDs as plain text for easier cross-environment import # Set to false to export dataset IDs as plain text for easier cross-environment import
DSL_EXPORT_ENCRYPT_DATASET_ID=true DSL_EXPORT_ENCRYPT_DATASET_ID=true
# Suggested Questions After Answer Configuration
# These environment variables allow customization of the suggested questions feature
#
# Custom prompt for generating suggested questions (optional)
# If not set, uses the default prompt that generates 3 questions under 20 characters each
# Example: "Please help me predict the five most likely technical follow-up questions a developer would ask. Focus on implementation details, best practices, and architecture considerations. Keep each question between 40-60 characters. Output must be JSON array: [\"question1\",\"question2\",\"question3\",\"question4\",\"question5\"]"
# SUGGESTED_QUESTIONS_PROMPT=
# Maximum number of tokens for suggested questions generation (default: 256)
# Adjust this value for longer questions or more questions
# SUGGESTED_QUESTIONS_MAX_TOKENS=256
# Temperature for suggested questions generation (default: 0.0)
# Higher values (0.5-1.0) produce more creative questions, lower values (0.0-0.3) produce more focused questions
# SUGGESTED_QUESTIONS_TEMPERATURE=0
# Tenant isolated task queue configuration # Tenant isolated task queue configuration
TENANT_ISOLATED_TASK_CONCURRENCY=1 TENANT_ISOLATED_TASK_CONCURRENCY=1
+22 -3
View File
@@ -69,6 +69,8 @@ ignore = [
"FURB152", # math-constant "FURB152", # math-constant
"UP007", # non-pep604-annotation "UP007", # non-pep604-annotation
"UP032", # f-string "UP032", # f-string
"UP045", # non-pep604-annotation-optional
"B005", # strip-with-multi-characters
"B006", # mutable-argument-default "B006", # mutable-argument-default
"B007", # unused-loop-control-variable "B007", # unused-loop-control-variable
"B026", # star-arg-unpacking-after-keyword-arg "B026", # star-arg-unpacking-after-keyword-arg
@@ -82,6 +84,7 @@ ignore = [
"SIM102", # collapsible-if "SIM102", # collapsible-if
"SIM103", # needless-bool "SIM103", # needless-bool
"SIM105", # suppressible-exception "SIM105", # suppressible-exception
"SIM107", # return-in-try-except-finally
"SIM108", # if-else-block-instead-of-if-exp "SIM108", # if-else-block-instead-of-if-exp
"SIM113", # enumerate-for-loop "SIM113", # enumerate-for-loop
"SIM117", # multiple-with-statements "SIM117", # multiple-with-statements
@@ -90,22 +93,38 @@ ignore = [
] ]
[lint.per-file-ignores] [lint.per-file-ignores]
"__init__.py" = [
"F401", # unused-import
"F811", # redefined-while-unused
]
"configs/*" = [ "configs/*" = [
"N802", # invalid-function-name "N802", # invalid-function-name
] ]
"graphon/model_runtime/callbacks/base_callback.py" = ["T201"]
"core/workflow/callbacks/workflow_logging_callback.py" = ["T201"]
"libs/gmpy2_pkcs10aep_cipher.py" = [ "libs/gmpy2_pkcs10aep_cipher.py" = [
"N803", # invalid-argument-name "N803", # invalid-argument-name
] ]
"tests/*" = [ "tests/*" = [
"F811", # redefined-while-unused
"T201", # allow print in tests, "T201", # allow print in tests,
"S110", # allow ignoring exceptions in tests code (currently) "S110", # allow ignoring exceptions in tests code (currently)
] ]
"controllers/console/explore/trial.py" = ["TID251"]
"controllers/console/human_input_form.py" = ["TID251"]
"controllers/web/human_input_form.py" = ["TID251"]
[lint.pyflakes]
allowed-unused-imports = [
"tests.integration_tests",
"tests.unit_tests",
]
[lint.flake8-tidy-imports]
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"] [lint.flake8-tidy-imports.banned-api."flask_restx.reqparse"]
msg = "Use Pydantic payload/query models instead of reqparse." msg = "Use Pydantic payload/query models instead of reqparse."
[lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"] [lint.flake8-tidy-imports.banned-api."flask_restx.reqparse.RequestParser"]
msg = "Use Pydantic payload/query models instead of reqparse." msg = "Use Pydantic payload/query models instead of reqparse."
[lint.isort]
known-first-party = ["graphon"]
+13 -5
View File
@@ -3,21 +3,29 @@
"compounds": [ "compounds": [
{ {
"name": "Launch Flask and Celery", "name": "Launch Flask and Celery",
"configurations": ["Python: API (gevent)", "Python: Celery"] "configurations": ["Python: Flask", "Python: Celery"]
} }
], ],
"configurations": [ "configurations": [
{ {
"name": "Python: API (gevent)", "name": "Python: Flask",
"consoleName": "API", "consoleName": "Flask",
"type": "debugpy", "type": "debugpy",
"request": "launch", "request": "launch",
"python": "${workspaceFolder}/.venv/bin/python", "python": "${workspaceFolder}/.venv/bin/python",
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"envFile": ".env", "envFile": ".env",
"program": "${workspaceFolder}/app.py", "module": "flask",
"justMyCode": true, "justMyCode": true,
"jinja": true "jinja": true,
"env": {
"FLASK_APP": "app.py",
"GEVENT_SUPPORT": "True"
},
"args": [
"run",
"--port=5001"
]
}, },
{ {
"name": "Python: Celery", "name": "Python: Celery",
+1 -2
View File
@@ -21,9 +21,8 @@ RUN apt-get update \
# for building gmpy2 # for building gmpy2
libmpfr-dev libmpc-dev libmpfr-dev libmpc-dev
# Install Python dependencies (workspace members under providers/vdb/) # Install Python dependencies
COPY pyproject.toml uv.lock ./ COPY pyproject.toml uv.lock ./
COPY providers ./providers
RUN uv sync --locked --no-dev RUN uv sync --locked --no-dev
# production stage # production stage
-8
View File
@@ -101,11 +101,3 @@ The scripts resolve paths relative to their location, so you can run them from a
uv run ruff format ./ # Format code uv run ruff format ./ # Format code
uv run basedpyright . # Type checking uv run basedpyright . # Type checking
``` ```
## Generate TS stub
```
uv run dev/generate_swagger_specs.py --output-dir openapi
```
use https://jsontotable.org/openapi-to-typescript to convert to typescript
+2 -27
View File
@@ -1,6 +1,5 @@
from __future__ import annotations from __future__ import annotations
import logging
import sys import sys
from typing import TYPE_CHECKING, cast from typing import TYPE_CHECKING, cast
@@ -10,35 +9,17 @@ if TYPE_CHECKING:
celery: Celery celery: Celery
HOST = "0.0.0.0"
PORT = 5001
logger = logging.getLogger(__name__)
def is_db_command() -> bool: def is_db_command() -> bool:
if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db": if len(sys.argv) > 1 and sys.argv[0].endswith("flask") and sys.argv[1] == "db":
return True return True
return False return False
def log_startup_banner(host: str, port: int) -> None:
debugger_attached = sys.gettrace() is not None
logger.info("Serving Dify API via gevent WebSocket server")
logger.info("Bound to http://%s:%s", host, port)
logger.info("Debugger attached: %s", "on" if debugger_attached else "off")
logger.info("Press CTRL+C to quit")
# create app # create app
flask_app = None
socketio_app = None
if is_db_command(): if is_db_command():
from app_factory import create_migrations_app from app_factory import create_migrations_app
app = create_migrations_app() app = create_migrations_app()
socketio_app = app
flask_app = app
else: else:
# Gunicorn and Celery handle monkey patching automatically in production by # Gunicorn and Celery handle monkey patching automatically in production by
# specifying the `gevent` worker class. Manual monkey patching is not required here. # specifying the `gevent` worker class. Manual monkey patching is not required here.
@@ -49,14 +30,8 @@ else:
from app_factory import create_app from app_factory import create_app
socketio_app, flask_app = create_app() app = create_app()
app = flask_app
celery = cast("Celery", app.extensions["celery"]) celery = cast("Celery", app.extensions["celery"])
if __name__ == "__main__": if __name__ == "__main__":
from gevent import pywsgi app.run(host="0.0.0.0", port=5001)
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
log_startup_banner(HOST, PORT)
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
server.serve_forever()
+2 -8
View File
@@ -1,7 +1,6 @@
import logging import logging
import time import time
import socketio # type: ignore[reportMissingTypeStubs]
from flask import request from flask import request
from opentelemetry.trace import get_current_span from opentelemetry.trace import get_current_span
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
@@ -11,7 +10,6 @@ from contexts.wrapper import RecyclableContextVar
from controllers.console.error import UnauthorizedAndForceLogout from controllers.console.error import UnauthorizedAndForceLogout
from core.logging.context import init_request_context from core.logging.context import init_request_context
from dify_app import DifyApp from dify_app import DifyApp
from extensions.ext_socketio import sio
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.feature_service import LicenseStatus from services.feature_service import LicenseStatus
@@ -124,18 +122,14 @@ def create_flask_app_with_configs() -> DifyApp:
return dify_app return dify_app
def create_app() -> tuple[socketio.WSGIApp, DifyApp]: def create_app() -> DifyApp:
start_time = time.perf_counter() start_time = time.perf_counter()
app = create_flask_app_with_configs() app = create_flask_app_with_configs()
initialize_extensions(app) initialize_extensions(app)
sio.app = app
socketio_app = socketio.WSGIApp(sio, app)
end_time = time.perf_counter() end_time = time.perf_counter()
if dify_config.DEBUG: if dify_config.DEBUG:
logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2)) logger.info("Finished create_app (%s ms)", round((end_time - start_time) * 1000, 2))
return socketio_app, app return app
def initialize_extensions(app: DifyApp): def initialize_extensions(app: DifyApp):
-18
View File
@@ -1,18 +0,0 @@
# This module provides a lightweight Celery instance for use in Docker health checks.
# Unlike celery_entrypoint.py, this does NOT import app.py and therefore avoids
# initializing all Flask extensions (DB, Redis, storage, blueprints, etc.).
# Using this module keeps the health check fast and low-cost.
from celery import Celery
from configs import dify_config
from extensions.ext_celery import get_celery_broker_transport_options, get_celery_ssl_options
celery = Celery(broker=dify_config.CELERY_BROKER_URL)
broker_transport_options = get_celery_broker_transport_options()
if broker_transport_options:
celery.conf.update(broker_transport_options=broker_transport_options)
ssl_options = get_celery_ssl_options()
if ssl_options:
celery.conf.update(broker_use_ssl=ssl_options)
+32 -46
View File
@@ -2,7 +2,7 @@ import base64
import secrets import secrets
import click import click
from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker
from constants.languages import languages from constants.languages import languages
from extensions.ext_database import db from extensions.ext_database import db
@@ -25,32 +25,30 @@ def reset_password(email, new_password, password_confirm):
return return
normalized_email = email.strip().lower() normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip()) with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
if not account: if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red")) click.echo(click.style(f"Account not found for email: {email}", fg="red"))
return return
try: try:
valid_password(new_password) valid_password(new_password)
except: except:
click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red")) click.echo(click.style(f"Invalid password. Must match {password_pattern}", fg="red"))
return return
# generate password salt # generate password salt
salt = secrets.token_bytes(16) salt = secrets.token_bytes(16)
base64_salt = base64.b64encode(salt).decode() base64_salt = base64.b64encode(salt).decode()
# encrypt password with salt # encrypt password with salt
password_hashed = hash_password(new_password, salt) password_hashed = hash_password(new_password, salt)
base64_password_hashed = base64.b64encode(password_hashed).decode() base64_password_hashed = base64.b64encode(password_hashed).decode()
with Session(db.engine) as session:
account = session.merge(account)
account.password = base64_password_hashed account.password = base64_password_hashed
account.password_salt = base64_salt account.password_salt = base64_salt
session.commit() AccountService.reset_login_error_rate_limit(normalized_email)
AccountService.reset_login_error_rate_limit(normalized_email) click.echo(click.style("Password reset successfully.", fg="green"))
click.echo(click.style("Password reset successfully.", fg="green"))
@click.command("reset-email", help="Reset the account email.") @click.command("reset-email", help="Reset the account email.")
@@ -67,23 +65,21 @@ def reset_email(email, new_email, email_confirm):
return return
normalized_new_email = new_email.strip().lower() normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip()) with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
account = AccountService.get_account_by_email_with_case_fallback(email.strip(), session=session)
if not account: if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red")) click.echo(click.style(f"Account not found for email: {email}", fg="red"))
return return
try: try:
email_validate(normalized_new_email) email_validate(normalized_new_email)
except: except:
click.echo(click.style(f"Invalid email: {new_email}", fg="red")) click.echo(click.style(f"Invalid email: {new_email}", fg="red"))
return return
with Session(db.engine) as session:
account = session.merge(account)
account.email = normalized_new_email account.email = normalized_new_email
session.commit() click.echo(click.style("Email updated successfully.", fg="green"))
click.echo(click.style("Email updated successfully.", fg="green"))
@click.command("create-tenant", help="Create account and tenant.") @click.command("create-tenant", help="Create account and tenant.")
@@ -113,18 +109,8 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
# Validates name encoding for non-Latin characters. # Validates name encoding for non-Latin characters.
name = name.strip().encode("utf-8").decode("utf-8") if name else None name = name.strip().encode("utf-8").decode("utf-8") if name else None
# Generate a random password that satisfies the password policy. # generate random password
# The iteration limit guards against infinite loops caused by unexpected bugs in valid_password. new_password = secrets.token_urlsafe(16)
for _ in range(100):
new_password = secrets.token_urlsafe(16)
try:
valid_password(new_password)
break
except Exception:
continue
else:
click.echo(click.style("Failed to generate a valid password. Please try again.", fg="red"))
return
# register account # register account
account = RegisterService.register( account = RegisterService.register(
+3 -3
View File
@@ -11,7 +11,7 @@ from configs import dify_config
from core.helper import encrypter from core.helper import encrypter
from core.plugin.entities.plugin_daemon import CredentialType from core.plugin.entities.plugin_daemon import CredentialType
from core.plugin.impl.plugin import PluginInstaller from core.plugin.impl.plugin import PluginInstaller
from core.tools.utils.system_encryption import encrypt_system_params from core.tools.utils.system_oauth_encryption import encrypt_system_oauth_params
from extensions.ext_database import db from extensions.ext_database import db
from models import Tenant from models import Tenant
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
@@ -44,7 +44,7 @@ def setup_system_tool_oauth_client(provider, client_params):
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
oauth_client_params = encrypt_system_params(client_params_dict) oauth_client_params = encrypt_system_oauth_params(client_params_dict)
click.echo(click.style("Client params encrypted successfully.", fg="green")) click.echo(click.style("Client params encrypted successfully.", fg="green"))
except Exception as e: except Exception as e:
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
@@ -94,7 +94,7 @@ def setup_system_trigger_oauth_client(provider, client_params):
click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow")) click.echo(click.style(f"Encrypting client params: {client_params}", fg="yellow"))
click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow")) click.echo(click.style(f"Using SECRET_KEY: `{dify_config.SECRET_KEY}`", fg="yellow"))
oauth_client_params = encrypt_system_params(client_params_dict) oauth_client_params = encrypt_system_oauth_params(client_params_dict)
click.echo(click.style("Client params encrypted successfully.", fg="green")) click.echo(click.style("Client params encrypted successfully.", fg="green"))
except Exception as e: except Exception as e:
click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red")) click.echo(click.style(f"Error parsing client params: {str(e)}", fg="red"))
+3 -15
View File
@@ -1,7 +1,7 @@
import datetime import datetime
import logging import logging
import time import time
from typing import TypedDict from typing import Any
import click import click
import sqlalchemy as sa import sqlalchemy as sa
@@ -503,19 +503,7 @@ def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]:
return [row[0] for row in result] return [row[0] for row in result]
class _AppOrphanCounts(TypedDict): def _count_orphaned_draft_variables() -> dict[str, Any]:
variables: int
files: int
class OrphanedDraftVariableStatsDict(TypedDict):
total_orphaned_variables: int
total_orphaned_files: int
orphaned_app_count: int
orphaned_by_app: dict[str, _AppOrphanCounts]
def _count_orphaned_draft_variables() -> OrphanedDraftVariableStatsDict:
""" """
Count orphaned draft variables by app, including associated file counts. Count orphaned draft variables by app, including associated file counts.
@@ -538,7 +526,7 @@ def _count_orphaned_draft_variables() -> OrphanedDraftVariableStatsDict:
with db.engine.connect() as conn: with db.engine.connect() as conn:
result = conn.execute(sa.text(variables_query)) result = conn.execute(sa.text(variables_query))
orphaned_by_app: dict[str, _AppOrphanCounts] = {} orphaned_by_app = {}
total_files = 0 total_files = 0
for row in result: for row in result:
+2 -1
View File
@@ -341,10 +341,11 @@ def add_qdrant_index(field: str):
click.echo(click.style("No dataset collection bindings found.", fg="red")) click.echo(click.style("No dataset collection bindings found.", fg="red"))
return return
import qdrant_client import qdrant_client
from dify_vdb_qdrant.qdrant_vector import PathQdrantParams, QdrantConfig
from qdrant_client.http.exceptions import UnexpectedResponse from qdrant_client.http.exceptions import UnexpectedResponse
from qdrant_client.http.models import PayloadSchemaType from qdrant_client.http.models import PayloadSchemaType
from core.rag.datasource.vdb.qdrant.qdrant_vector import PathQdrantParams, QdrantConfig
for binding in bindings: for binding in bindings:
if dify_config.QDRANT_URL is None: if dify_config.QDRANT_URL is None:
raise ValueError("Qdrant URL is required.") raise ValueError("Qdrant URL is required.")
-30
View File
@@ -287,27 +287,6 @@ class MarketplaceConfig(BaseSettings):
) )
class CreatorsPlatformConfig(BaseSettings):
"""
Configuration for Creators Platform integration
"""
CREATORS_PLATFORM_FEATURES_ENABLED: bool = Field(
description="Enable or disable Creators Platform features",
default=True,
)
CREATORS_PLATFORM_API_URL: HttpUrl = Field(
description="Creators Platform API URL",
default=HttpUrl("https://creators.dify.ai"),
)
CREATORS_PLATFORM_OAUTH_CLIENT_ID: str = Field(
description="OAuth client ID for Creators Platform integration",
default="",
)
class EndpointConfig(BaseSettings): class EndpointConfig(BaseSettings):
""" """
Configuration for various application endpoints and URLs Configuration for various application endpoints and URLs
@@ -1295,13 +1274,6 @@ class PositionConfig(BaseSettings):
return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""} return {item.strip() for item in self.POSITION_TOOL_EXCLUDES.split(",") if item.strip() != ""}
class CollaborationConfig(BaseSettings):
ENABLE_COLLABORATION_MODE: bool = Field(
description="Whether to enable collaboration mode features across the workspace",
default=False,
)
class LoginConfig(BaseSettings): class LoginConfig(BaseSettings):
ENABLE_EMAIL_CODE_LOGIN: bool = Field( ENABLE_EMAIL_CODE_LOGIN: bool = Field(
description="whether to enable email code login", description="whether to enable email code login",
@@ -1400,7 +1372,6 @@ class FeatureConfig(
AuthConfig, # Changed from OAuthConfig to AuthConfig AuthConfig, # Changed from OAuthConfig to AuthConfig
BillingConfig, BillingConfig,
CodeExecutionSandboxConfig, CodeExecutionSandboxConfig,
CreatorsPlatformConfig,
TriggerConfig, TriggerConfig,
AsyncWorkflowConfig, AsyncWorkflowConfig,
PluginConfig, PluginConfig,
@@ -1428,7 +1399,6 @@ class FeatureConfig(
WorkflowConfig, WorkflowConfig,
WorkflowNodeExecutionConfig, WorkflowNodeExecutionConfig,
WorkspaceConfig, WorkspaceConfig,
CollaborationConfig,
LoginConfig, LoginConfig,
AccountConfig, AccountConfig,
SwaggerUIConfig, SwaggerUIConfig,
+10 -33
View File
@@ -1,5 +1,5 @@
import os import os
from typing import Any, Literal, TypedDict from typing import Any, Literal
from urllib.parse import parse_qsl, quote_plus from urllib.parse import parse_qsl, quote_plus
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
@@ -107,17 +107,6 @@ class KeywordStoreConfig(BaseSettings):
) )
class SQLAlchemyEngineOptionsDict(TypedDict):
pool_size: int
max_overflow: int
pool_recycle: int
pool_pre_ping: bool
connect_args: dict[str, str]
pool_use_lifo: bool
pool_reset_on_return: None
pool_timeout: int
class DatabaseConfig(BaseSettings): class DatabaseConfig(BaseSettings):
# Database type selector # Database type selector
DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field( DB_TYPE: Literal["postgresql", "mysql", "oceanbase", "seekdb"] = Field(
@@ -160,16 +149,6 @@ class DatabaseConfig(BaseSettings):
default="", default="",
) )
DB_SESSION_TIMEZONE_OVERRIDE: str = Field(
description=(
"PostgreSQL session timezone override injected via startup options."
" Default is 'UTC' for out-of-the-box consistency."
" Set to empty string to disable app-level timezone injection, for example when using RDS Proxy"
" together with a database-side default timezone."
),
default="UTC",
)
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def SQLALCHEMY_DATABASE_URI_SCHEME(self) -> str: def SQLALCHEMY_DATABASE_URI_SCHEME(self) -> str:
@@ -230,22 +209,21 @@ class DatabaseConfig(BaseSettings):
@computed_field # type: ignore[prop-decorator] @computed_field # type: ignore[prop-decorator]
@property @property
def SQLALCHEMY_ENGINE_OPTIONS(self) -> SQLAlchemyEngineOptionsDict: def SQLALCHEMY_ENGINE_OPTIONS(self) -> dict[str, Any]:
# Parse DB_EXTRAS for 'options' # Parse DB_EXTRAS for 'options'
db_extras_dict = dict(parse_qsl(self.DB_EXTRAS)) db_extras_dict = dict(parse_qsl(self.DB_EXTRAS))
options = db_extras_dict.get("options", "") options = db_extras_dict.get("options", "")
connect_args: dict[str, str] = {} connect_args = {}
# Use the dynamic SQLALCHEMY_DATABASE_URI_SCHEME property # Use the dynamic SQLALCHEMY_DATABASE_URI_SCHEME property
if self.SQLALCHEMY_DATABASE_URI_SCHEME.startswith("postgresql"): if self.SQLALCHEMY_DATABASE_URI_SCHEME.startswith("postgresql"):
merged_options = options.strip() timezone_opt = "-c timezone=UTC"
session_timezone_override = self.DB_SESSION_TIMEZONE_OVERRIDE.strip() if options:
if session_timezone_override: merged_options = f"{options} {timezone_opt}"
timezone_opt = f"-c timezone={session_timezone_override}" else:
merged_options = f"{merged_options} {timezone_opt}".strip() if merged_options else timezone_opt merged_options = timezone_opt
if merged_options: connect_args = {"options": merged_options}
connect_args = {"options": merged_options}
result: SQLAlchemyEngineOptionsDict = { return {
"pool_size": self.SQLALCHEMY_POOL_SIZE, "pool_size": self.SQLALCHEMY_POOL_SIZE,
"max_overflow": self.SQLALCHEMY_MAX_OVERFLOW, "max_overflow": self.SQLALCHEMY_MAX_OVERFLOW,
"pool_recycle": self.SQLALCHEMY_POOL_RECYCLE, "pool_recycle": self.SQLALCHEMY_POOL_RECYCLE,
@@ -255,7 +233,6 @@ class DatabaseConfig(BaseSettings):
"pool_reset_on_return": None, "pool_reset_on_return": None,
"pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT, "pool_timeout": self.SQLALCHEMY_POOL_TIMEOUT,
} }
return result
class CeleryConfig(DatabaseConfig): class CeleryConfig(DatabaseConfig):
-36
View File
@@ -32,11 +32,6 @@ class RedisConfig(BaseSettings):
default=0, default=0,
) )
REDIS_KEY_PREFIX: str = Field(
description="Optional global prefix for Redis keys, topics, and transport artifacts",
default="",
)
REDIS_USE_SSL: bool = Field( REDIS_USE_SSL: bool = Field(
description="Enable SSL/TLS for the Redis connection", description="Enable SSL/TLS for the Redis connection",
default=False, default=False,
@@ -122,37 +117,6 @@ class RedisConfig(BaseSettings):
default=None, default=None,
) )
REDIS_RETRY_RETRIES: NonNegativeInt = Field(
description="Maximum number of retries per Redis command on "
"transient failures (ConnectionError, TimeoutError, socket.timeout)",
default=3,
)
REDIS_RETRY_BACKOFF_BASE: PositiveFloat = Field(
description="Base delay in seconds for exponential backoff between retries",
default=1.0,
)
REDIS_RETRY_BACKOFF_CAP: PositiveFloat = Field(
description="Maximum backoff delay in seconds between retries",
default=10.0,
)
REDIS_SOCKET_TIMEOUT: PositiveFloat | None = Field(
description="Socket timeout in seconds for Redis read/write operations",
default=5.0,
)
REDIS_SOCKET_CONNECT_TIMEOUT: PositiveFloat | None = Field(
description="Socket timeout in seconds for Redis connection establishment",
default=5.0,
)
REDIS_HEALTH_CHECK_INTERVAL: NonNegativeInt = Field(
description="Interval in seconds between Redis connection health checks (0 to disable)",
default=30,
)
@field_validator("REDIS_MAX_CONNECTIONS", mode="before") @field_validator("REDIS_MAX_CONNECTIONS", mode="before")
@classmethod @classmethod
def _empty_string_to_none_for_max_conns(cls, v): def _empty_string_to_none_for_max_conns(cls, v):
@@ -1,3 +1,4 @@
from holo_search_sdk.types import BaseQuantizationType, DistanceType, TokenizerType
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -41,17 +42,17 @@ class HologresConfig(BaseSettings):
default="public", default="public",
) )
HOLOGRES_TOKENIZER: str = Field( HOLOGRES_TOKENIZER: TokenizerType = Field(
description="Tokenizer for full-text search index (e.g., 'jieba', 'ik', 'standard', 'simple').", description="Tokenizer for full-text search index (e.g., 'jieba', 'ik', 'standard', 'simple').",
default="jieba", default="jieba",
) )
HOLOGRES_DISTANCE_METHOD: str = Field( HOLOGRES_DISTANCE_METHOD: DistanceType = Field(
description="Distance method for vector index (e.g., 'Cosine', 'Euclidean', 'InnerProduct').", description="Distance method for vector index (e.g., 'Cosine', 'Euclidean', 'InnerProduct').",
default="Cosine", default="Cosine",
) )
HOLOGRES_BASE_QUANTIZATION_TYPE: str = Field( HOLOGRES_BASE_QUANTIZATION_TYPE: BaseQuantizationType = Field(
description="Base quantization type for vector index (e.g., 'rabitq', 'sq8', 'fp16', 'fp32').", description="Base quantization type for vector index (e.g., 'rabitq', 'sq8', 'fp16', 'fp32').",
default="rabitq", default="rabitq",
) )
+1 -3
View File
@@ -1,7 +1,5 @@
"""Configuration for InterSystems IRIS vector database.""" """Configuration for InterSystems IRIS vector database."""
from typing import Any
from pydantic import Field, PositiveInt, model_validator from pydantic import Field, PositiveInt, model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
@@ -66,7 +64,7 @@ class IrisVectorConfig(BaseSettings):
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def validate_config(cls, values: dict[str, Any]) -> dict[str, Any]: def validate_config(cls, values: dict) -> dict:
"""Validate IRIS configuration values. """Validate IRIS configuration values.
Args: Args:
-1
View File
@@ -1 +0,0 @@
CURRENT_APP_DSL_VERSION = "0.6.0"
+22 -22
View File
@@ -19,7 +19,7 @@
"name": "Website Generator" "name": "Website Generator"
}, },
"app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041", "app_id": "b53545b1-79ea-4da3-b31a-c39391c6f041",
"categories": ["Programming"], "category": "Programming",
"copyright": null, "copyright": null,
"description": null, "description": null,
"is_listed": true, "is_listed": true,
@@ -35,7 +35,7 @@
"name": "Investment Analysis Report Copilot" "name": "Investment Analysis Report Copilot"
}, },
"app_id": "a23b57fa-85da-49c0-a571-3aff375976c1", "app_id": "a23b57fa-85da-49c0-a571-3aff375976c1",
"categories": ["Agent"], "category": "Agent",
"copyright": "Dify.AI", "copyright": "Dify.AI",
"description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n", "description": "Welcome to your personalized Investment Analysis Copilot service, where we delve into the depths of stock analysis to provide you with comprehensive insights. \n",
"is_listed": true, "is_listed": true,
@@ -51,7 +51,7 @@
"name": "Workflow Planning Assistant " "name": "Workflow Planning Assistant "
}, },
"app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1", "app_id": "f3303a7d-a81c-404e-b401-1f8711c998c1",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ", "description": "An assistant that helps you plan and select the right node for a workflow (V0.6.0). ",
"is_listed": true, "is_listed": true,
@@ -67,7 +67,7 @@
"name": "Automated Email Reply " "name": "Automated Email Reply "
}, },
"app_id": "e9d92058-7d20-4904-892f-75d90bef7587", "app_id": "e9d92058-7d20-4904-892f-75d90bef7587",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ", "description": "Reply emails using Gmail API. It will automatically retrieve email in your inbox and create a response in Gmail. \nConfigure your Gmail API in Google Cloud Console. ",
"is_listed": true, "is_listed": true,
@@ -83,7 +83,7 @@
"name": "Book Translation " "name": "Book Translation "
}, },
"app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4", "app_id": "98b87f88-bd22-4d86-8b74-86beba5e0ed4",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ", "description": "A workflow designed to translate a full book up to 15000 tokens per run. Uses Code node to separate text into chunks and Iteration to translate each chunk. ",
"is_listed": true, "is_listed": true,
@@ -99,7 +99,7 @@
"name": "Python bug fixer" "name": "Python bug fixer"
}, },
"app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e", "app_id": "cae337e6-aec5-4c7b-beca-d6f1a808bd5e",
"categories": ["Programming"], "category": "Programming",
"copyright": null, "copyright": null,
"description": null, "description": null,
"is_listed": true, "is_listed": true,
@@ -115,7 +115,7 @@
"name": "Code Interpreter" "name": "Code Interpreter"
}, },
"app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f", "app_id": "d077d587-b072-4f2c-b631-69ed1e7cdc0f",
"categories": ["Programming"], "category": "Programming",
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "Code interpreter, clarifying the syntax and semantics of the code.", "description": "Code interpreter, clarifying the syntax and semantics of the code.",
"is_listed": true, "is_listed": true,
@@ -131,7 +131,7 @@
"name": "SVG Logo Design " "name": "SVG Logo Design "
}, },
"app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca", "app_id": "73fbb5f1-c15d-4d74-9cc8-46d9db9b2cca",
"categories": ["Agent"], "category": "Agent",
"copyright": "Dify.AI", "copyright": "Dify.AI",
"description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ", "description": "Hello, I am your creative partner in bringing ideas to vivid life! I can assist you in creating stunning designs by leveraging abilities of DALL·E 3. ",
"is_listed": true, "is_listed": true,
@@ -147,7 +147,7 @@
"name": "Long Story Generator (Iteration) " "name": "Long Story Generator (Iteration) "
}, },
"app_id": "5efb98d7-176b-419c-b6ef-50767391ab62", "app_id": "5efb98d7-176b-419c-b6ef-50767391ab62",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ", "description": "A workflow demonstrating how to use Iteration node to generate long article that is longer than the context length of LLMs. ",
"is_listed": true, "is_listed": true,
@@ -163,7 +163,7 @@
"name": "Text Summarization Workflow" "name": "Text Summarization Workflow"
}, },
"app_id": "f00c4531-6551-45ee-808f-1d7903099515", "app_id": "f00c4531-6551-45ee-808f-1d7903099515",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.", "description": "Based on users' choice, retrieve external knowledge to more accurately summarize articles.",
"is_listed": true, "is_listed": true,
@@ -179,7 +179,7 @@
"name": "YouTube Channel Data Analysis" "name": "YouTube Channel Data Analysis"
}, },
"app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638", "app_id": "be591209-2ca8-410f-8f3b-ca0e530dd638",
"categories": ["Agent"], "category": "Agent",
"copyright": "Dify.AI", "copyright": "Dify.AI",
"description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ", "description": "I am a YouTube Channel Data Analysis Copilot, I am here to provide expert data analysis tailored to your needs. ",
"is_listed": true, "is_listed": true,
@@ -195,7 +195,7 @@
"name": "Article Grading Bot" "name": "Article Grading Bot"
}, },
"app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f", "app_id": "a747f7b4-c48b-40d6-b313-5e628232c05f",
"categories": ["Writing"], "category": "Writing",
"copyright": null, "copyright": null,
"description": "Assess the quality of articles and text based on user defined criteria. ", "description": "Assess the quality of articles and text based on user defined criteria. ",
"is_listed": true, "is_listed": true,
@@ -211,7 +211,7 @@
"name": "SEO Blog Generator" "name": "SEO Blog Generator"
}, },
"app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5", "app_id": "18f3bd03-524d-4d7a-8374-b30dbe7c69d5",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.", "description": "Workflow for retrieving information from the internet, followed by segmented generation of SEO blogs.",
"is_listed": true, "is_listed": true,
@@ -227,7 +227,7 @@
"name": "SQL Creator" "name": "SQL Creator"
}, },
"app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744", "app_id": "050ef42e-3e0c-40c1-a6b6-a64f2c49d744",
"categories": ["Programming"], "category": "Programming",
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.", "description": "Write SQL from natural language by pasting in your schema with the request.Please describe your query requirements in natural language and select the target database type.",
"is_listed": true, "is_listed": true,
@@ -243,7 +243,7 @@
"name": "Sentiment Analysis " "name": "Sentiment Analysis "
}, },
"app_id": "f06bf86b-d50c-4895-a942-35112dbe4189", "app_id": "f06bf86b-d50c-4895-a942-35112dbe4189",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.", "description": "Batch sentiment analysis of text, followed by JSON output of sentiment classification along with scores.",
"is_listed": true, "is_listed": true,
@@ -259,7 +259,7 @@
"name": "Strategic Consulting Expert" "name": "Strategic Consulting Expert"
}, },
"app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2", "app_id": "7e8ca1ae-02f2-4b5f-979e-62d19133bee2",
"categories": ["Assistant"], "category": "Assistant",
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "I can answer your questions related to strategic marketing.", "description": "I can answer your questions related to strategic marketing.",
"is_listed": true, "is_listed": true,
@@ -275,7 +275,7 @@
"name": "Code Converter" "name": "Code Converter"
}, },
"app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a", "app_id": "4006c4b2-0735-4f37-8dbb-fb1a8c5bd87a",
"categories": ["Programming"], "category": "Programming",
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.", "description": "This is an application that provides the ability to convert code snippets in multiple programming languages. You can input the code you wish to convert, select the target programming language, and get the desired output.",
"is_listed": true, "is_listed": true,
@@ -291,7 +291,7 @@
"name": "Question Classifier + Knowledge + Chatbot " "name": "Question Classifier + Knowledge + Chatbot "
}, },
"app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7", "app_id": "d9f6b733-e35d-4a40-9f38-ca7bbfa009f7",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.", "description": "Basic Workflow Template, a chatbot capable of identifying intents alongside with a knowledge base.",
"is_listed": true, "is_listed": true,
@@ -307,7 +307,7 @@
"name": "AI Front-end interviewer" "name": "AI Front-end interviewer"
}, },
"app_id": "127efead-8944-4e20-ba9d-12402eb345e0", "app_id": "127efead-8944-4e20-ba9d-12402eb345e0",
"categories": ["HR"], "category": "HR",
"copyright": "Copyright 2023 Dify", "copyright": "Copyright 2023 Dify",
"description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.", "description": "A simulated front-end interviewer that tests the skill level of front-end development through questioning.",
"is_listed": true, "is_listed": true,
@@ -323,7 +323,7 @@
"name": "Knowledge Retrieval + Chatbot " "name": "Knowledge Retrieval + Chatbot "
}, },
"app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce", "app_id": "e9870913-dd01-4710-9f06-15d4180ca1ce",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Basic Workflow Template, A chatbot with a knowledge base. ", "description": "Basic Workflow Template, A chatbot with a knowledge base. ",
"is_listed": true, "is_listed": true,
@@ -339,7 +339,7 @@
"name": "Email Assistant Workflow " "name": "Email Assistant Workflow "
}, },
"app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709", "app_id": "dd5b6353-ae9b-4bce-be6a-a681a12cf709",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.", "description": "A multifunctional email assistant capable of summarizing, replying, composing, proofreading, and checking grammar.",
"is_listed": true, "is_listed": true,
@@ -355,7 +355,7 @@
"name": "Customer Review Analysis Workflow " "name": "Customer Review Analysis Workflow "
}, },
"app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a", "app_id": "9c0cd31f-4b62-4005-adf5-e3888d08654a",
"categories": ["Workflow"], "category": "Workflow",
"copyright": null, "copyright": null,
"description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.", "description": "Utilize LLM (Large Language Models) to classify customer reviews and forward them to the internal system.",
"is_listed": true, "is_listed": true,
+3 -1
View File
@@ -10,7 +10,7 @@ import threading
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from collections.abc import Callable, Generator from collections.abc import Callable, Generator
from contextlib import AbstractContextManager, contextmanager from contextlib import AbstractContextManager, contextmanager
from typing import Any, Protocol, final, runtime_checkable from typing import Any, Protocol, TypeVar, final, runtime_checkable
from pydantic import BaseModel from pydantic import BaseModel
@@ -188,6 +188,8 @@ class ExecutionContextBuilder:
_capturer: Callable[[], IExecutionContext] | None = None _capturer: Callable[[], IExecutionContext] | None = None
_tenant_context_providers: dict[tuple[str, str], Callable[[], BaseModel]] = {} _tenant_context_providers: dict[tuple[str, str], Callable[[], BaseModel]] = {}
T = TypeVar("T", bound=BaseModel)
class ContextProviderNotFoundError(KeyError): class ContextProviderNotFoundError(KeyError):
"""Raised when a tenant-scoped context provider is missing.""" """Raised when a tenant-scoped context provider is missing."""
+4 -1
View File
@@ -1,4 +1,7 @@
from contextvars import ContextVar from contextvars import ContextVar
from typing import Generic, TypeVar
T = TypeVar("T")
class HiddenValue: class HiddenValue:
@@ -8,7 +11,7 @@ class HiddenValue:
_default = HiddenValue() _default = HiddenValue()
class RecyclableContextVar[T]: class RecyclableContextVar(Generic[T]):
""" """
RecyclableContextVar is a wrapper around ContextVar RecyclableContextVar is a wrapper around ContextVar
It's safe to use in gunicorn with thread recycling, but features like `reset` are not available for now It's safe to use in gunicorn with thread recycling, but features like `reset` are not available for now
@@ -1,104 +0,0 @@
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, model_validator
from libs.helper import UUIDStrOrEmpty
# --- Conversation schemas ---
class ConversationRenamePayload(BaseModel):
name: str | None = None
auto_generate: bool = False
@model_validator(mode="after")
def validate_name_requirement(self):
if not self.auto_generate:
if self.name is None or not self.name.strip():
raise ValueError("name is required when auto_generate is false")
return self
# --- Message schemas ---
class MessageListQuery(BaseModel):
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
class MessageFeedbackPayload(BaseModel):
rating: Literal["like", "dislike"] | None = None
content: str | None = None
# --- Saved message schemas ---
class SavedMessageListQuery(BaseModel):
last_id: UUIDStrOrEmpty | None = None
limit: int = Field(default=20, ge=1, le=100)
class SavedMessageCreatePayload(BaseModel):
message_id: UUIDStrOrEmpty
# --- Workflow schemas ---
class DefaultBlockConfigQuery(BaseModel):
q: str | None = None
class WorkflowListQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=10, ge=1, le=100)
user_id: str | None = None
named_only: bool = False
class WorkflowRunPayload(BaseModel):
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = None
class WorkflowUpdatePayload(BaseModel):
marked_name: str | None = Field(default=None, max_length=20)
marked_comment: str | None = Field(default=None, max_length=100)
# --- Dataset schemas ---
DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
class ChildChunkCreatePayload(BaseModel):
content: str
class ChildChunkUpdatePayload(BaseModel):
content: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class MetadataUpdatePayload(BaseModel):
name: str
# --- Audio schemas ---
class TextToAudioPayload(BaseModel):
message_id: str | None = Field(default=None, description="Message ID")
voice: str | None = Field(default=None, description="Voice to use for TTS")
text: str | None = Field(default=None, description="Text to convert to audio")
streaming: bool | None = Field(default=None, description="Enable streaming response")
+5 -5
View File
@@ -1,14 +1,14 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any, TypeAlias
from pydantic import BaseModel, ConfigDict, computed_field
from graphon.file import helpers as file_helpers from graphon.file import helpers as file_helpers
from pydantic import BaseModel, ConfigDict, computed_field
from models.model import IconType from models.model import IconType
type JSONValue = str | int | float | bool | None | dict[str, Any] | list[Any] JSONValue: TypeAlias = str | int | float | bool | None | dict[str, Any] | list[Any]
type JSONObject = dict[str, Any] JSONObject: TypeAlias = dict[str, Any]
class SystemParameters(BaseModel): class SystemParameters(BaseModel):
+1 -2
View File
@@ -41,8 +41,7 @@ def guess_file_info_from_response(response: httpx.Response):
# Try to extract filename from URL # Try to extract filename from URL
parsed_url = urllib.parse.urlparse(url) parsed_url = urllib.parse.urlparse(url)
url_path = parsed_url.path url_path = parsed_url.path
# Decode percent-encoded characters in the path segment filename = os.path.basename(url_path)
filename = urllib.parse.unquote(os.path.basename(url_path))
# If filename couldn't be extracted, use Content-Disposition header # If filename couldn't be extracted, use Content-Disposition header
if not filename: if not filename:
-6
View File
@@ -1,6 +0,0 @@
from pydantic import BaseModel, JsonValue
class HumanInputFormSubmitPayload(BaseModel):
inputs: dict[str, JsonValue]
action: str
-4
View File
@@ -65,7 +65,6 @@ from .app import (
statistic, statistic,
workflow, workflow,
workflow_app_log, workflow_app_log,
workflow_comment,
workflow_draft_variable, workflow_draft_variable,
workflow_run, workflow_run,
workflow_statistic, workflow_statistic,
@@ -117,7 +116,6 @@ from .explore import (
saved_message, saved_message,
trial, trial,
) )
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
# Import tag controllers # Import tag controllers
from .tag import tags from .tag import tags
@@ -203,7 +201,6 @@ __all__ = [
"saved_message", "saved_message",
"setup", "setup",
"site", "site",
"socketio_workflow",
"spec", "spec",
"statistic", "statistic",
"tags", "tags",
@@ -214,7 +211,6 @@ __all__ = [
"website", "website",
"workflow", "workflow",
"workflow_app_log", "workflow_app_log",
"workflow_comment",
"workflow_draft_variable", "workflow_draft_variable",
"workflow_run", "workflow_run",
"workflow_statistic", "workflow_statistic",
+8 -5
View File
@@ -2,7 +2,7 @@ import csv
import io import io
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import cast from typing import ParamSpec, TypeVar
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource
@@ -18,7 +18,10 @@ from core.db.session_factory import session_factory
from extensions.ext_database import db from extensions.ext_database import db
from libs.token import extract_access_token from libs.token import extract_access_token
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
from services.billing_service import BillingService, LangContentDict from services.billing_service import BillingService
P = ParamSpec("P")
R = TypeVar("R")
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
@@ -69,9 +72,9 @@ console_ns.schema_model(
) )
def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]: def admin_required(view: Callable[P, R]):
@wraps(view) @wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R: def decorated(*args: P.args, **kwargs: P.kwargs):
if not dify_config.ADMIN_API_KEY: if not dify_config.ADMIN_API_KEY:
raise Unauthorized("API key is invalid.") raise Unauthorized("API key is invalid.")
@@ -329,7 +332,7 @@ class UpsertNotificationApi(Resource):
def post(self): def post(self):
payload = UpsertNotificationPayload.model_validate(console_ns.payload) payload = UpsertNotificationPayload.model_validate(console_ns.payload)
result = BillingService.upsert_notification( result = BillingService.upsert_notification(
contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents], contents=[c.model_dump() for c in payload.contents],
frequency=payload.frequency, frequency=payload.frequency,
status=payload.status, status=payload.status,
notification_id=payload.notification_id, notification_id=payload.notification_id,
+22 -34
View File
@@ -1,16 +1,12 @@
from datetime import datetime
import flask_restx import flask_restx
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from flask_restx._http import HTTPStatus from flask_restx._http import HTTPStatus
from pydantic import field_validator
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden from werkzeug.exceptions import Forbidden
from controllers.common.schema import register_schema_models
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.dataset import Dataset from models.dataset import Dataset
from models.enums import ApiTokenType from models.enums import ApiTokenType
@@ -20,31 +16,21 @@ from services.api_token_service import ApiTokenCache
from . import console_ns from . import console_ns
from .wraps import account_initialization_required, edit_permission_required, setup_required from .wraps import account_initialization_required, edit_permission_required, setup_required
api_key_fields = {
"id": fields.String,
"type": fields.String,
"token": fields.String,
"last_used_at": TimestampField,
"created_at": TimestampField,
}
def _to_timestamp(value: datetime | int | None) -> int | None: api_key_item_model = console_ns.model("ApiKeyItem", api_key_fields)
if isinstance(value, datetime):
return int(value.timestamp())
return value
api_key_list = {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")}
class ApiKeyItem(ResponseModel): api_key_list_model = console_ns.model(
id: str "ApiKeyList", {"data": fields.List(fields.Nested(api_key_item_model), attribute="items")}
type: str )
token: str
last_used_at: int | None = None
created_at: int | None = None
@field_validator("last_used_at", "created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class ApiKeyList(ResponseModel):
data: list[ApiKeyItem]
register_schema_models(console_ns, ApiKeyItem, ApiKeyList)
def _get_resource(resource_id, tenant_id, resource_model): def _get_resource(resource_id, tenant_id, resource_model):
@@ -68,6 +54,7 @@ class BaseApiKeyListResource(Resource):
token_prefix: str | None = None token_prefix: str | None = None
max_keys = 10 max_keys = 10
@marshal_with(api_key_list_model)
def get(self, resource_id): def get(self, resource_id):
assert self.resource_id_field is not None, "resource_id_field must be set" assert self.resource_id_field is not None, "resource_id_field must be set"
resource_id = str(resource_id) resource_id = str(resource_id)
@@ -79,8 +66,9 @@ class BaseApiKeyListResource(Resource):
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
) )
).all() ).all()
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json") return {"items": keys}
@marshal_with(api_key_item_model)
@edit_permission_required @edit_permission_required
def post(self, resource_id): def post(self, resource_id):
assert self.resource_id_field is not None, "resource_id_field must be set" assert self.resource_id_field is not None, "resource_id_field must be set"
@@ -112,7 +100,7 @@ class BaseApiKeyListResource(Resource):
api_token.type = self.resource_type api_token.type = self.resource_type
db.session.add(api_token) db.session.add(api_token)
db.session.commit() db.session.commit()
return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 201 return api_token, 201
class BaseApiKeyResource(Resource): class BaseApiKeyResource(Resource):
@@ -159,7 +147,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_app_api_keys") @console_ns.doc("get_app_api_keys")
@console_ns.doc(description="Get all API keys for an app") @console_ns.doc(description="Get all API keys for an app")
@console_ns.doc(params={"resource_id": "App ID"}) @console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) @console_ns.response(200, "Success", api_key_list_model)
def get(self, resource_id): # type: ignore def get(self, resource_id): # type: ignore
"""Get all API keys for an app""" """Get all API keys for an app"""
return super().get(resource_id) return super().get(resource_id)
@@ -167,7 +155,7 @@ class AppApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("create_app_api_key") @console_ns.doc("create_app_api_key")
@console_ns.doc(description="Create a new API key for an app") @console_ns.doc(description="Create a new API key for an app")
@console_ns.doc(params={"resource_id": "App ID"}) @console_ns.doc(params={"resource_id": "App ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(201, "API key created successfully", api_key_item_model)
@console_ns.response(400, "Maximum keys exceeded") @console_ns.response(400, "Maximum keys exceeded")
def post(self, resource_id): # type: ignore def post(self, resource_id): # type: ignore
"""Create a new API key for an app""" """Create a new API key for an app"""
@@ -199,7 +187,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("get_dataset_api_keys") @console_ns.doc("get_dataset_api_keys")
@console_ns.doc(description="Get all API keys for a dataset") @console_ns.doc(description="Get all API keys for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"}) @console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__]) @console_ns.response(200, "Success", api_key_list_model)
def get(self, resource_id): # type: ignore def get(self, resource_id): # type: ignore
"""Get all API keys for a dataset""" """Get all API keys for a dataset"""
return super().get(resource_id) return super().get(resource_id)
@@ -207,7 +195,7 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
@console_ns.doc("create_dataset_api_key") @console_ns.doc("create_dataset_api_key")
@console_ns.doc(description="Create a new API key for a dataset") @console_ns.doc(description="Create a new API key for a dataset")
@console_ns.doc(params={"resource_id": "Dataset ID"}) @console_ns.doc(params={"resource_id": "Dataset ID"})
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__]) @console_ns.response(201, "API key created successfully", api_key_item_model)
@console_ns.response(400, "Maximum keys exceeded") @console_ns.response(400, "Maximum keys exceeded")
def post(self, resource_id): # type: ignore def post(self, resource_id): # type: ignore
"""Create a new API key for a dataset""" """Create a new API key for a dataset"""
@@ -5,7 +5,7 @@ from pydantic import BaseModel, Field
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from libs.login import login_required from libs.login import login_required
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService from services.advanced_prompt_template_service import AdvancedPromptTemplateService
class AdvancedPromptTemplateQuery(BaseModel): class AdvancedPromptTemplateQuery(BaseModel):
@@ -35,10 +35,5 @@ class AdvancedPromptTemplateList(Resource):
@account_initialization_required @account_initialization_required
def get(self): def get(self):
args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = AdvancedPromptTemplateQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
prompt_args: AdvancedPromptTemplateArgs = {
"app_mode": args.app_mode, return AdvancedPromptTemplateService.get_prompt(args.model_dump())
"model_mode": args.model_mode,
"model_name": args.model_name,
"has_context": args.has_context,
}
return AdvancedPromptTemplateService.get_prompt(prompt_args)
+8 -31
View File
@@ -25,13 +25,7 @@ from fields.annotation_fields import (
) )
from libs.helper import uuid_value from libs.helper import uuid_value
from libs.login import login_required from libs.login import login_required
from services.annotation_service import ( from services.annotation_service import AppAnnotationService
AppAnnotationService,
EnableAnnotationArgs,
UpdateAnnotationArgs,
UpdateAnnotationSettingArgs,
UpsertAnnotationArgs,
)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
@@ -126,12 +120,7 @@ class AnnotationReplyActionApi(Resource):
args = AnnotationReplyPayload.model_validate(console_ns.payload) args = AnnotationReplyPayload.model_validate(console_ns.payload)
match action: match action:
case "enable": case "enable":
enable_args: EnableAnnotationArgs = { result = AppAnnotationService.enable_app_annotation(args.model_dump(), app_id)
"score_threshold": args.score_threshold,
"embedding_provider_name": args.embedding_provider_name,
"embedding_model_name": args.embedding_model_name,
}
result = AppAnnotationService.enable_app_annotation(enable_args, app_id)
case "disable": case "disable":
result = AppAnnotationService.disable_app_annotation(app_id) result = AppAnnotationService.disable_app_annotation(app_id)
return result, 200 return result, 200
@@ -172,8 +161,7 @@ class AppAnnotationSettingUpdateApi(Resource):
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload) args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold} result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, args.model_dump())
result = AppAnnotationService.update_app_annotation_setting(app_id, annotation_setting_id, setting_args)
return result, 200 return result, 200
@@ -249,16 +237,8 @@ class AnnotationApi(Resource):
def post(self, app_id): def post(self, app_id):
app_id = str(app_id) app_id = str(app_id)
args = CreateAnnotationPayload.model_validate(console_ns.payload) args = CreateAnnotationPayload.model_validate(console_ns.payload)
upsert_args: UpsertAnnotationArgs = {} data = args.model_dump(exclude_none=True)
if args.answer is not None: annotation = AppAnnotationService.up_insert_app_annotation_from_message(data, app_id)
upsert_args["answer"] = args.answer
if args.content is not None:
upsert_args["content"] = args.content
if args.message_id is not None:
upsert_args["message_id"] = args.message_id
if args.question is not None:
upsert_args["question"] = args.question
annotation = AppAnnotationService.up_insert_app_annotation_from_message(upsert_args, app_id)
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required @setup_required
@@ -335,12 +315,9 @@ class AnnotationUpdateDeleteApi(Resource):
app_id = str(app_id) app_id = str(app_id)
annotation_id = str(annotation_id) annotation_id = str(annotation_id)
args = UpdateAnnotationPayload.model_validate(console_ns.payload) args = UpdateAnnotationPayload.model_validate(console_ns.payload)
update_args: UpdateAnnotationArgs = {} annotation = AppAnnotationService.update_app_annotation_directly(
if args.answer is not None: args.model_dump(exclude_none=True), app_id, annotation_id
update_args["answer"] = args.answer )
if args.question is not None:
update_args["question"] = args.question
annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_id, annotation_id)
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json") return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required @setup_required
+43 -83
View File
@@ -1,15 +1,15 @@
import logging import logging
import re
import uuid import uuid
from datetime import datetime from datetime import datetime
from typing import Any, Literal from typing import Any, Literal, TypeAlias
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, computed_field, field_validator from graphon.enums import WorkflowExecutionStatus
from graphon.file import helpers as file_helpers
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker
from werkzeug.datastructures import MultiDict
from werkzeug.exceptions import BadRequest from werkzeug.exceptions import BadRequest
from controllers.common.helpers import FileInfo from controllers.common.helpers import FileInfo
@@ -26,27 +26,25 @@ from controllers.console.wraps import (
setup_required, setup_required,
) )
from core.ops.ops_trace_manager import OpsTraceManager from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.trigger.constants import TRIGGER_NODE_TYPES from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import App, DatasetPermissionEnum, Workflow from models import App, DatasetPermissionEnum, Workflow
from models.model import IconType from models.model import IconType
from services.app_dsl_service import AppDslService from services.app_dsl_service import AppDslService, ImportMode
from services.app_service import AppService from services.app_service import AppService
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import ( from services.entities.knowledge_entities.knowledge_entities import (
DataSource, DataSource,
InfoList, InfoList,
NotionIcon, NotionIcon,
NotionInfo, NotionInfo,
NotionPage, NotionPage,
PreProcessingRule,
RerankingModel, RerankingModel,
Rule,
Segmentation,
WebsiteInfo, WebsiteInfo,
WeightKeywordSetting, WeightKeywordSetting,
WeightModel, WeightModel,
@@ -59,7 +57,6 @@ ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "co
register_enum_models(console_ns, IconType) register_enum_models(console_ns, IconType)
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
class AppListQuery(BaseModel): class AppListQuery(BaseModel):
@@ -69,19 +66,22 @@ class AppListQuery(BaseModel):
default="all", description="App mode filter" default="all", description="App mode filter"
) )
name: str | None = Field(default=None, description="Filter by app name") name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") tag_ids: list[str] | None = Field(default=None, description="Comma-separated tag IDs")
is_created_by_me: bool | None = Field(default=None, description="Filter by creator") is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
@field_validator("tag_ids", mode="before") @field_validator("tag_ids", mode="before")
@classmethod @classmethod
def validate_tag_ids(cls, value: list[str] | None) -> list[str] | None: def validate_tag_ids(cls, value: str | list[str] | None) -> list[str] | None:
if not value: if not value:
return None return None
if not isinstance(value, list): if isinstance(value, str):
raise ValueError("Unsupported tag_ids type.") items = [item.strip() for item in value.split(",") if item.strip()]
elif isinstance(value, list):
items = [str(item).strip() for item in value if item and str(item).strip()]
else:
raise TypeError("Unsupported tag_ids type.")
items = [str(item).strip() for item in value if item and str(item).strip()]
if not items: if not items:
return None return None
@@ -91,26 +91,6 @@ class AppListQuery(BaseModel):
raise ValueError("Invalid UUID format in tag_ids.") from exc raise ValueError("Invalid UUID format in tag_ids.") from exc
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
for key in query_args:
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
if match:
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
continue
value = query_args.get(key)
if value is not None:
normalized[key] = value
if indexed_tag_ids:
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
return normalized
class CreateAppPayload(BaseModel): class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name") name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400) description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
@@ -149,7 +129,6 @@ class AppNamePayload(BaseModel):
class AppIconPayload(BaseModel): class AppIconPayload(BaseModel):
icon: str | None = Field(default=None, description="Icon data") icon: str | None = Field(default=None, description="Icon data")
icon_type: IconType | None = Field(default=None, description="Icon type")
icon_background: str | None = Field(default=None, description="Icon background color") icon_background: str | None = Field(default=None, description="Icon background color")
@@ -173,7 +152,17 @@ class AppTracePayload(BaseModel):
return value return value
type JSONValue = Any JSONValue: TypeAlias = Any
class ResponseModel(BaseModel):
model_config = ConfigDict(
from_attributes=True,
extra="ignore",
populate_by_name=True,
serialize_by_alias=True,
protected_namespaces=(),
)
def _to_timestamp(value: datetime | int | None) -> int | None: def _to_timestamp(value: datetime | int | None) -> int | None:
@@ -182,6 +171,15 @@ def _to_timestamp(value: datetime | int | None) -> int | None:
return value return value
def _build_icon_url(icon_type: str | IconType | None, icon: str | None) -> str | None:
if icon is None or icon_type is None:
return None
icon_type_value = icon_type.value if isinstance(icon_type, IconType) else str(icon_type)
if icon_type_value.lower() != IconType.IMAGE:
return None
return file_helpers.get_signed_file_url(icon)
class Tag(ResponseModel): class Tag(ResponseModel):
id: str id: str
name: str name: str
@@ -304,7 +302,7 @@ class Site(ResponseModel):
@computed_field(return_type=str | None) # type: ignore @computed_field(return_type=str | None) # type: ignore
@property @property
def icon_url(self) -> str | None: def icon_url(self) -> str | None:
return build_icon_url(self.icon_type, self.icon) return _build_icon_url(self.icon_type, self.icon)
@field_validator("icon_type", mode="before") @field_validator("icon_type", mode="before")
@classmethod @classmethod
@@ -354,7 +352,7 @@ class AppPartial(ResponseModel):
@computed_field(return_type=str | None) # type: ignore @computed_field(return_type=str | None) # type: ignore
@property @property
def icon_url(self) -> str | None: def icon_url(self) -> str | None:
return build_icon_url(self.icon_type, self.icon) return _build_icon_url(self.icon_type, self.icon)
@field_validator("created_at", "updated_at", mode="before") @field_validator("created_at", "updated_at", mode="before")
@classmethod @classmethod
@@ -402,7 +400,7 @@ class AppDetailWithSite(AppDetail):
@computed_field(return_type=str | None) # type: ignore @computed_field(return_type=str | None) # type: ignore
@property @property
def icon_url(self) -> str | None: def icon_url(self) -> str | None:
return build_icon_url(self.icon_type, self.icon) return _build_icon_url(self.icon_type, self.icon)
class AppPagination(ResponseModel): class AppPagination(ResponseModel):
@@ -475,7 +473,7 @@ class AppListApi(Resource):
"""Get app list""" """Get app list"""
current_user, current_tenant_id = current_account_with_tenant() current_user, current_tenant_id = current_account_with_tenant()
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args)) args = AppListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
args_dict = args.model_dump() args_dict = args.model_dump()
# get app list # get app list
@@ -644,7 +642,7 @@ class AppCopyApi(Resource):
args = CopyAppPayload.model_validate(console_ns.payload or {}) args = CopyAppPayload.model_validate(console_ns.payload or {})
with Session(db.engine, expire_on_commit=False) as session: with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
import_service = AppDslService(session) import_service = AppDslService(session)
yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True) yaml_content = import_service.export_dsl(app_model=app_model, include_secret=True)
result = import_service.import_app( result = import_service.import_app(
@@ -657,13 +655,6 @@ class AppCopyApi(Resource):
icon=args.icon, icon=args.icon,
icon_background=args.icon_background, icon_background=args.icon_background,
) )
if result.status == ImportStatus.FAILED:
session.rollback()
return result.model_dump(mode="json"), 400
if result.status == ImportStatus.PENDING:
session.rollback()
return result.model_dump(mode="json"), 202
session.commit()
# Inherit web app permission from original app # Inherit web app permission from original app
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
@@ -712,32 +703,6 @@ class AppExportApi(Resource):
return payload.model_dump(mode="json") return payload.model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
class AppPublishToCreatorsPlatformApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
"""Publish app to Creators Platform"""
from configs import dify_config
from core.helper.creators import get_redirect_url, upload_dsl
if not dify_config.CREATORS_PLATFORM_FEATURES_ENABLED:
return {"error": "Creators Platform features are not enabled"}, 403
current_user, _ = current_account_with_tenant()
dsl_content = AppDslService.export_dsl(app_model=app_model, include_secret=False)
dsl_bytes = dsl_content.encode("utf-8")
claim_code = upload_dsl(dsl_bytes)
redirect_url = get_redirect_url(str(current_user.id), claim_code)
return {"redirect_url": redirect_url}
@console_ns.route("/apps/<uuid:app_id>/name") @console_ns.route("/apps/<uuid:app_id>/name")
class AppNameApi(Resource): class AppNameApi(Resource):
@console_ns.doc("check_app_name") @console_ns.doc("check_app_name")
@@ -776,12 +741,7 @@ class AppIconApi(Resource):
args = AppIconPayload.model_validate(console_ns.payload or {}) args = AppIconPayload.model_validate(console_ns.payload or {})
app_service = AppService() app_service = AppService()
app_model = app_service.update_app_icon( app_model = app_service.update_app_icon(app_model, args.icon or "", args.icon_background or "")
app_model,
args.icon or "",
args.icon_background or "",
args.icon_type,
)
response_model = AppDetail.model_validate(app_model, from_attributes=True) response_model = AppDetail.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json") return response_model.model_dump(mode="json")
+39 -32
View File
@@ -1,8 +1,7 @@
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import Session from sqlalchemy.orm import sessionmaker
from controllers.common.schema import register_schema_models
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import ( from controllers.console.wraps import (
account_initialization_required, account_initialization_required,
@@ -11,15 +10,34 @@ from controllers.console.wraps import (
setup_required, setup_required,
) )
from extensions.ext_database import db from extensions.ext_database import db
from fields.app_fields import (
app_import_check_dependencies_fields,
app_import_fields,
leaked_dependency_fields,
)
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.model import App from models.model import App
from services.app_dsl_service import AppDslService, Import from services.app_dsl_service import AppDslService, ImportStatus
from services.enterprise.enterprise_service import EnterpriseService from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.feature_service import FeatureService from services.feature_service import FeatureService
from .. import console_ns from .. import console_ns
# Register models for flask_restx to avoid dict type issues in Swagger
# Register base model first
leaked_dependency_model = console_ns.model("LeakedDependency", leaked_dependency_fields)
app_import_model = console_ns.model("AppImport", app_import_fields)
# For nested models, need to replace nested dict with registered model
app_import_check_dependencies_fields_copy = app_import_check_dependencies_fields.copy()
app_import_check_dependencies_fields_copy["leaked_dependencies"] = fields.List(fields.Nested(leaked_dependency_model))
app_import_check_dependencies_model = console_ns.model(
"AppImportCheckDependencies", app_import_check_dependencies_fields_copy
)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class AppImportPayload(BaseModel): class AppImportPayload(BaseModel):
mode: str = Field(..., description="Import mode") mode: str = Field(..., description="Import mode")
@@ -33,18 +51,18 @@ class AppImportPayload(BaseModel):
app_id: str | None = Field(None) app_id: str | None = Field(None)
register_schema_models(console_ns, AppImportPayload, Import, CheckDependenciesResult) console_ns.schema_model(
AppImportPayload.__name__, AppImportPayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
)
@console_ns.route("/apps/imports") @console_ns.route("/apps/imports")
class AppImportApi(Resource): class AppImportApi(Resource):
@console_ns.expect(console_ns.models[AppImportPayload.__name__]) @console_ns.expect(console_ns.models[AppImportPayload.__name__])
@console_ns.response(200, "Import completed", console_ns.models[Import.__name__])
@console_ns.response(202, "Import pending confirmation", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@marshal_with(app_import_model)
@cloud_edition_billing_resource_check("apps") @cloud_edition_billing_resource_check("apps")
@edit_permission_required @edit_permission_required
def post(self): def post(self):
@@ -52,9 +70,8 @@ class AppImportApi(Resource):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
args = AppImportPayload.model_validate(console_ns.payload) args = AppImportPayload.model_validate(console_ns.payload)
# AppDslService performs internal commits for some creation paths, so use a plain # Create service with session
# Session here instead of nesting it inside sessionmaker(...).begin(). with sessionmaker(db.engine).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
import_service = AppDslService(session) import_service = AppDslService(session)
# Import app # Import app
account = current_user account = current_user
@@ -70,45 +87,35 @@ class AppImportApi(Resource):
icon_background=args.icon_background, icon_background=args.icon_background,
app_id=args.app_id, app_id=args.app_id,
) )
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
if result.app_id and FeatureService.get_system_features().webapp_auth.enabled: if result.app_id and FeatureService.get_system_features().webapp_auth.enabled:
# update web app setting as private # update web app setting as private
EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private") EnterpriseService.WebAppAuth.update_app_access_mode(result.app_id, "private")
# Return appropriate status code based on result # Return appropriate status code based on result
status = result.status status = result.status
match status: if status == ImportStatus.FAILED:
case ImportStatus.FAILED: return result.model_dump(mode="json"), 400
return result.model_dump(mode="json"), 400 elif status == ImportStatus.PENDING:
case ImportStatus.PENDING: return result.model_dump(mode="json"), 202
return result.model_dump(mode="json"), 202 return result.model_dump(mode="json"), 200
case ImportStatus.COMPLETED | ImportStatus.COMPLETED_WITH_WARNINGS:
return result.model_dump(mode="json"), 200
@console_ns.route("/apps/imports/<string:import_id>/confirm") @console_ns.route("/apps/imports/<string:import_id>/confirm")
class AppImportConfirmApi(Resource): class AppImportConfirmApi(Resource):
@console_ns.response(200, "Import confirmed", console_ns.models[Import.__name__])
@console_ns.response(400, "Import failed", console_ns.models[Import.__name__])
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@marshal_with(app_import_model)
@edit_permission_required @edit_permission_required
def post(self, import_id): def post(self, import_id):
# Check user role first # Check user role first
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
with Session(db.engine, expire_on_commit=False) as session: # Create service with session
with sessionmaker(db.engine).begin() as session:
import_service = AppDslService(session) import_service = AppDslService(session)
# Confirm import # Confirm import
account = current_user account = current_user
result = import_service.confirm_import(import_id=import_id, account=account) result = import_service.confirm_import(import_id=import_id, account=account)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
# Return appropriate status code based on result # Return appropriate status code based on result
if result.status == ImportStatus.FAILED: if result.status == ImportStatus.FAILED:
@@ -118,14 +125,14 @@ class AppImportConfirmApi(Resource):
@console_ns.route("/apps/imports/<string:app_id>/check-dependencies") @console_ns.route("/apps/imports/<string:app_id>/check-dependencies")
class AppImportCheckDependenciesApi(Resource): class AppImportCheckDependenciesApi(Resource):
@console_ns.response(200, "Dependencies checked", console_ns.models[CheckDependenciesResult.__name__])
@setup_required @setup_required
@login_required @login_required
@get_app_model @get_app_model
@account_initialization_required @account_initialization_required
@marshal_with(app_import_check_dependencies_model)
@edit_permission_required @edit_permission_required
def get(self, app_model: App): def get(self, app_model: App):
with Session(db.engine, expire_on_commit=False) as session: with sessionmaker(db.engine).begin() as session:
import_service = AppDslService(session) import_service = AppDslService(session)
result = import_service.check_dependencies(app_model=app_model) result = import_service.check_dependencies(app_model=app_model)
+1 -1
View File
@@ -2,6 +2,7 @@ import logging
from flask import request from flask import request
from flask_restx import Resource, fields from flask_restx import Resource, fields
from graphon.model_runtime.errors.invoke import InvokeError
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from werkzeug.exceptions import InternalServerError from werkzeug.exceptions import InternalServerError
@@ -22,7 +23,6 @@ from controllers.console.app.error import (
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required from libs.login import login_required
from models import App, AppMode from models import App, AppMode
from services.audio_service import AudioService from services.audio_service import AudioService
+1 -1
View File
@@ -3,6 +3,7 @@ from typing import Any, Literal
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource
from graphon.model_runtime.errors.invoke import InvokeError
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from werkzeug.exceptions import InternalServerError, NotFound from werkzeug.exceptions import InternalServerError, NotFound
@@ -26,7 +27,6 @@ from core.errors.error import (
QuotaExceededError, QuotaExceededError,
) )
from core.helper.trace_id_helper import get_external_trace_id from core.helper.trace_id_helper import get_external_trace_id
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper from libs import helper
from libs.helper import uuid_value from libs.helper import uuid_value
from libs.login import current_user, login_required from libs.login import current_user, login_required
+280 -48
View File
@@ -2,37 +2,20 @@ from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
from flask import abort, request from flask import abort, request
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import func, or_ from sqlalchemy import func, or_
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db from extensions.ext_database import db
from fields.conversation_fields import ( from fields.raws import FilesContainedField
Conversation as ConversationResponse,
)
from fields.conversation_fields import (
ConversationDetail as ConversationDetailResponse,
)
from fields.conversation_fields import (
ConversationMessageDetail as ConversationMessageDetailResponse,
)
from fields.conversation_fields import (
ConversationPagination as ConversationPaginationResponse,
)
from fields.conversation_fields import (
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
)
from fields.conversation_fields import (
ResultResponse,
)
from libs.datetime_utils import naive_utc_now, parse_time_range from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.helper import TimestampField
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation from models import Conversation, EndUser, Message, MessageAnnotation
from models.model import AppMode from models.model import AppMode
@@ -79,16 +62,267 @@ console_ns.schema_model(
ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), ChatConversationQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
) )
register_schema_models( # Register models for flask_restx to avoid dict type issues in Swagger
console_ns, # Register in dependency order: base models first, then dependent models
CompletionConversationQuery,
ChatConversationQuery, # Base models
ConversationResponse, simple_account_model = console_ns.model(
ConversationPaginationResponse, "SimpleAccount",
ConversationMessageDetailResponse, {
ConversationWithSummaryPaginationResponse, "id": fields.String,
ConversationDetailResponse, "name": fields.String,
ResultResponse, "email": fields.String,
},
)
feedback_stat_model = console_ns.model(
"FeedbackStat",
{
"like": fields.Integer,
"dislike": fields.Integer,
},
)
status_count_model = console_ns.model(
"StatusCount",
{
"success": fields.Integer,
"failed": fields.Integer,
"partial_success": fields.Integer,
"paused": fields.Integer,
},
)
message_file_model = console_ns.model(
"MessageFile",
{
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
},
)
agent_thought_model = console_ns.model(
"AgentThought",
{
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
},
)
simple_model_config_model = console_ns.model(
"SimpleModelConfig",
{
"model": fields.Raw(attribute="model_dict"),
"pre_prompt": fields.String,
},
)
model_config_model = console_ns.model(
"ModelConfig",
{
"opening_statement": fields.String,
"suggested_questions": fields.Raw,
"model": fields.Raw,
"user_input_form": fields.Raw,
"pre_prompt": fields.String,
"agent_mode": fields.Raw,
},
)
# Models that depend on simple_account_model
feedback_model = console_ns.model(
"Feedback",
{
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_model, allow_null=True),
},
)
annotation_model = console_ns.model(
"Annotation",
{
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
annotation_hit_history_model = console_ns.model(
"AnnotationHitHistory",
{
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
class MessageTextField(fields.Raw):
def format(self, value):
return value[0]["text"] if value else ""
# Simple message detail model
simple_message_detail_model = console_ns.model(
"SimpleMessageDetail",
{
"inputs": FilesContainedField,
"query": fields.String,
"message": MessageTextField,
"answer": fields.String,
},
)
# Message detail model that depends on multiple models
message_detail_model = console_ns.model(
"MessageDetail",
{
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_model)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_model, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
},
)
# Conversation models
conversation_fields_model = console_ns.model(
"Conversation",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String(),
"from_account_id": fields.String,
"from_account_name": fields.String,
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotation": fields.Nested(annotation_model, allow_null=True),
"model_config": fields.Nested(simple_model_config_model),
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"message": fields.Nested(simple_message_detail_model, attribute="first_message"),
},
)
conversation_pagination_model = console_ns.model(
"ConversationPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_fields_model), attribute="items"),
},
)
conversation_message_detail_model = console_ns.model(
"ConversationMessageDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"model_config": fields.Nested(model_config_model),
"message": fields.Nested(message_detail_model, attribute="first_message"),
},
)
conversation_with_summary_model = console_ns.model(
"ConversationWithSummary",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_end_user_session_id": fields.String,
"from_account_id": fields.String,
"from_account_name": fields.String,
"name": fields.String,
"summary": fields.String(attribute="summary_or_query"),
"read_at": TimestampField,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"model_config": fields.Nested(simple_model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
"status_count": fields.Nested(status_count_model),
},
)
conversation_with_summary_pagination_model = console_ns.model(
"ConversationWithSummaryPagination",
{
"page": fields.Integer,
"limit": fields.Integer(attribute="per_page"),
"total": fields.Integer,
"has_more": fields.Boolean(attribute="has_next"),
"data": fields.List(fields.Nested(conversation_with_summary_model), attribute="items"),
},
)
conversation_detail_model = console_ns.model(
"ConversationDetail",
{
"id": fields.String,
"status": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"created_at": TimestampField,
"updated_at": TimestampField,
"annotated": fields.Boolean,
"introduction": fields.String,
"model_config": fields.Nested(model_config_model),
"message_count": fields.Integer,
"user_feedback_stats": fields.Nested(feedback_stat_model),
"admin_feedback_stats": fields.Nested(feedback_stat_model),
},
) )
@@ -98,12 +332,13 @@ class CompletionConversationApi(Resource):
@console_ns.doc(description="Get completion conversations with pagination and filtering") @console_ns.doc(description="Get completion conversations with pagination and filtering")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__]) @console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__]) @console_ns.response(200, "Success", conversation_pagination_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.COMPLETION) @get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_pagination_model)
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
@@ -159,9 +394,7 @@ class CompletionConversationApi(Resource):
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False) conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return ConversationPaginationResponse.model_validate(conversations, from_attributes=True).model_dump( return conversations
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>") @console_ns.route("/apps/<uuid:app_id>/completion-conversations/<uuid:conversation_id>")
@@ -169,19 +402,19 @@ class CompletionConversationDetailApi(Resource):
@console_ns.doc("get_completion_conversation") @console_ns.doc("get_completion_conversation")
@console_ns.doc(description="Get completion conversation details with messages") @console_ns.doc(description="Get completion conversation details with messages")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"}) @console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", console_ns.models[ConversationMessageDetailResponse.__name__]) @console_ns.response(200, "Success", conversation_message_detail_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found") @console_ns.response(404, "Conversation not found")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.COMPLETION) @get_app_model(mode=AppMode.COMPLETION)
@marshal_with(conversation_message_detail_model)
@edit_permission_required @edit_permission_required
def get(self, app_model, conversation_id): def get(self, app_model, conversation_id):
conversation_id = str(conversation_id) conversation_id = str(conversation_id)
return ConversationMessageDetailResponse.model_validate(
_get_conversation(app_model, conversation_id), from_attributes=True return _get_conversation(app_model, conversation_id)
).model_dump(mode="json")
@console_ns.doc("delete_completion_conversation") @console_ns.doc("delete_completion_conversation")
@console_ns.doc(description="Delete a completion conversation") @console_ns.doc(description="Delete a completion conversation")
@@ -203,7 +436,7 @@ class CompletionConversationDetailApi(Resource):
except ConversationNotExistsError: except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.") raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json"), 204 return {"result": "success"}, 204
@console_ns.route("/apps/<uuid:app_id>/chat-conversations") @console_ns.route("/apps/<uuid:app_id>/chat-conversations")
@@ -212,12 +445,13 @@ class ChatConversationApi(Resource):
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary") @console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__]) @console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__]) @console_ns.response(200, "Success", conversation_with_summary_pagination_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_with_summary_pagination_model)
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
@@ -312,9 +546,7 @@ class ChatConversationApi(Resource):
conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False) conversations = db.paginate(query, page=args.page, per_page=args.limit, error_out=False)
return ConversationWithSummaryPaginationResponse.model_validate(conversations, from_attributes=True).model_dump( return conversations
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>") @console_ns.route("/apps/<uuid:app_id>/chat-conversations/<uuid:conversation_id>")
@@ -322,19 +554,19 @@ class ChatConversationDetailApi(Resource):
@console_ns.doc("get_chat_conversation") @console_ns.doc("get_chat_conversation")
@console_ns.doc(description="Get chat conversation details") @console_ns.doc(description="Get chat conversation details")
@console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"}) @console_ns.doc(params={"app_id": "Application ID", "conversation_id": "Conversation ID"})
@console_ns.response(200, "Success", console_ns.models[ConversationDetailResponse.__name__]) @console_ns.response(200, "Success", conversation_detail_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Conversation not found") @console_ns.response(404, "Conversation not found")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(conversation_detail_model)
@edit_permission_required @edit_permission_required
def get(self, app_model, conversation_id): def get(self, app_model, conversation_id):
conversation_id = str(conversation_id) conversation_id = str(conversation_id)
return ConversationDetailResponse.model_validate(
_get_conversation(app_model, conversation_id), from_attributes=True return _get_conversation(app_model, conversation_id)
).model_dump(mode="json")
@console_ns.doc("delete_chat_conversation") @console_ns.doc("delete_chat_conversation")
@console_ns.doc(description="Delete a chat conversation") @console_ns.doc(description="Delete a chat conversation")
@@ -356,7 +588,7 @@ class ChatConversationDetailApi(Resource):
except ConversationNotExistsError: except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.") raise NotFound("Conversation Not Exists.")
return ResultResponse(result="success").model_dump(mode="json"), 204 return {"result": "success"}, 204
def _get_conversation(app_model, conversation_id): def _get_conversation(app_model, conversation_id):
@@ -1,86 +1,44 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db from extensions.ext_database import db
from fields._value_type_serializer import serialize_value_type from fields.conversation_variable_fields import (
from fields.base import ResponseModel conversation_variable_fields,
paginated_conversation_variable_fields,
)
from libs.login import login_required from libs.login import login_required
from models import ConversationVariable from models import ConversationVariable
from models.model import AppMode from models.model import AppMode
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class ConversationVariablesQuery(BaseModel): class ConversationVariablesQuery(BaseModel):
conversation_id: str = Field(..., description="Conversation ID to filter variables") conversation_id: str = Field(..., description="Conversation ID to filter variables")
def _to_timestamp(value: datetime | int | None) -> int | None: console_ns.schema_model(
if isinstance(value, datetime): ConversationVariablesQuery.__name__,
return int(value.timestamp()) ConversationVariablesQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
return value )
# Register models for flask_restx to avoid dict type issues in Swagger
# Register base model first
conversation_variable_model = console_ns.model("ConversationVariable", conversation_variable_fields)
class ConversationVariableResponse(ResponseModel): # For nested models, need to replace nested dict with registered model
id: str paginated_conversation_variable_fields_copy = paginated_conversation_variable_fields.copy()
name: str paginated_conversation_variable_fields_copy["data"] = fields.List(
value_type: str fields.Nested(conversation_variable_model), attribute="data"
value: str | None = None )
description: str | None = None paginated_conversation_variable_model = console_ns.model(
created_at: int | None = None "PaginatedConversationVariable", paginated_conversation_variable_fields_copy
updated_at: int | None = None
@field_validator("value_type", mode="before")
@classmethod
def _normalize_value_type(cls, value: Any) -> str:
exposed_type = getattr(value, "exposed_type", None)
if callable(exposed_type):
return str(exposed_type())
if isinstance(value, str):
return value
try:
return serialize_value_type(value)
except Exception:
return serialize_value_type({"value_type": value})
@field_validator("value", mode="before")
@classmethod
def _normalize_value(cls, value: Any | None) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return str(value)
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
class PaginatedConversationVariableResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[ConversationVariableResponse]
register_schema_models(
console_ns,
ConversationVariablesQuery,
ConversationVariableResponse,
PaginatedConversationVariableResponse,
) )
@@ -90,15 +48,12 @@ class ConversationVariablesApi(Resource):
@console_ns.doc(description="Get conversation variables for an application") @console_ns.doc(description="Get conversation variables for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__]) @console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__])
@console_ns.response( @console_ns.response(200, "Conversation variables retrieved successfully", paginated_conversation_variable_model)
200,
"Conversation variables retrieved successfully",
console_ns.models[PaginatedConversationVariableResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT) @get_app_model(mode=AppMode.ADVANCED_CHAT)
@marshal_with(paginated_conversation_variable_model)
def get(self, app_model): def get(self, app_model):
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@@ -117,22 +72,17 @@ class ConversationVariablesApi(Resource):
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
rows = session.scalars(stmt).all() rows = session.scalars(stmt).all()
response = PaginatedConversationVariableResponse.model_validate( return {
{ "page": page,
"page": page, "limit": page_size,
"limit": page_size, "total": len(rows),
"total": len(rows), "has_more": False,
"has_more": False, "data": [
"data": [ {
ConversationVariableResponse.model_validate( "created_at": row.created_at,
{ "updated_at": row.updated_at,
"created_at": row.created_at, **row.to_variable().model_dump(),
"updated_at": row.updated_at, }
**row.to_variable().model_dump(), for row in rows
} ],
) }
for row in rows
],
}
)
return response.model_dump(mode="json")
+1 -1
View File
@@ -1,6 +1,7 @@
from collections.abc import Sequence from collections.abc import Sequence
from flask_restx import Resource from flask_restx import Resource
from graphon.model_runtime.errors.invoke import InvokeError
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from controllers.console import console_ns from controllers.console import console_ns
@@ -19,7 +20,6 @@ from core.helper.code_executor.python3.python3_code_provider import Python3CodeP
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator from core.llm_generator.llm_generator import LLMGenerator
from extensions.ext_database import db from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import App from models import App
from services.workflow_service import WorkflowService from services.workflow_service import WorkflowService
+24 -57
View File
@@ -1,68 +1,39 @@
import json import json
from datetime import datetime
from typing import Any
from flask_restx import Resource from flask_restx import Resource, marshal_with
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field
from sqlalchemy import select from sqlalchemy import select
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.app_fields import app_server_fields
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.enums import AppMCPServerStatus from models.enums import AppMCPServerStatus
from models.model import AppMCPServer from models.model import AppMCPServer
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
# Register model for flask_restx to avoid dict type issues in Swagger
app_server_model = console_ns.model("AppServer", app_server_fields)
class MCPServerCreatePayload(BaseModel): class MCPServerCreatePayload(BaseModel):
description: str | None = Field(default=None, description="Server description") description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(..., description="Server parameters configuration") parameters: dict = Field(..., description="Server parameters configuration")
class MCPServerUpdatePayload(BaseModel): class MCPServerUpdatePayload(BaseModel):
id: str = Field(..., description="Server ID") id: str = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description") description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(..., description="Server parameters configuration") parameters: dict = Field(..., description="Server parameters configuration")
status: str | None = Field(default=None, description="Server status") status: str | None = Field(default=None, description="Server status")
def _to_timestamp(value: datetime | int | None) -> int | None: for model in (MCPServerCreatePayload, MCPServerUpdatePayload):
if isinstance(value, datetime): console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
return int(value.timestamp())
return value
class AppMCPServerResponse(ResponseModel):
id: str
name: str
server_code: str
description: str
status: AppMCPServerStatus
parameters: dict[str, Any] | list[Any] | str
created_at: int | None = None
updated_at: int | None = None
@field_validator("parameters", mode="before")
@classmethod
def _normalize_parameters(cls, value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except (json.JSONDecodeError, TypeError):
return value
return value
@field_validator("created_at", "updated_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return _to_timestamp(value)
register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse)
@console_ns.route("/apps/<uuid:app_id>/server") @console_ns.route("/apps/<uuid:app_id>/server")
@@ -70,31 +41,27 @@ class AppMCPServerController(Resource):
@console_ns.doc("get_app_mcp_server") @console_ns.doc("get_app_mcp_server")
@console_ns.doc(description="Get MCP server configuration for an application") @console_ns.doc(description="Get MCP server configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response( @console_ns.response(200, "MCP server configuration retrieved successfully", app_server_model)
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@login_required @login_required
@account_initialization_required @account_initialization_required
@setup_required @setup_required
@get_app_model @get_app_model
@marshal_with(app_server_model)
def get(self, app_model): def get(self, app_model):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1)) server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
if server is None: return server
return {}
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json")
@console_ns.doc("create_app_mcp_server") @console_ns.doc("create_app_mcp_server")
@console_ns.doc(description="Create MCP server configuration for an application") @console_ns.doc(description="Create MCP server configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__]) @console_ns.expect(console_ns.models[MCPServerCreatePayload.__name__])
@console_ns.response( @console_ns.response(201, "MCP server configuration created successfully", app_server_model)
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@account_initialization_required @account_initialization_required
@get_app_model @get_app_model
@login_required @login_required
@setup_required @setup_required
@marshal_with(app_server_model)
@edit_permission_required @edit_permission_required
def post(self, app_model): def post(self, app_model):
_, current_tenant_id = current_account_with_tenant() _, current_tenant_id = current_account_with_tenant()
@@ -115,21 +82,20 @@ class AppMCPServerController(Resource):
) )
db.session.add(server) db.session.add(server)
db.session.commit() db.session.commit()
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json"), 201 return server
@console_ns.doc("update_app_mcp_server") @console_ns.doc("update_app_mcp_server")
@console_ns.doc(description="Update MCP server configuration for an application") @console_ns.doc(description="Update MCP server configuration for an application")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__]) @console_ns.expect(console_ns.models[MCPServerUpdatePayload.__name__])
@console_ns.response( @console_ns.response(200, "MCP server configuration updated successfully", app_server_model)
200, "MCP server configuration updated successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found") @console_ns.response(404, "Server not found")
@get_app_model @get_app_model
@login_required @login_required
@setup_required @setup_required
@account_initialization_required @account_initialization_required
@marshal_with(app_server_model)
@edit_permission_required @edit_permission_required
def put(self, app_model): def put(self, app_model):
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {}) payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
@@ -152,7 +118,7 @@ class AppMCPServerController(Resource):
except ValueError: except ValueError:
raise ValueError("Invalid status") raise ValueError("Invalid status")
db.session.commit() db.session.commit()
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") return server
@console_ns.route("/apps/<uuid:server_id>/server/refresh") @console_ns.route("/apps/<uuid:server_id>/server/refresh")
@@ -160,12 +126,13 @@ class AppMCPServerRefreshController(Resource):
@console_ns.doc("refresh_app_mcp_server") @console_ns.doc("refresh_app_mcp_server")
@console_ns.doc(description="Refresh MCP server configuration and regenerate server code") @console_ns.doc(description="Refresh MCP server configuration and regenerate server code")
@console_ns.doc(params={"server_id": "Server ID"}) @console_ns.doc(params={"server_id": "Server ID"})
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__]) @console_ns.response(200, "MCP server refreshed successfully", app_server_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found") @console_ns.response(404, "Server not found")
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@marshal_with(app_server_model)
@edit_permission_required @edit_permission_required
def get(self, server_id): def get(self, server_id):
_, current_tenant_id = current_account_with_tenant() _, current_tenant_id = current_account_with_tenant()
@@ -178,4 +145,4 @@ class AppMCPServerRefreshController(Resource):
raise NotFound() raise NotFound()
server.server_code = AppMCPServer.generate_server_code(16) server.server_code = AppMCPServer.generate_server_code(16)
db.session.commit() db.session.commit()
return AppMCPServerResponse.model_validate(server, from_attributes=True).model_dump(mode="json") return server
+131 -72
View File
@@ -1,14 +1,13 @@
import logging import logging
from datetime import datetime
from typing import Literal from typing import Literal
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from graphon.model_runtime.errors.invoke import InvokeError
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import exists, func, select from sqlalchemy import exists, func, select
from werkzeug.exceptions import InternalServerError, NotFound from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.schema import register_schema_models from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ( from controllers.console.app.error import (
@@ -25,22 +24,10 @@ from controllers.console.wraps import (
setup_required, setup_required,
) )
from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.app_invoke_entities import InvokeFrom
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.raws import FilesContainedField
from fields.conversation_fields import ( from libs.helper import TimestampField, uuid_value
AgentThought,
ConversationAnnotation,
ConversationAnnotationHitHistory,
Feedback,
JSONValue,
MessageFile,
format_files_contained,
to_timestamp,
)
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models.enums import FeedbackFromSource, FeedbackRating from models.enums import FeedbackFromSource, FeedbackRating
@@ -72,8 +59,10 @@ class ChatMessagesQuery(BaseModel):
return uuid_value(value) return uuid_value(value)
class MessageFeedbackPayload(_MessageFeedbackPayloadBase): class MessageFeedbackPayload(BaseModel):
message_id: str = Field(..., description="Message ID") message_id: str = Field(..., description="Message ID")
rating: Literal["like", "dislike"] | None = Field(default=None, description="Feedback rating")
content: str | None = Field(default=None, description="Feedback content")
@field_validator("message_id") @field_validator("message_id")
@classmethod @classmethod
@@ -110,51 +99,6 @@ class SuggestedQuestionsResponse(BaseModel):
data: list[str] = Field(description="Suggested question") data: list[str] = Field(description="Suggested question")
class MessageDetailResponse(ResponseModel):
id: str
conversation_id: str
inputs: dict[str, JSONValue]
query: str
message: JSONValue | None = None
message_tokens: int | None = None
answer: str = Field(validation_alias="re_sign_file_url_answer")
answer_tokens: int | None = None
provider_response_latency: float | None = None
from_source: str
from_end_user_id: str | None = None
from_account_id: str | None = None
feedbacks: list[Feedback] = Field(default_factory=list)
workflow_run_id: str | None = None
annotation: ConversationAnnotation | None = None
annotation_hit_history: ConversationAnnotationHitHistory | None = None
created_at: int | None = None
agent_thoughts: list[AgentThought] = Field(default_factory=list)
message_files: list[MessageFile] = Field(default_factory=list)
extra_contents: list[ExecutionExtraContentDomainModel] = Field(default_factory=list)
metadata: JSONValue | None = Field(default=None, validation_alias="message_metadata_dict")
status: str
error: str | None = None
parent_message_id: str | None = None
@field_validator("inputs", mode="before")
@classmethod
def _normalize_inputs(cls, value: JSONValue) -> JSONValue:
return format_files_contained(value)
@field_validator("created_at", mode="before")
@classmethod
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return to_timestamp(value)
return value
class MessageInfiniteScrollPaginationResponse(ResponseModel):
limit: int
has_more: bool
data: list[MessageDetailResponse]
register_schema_models( register_schema_models(
console_ns, console_ns,
ChatMessagesQuery, ChatMessagesQuery,
@@ -162,8 +106,124 @@ register_schema_models(
FeedbackExportQuery, FeedbackExportQuery,
AnnotationCountResponse, AnnotationCountResponse,
SuggestedQuestionsResponse, SuggestedQuestionsResponse,
MessageDetailResponse, )
MessageInfiniteScrollPaginationResponse,
# Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models
# Base models
simple_account_model = console_ns.model(
"SimpleAccount",
{
"id": fields.String,
"name": fields.String,
"email": fields.String,
},
)
message_file_model = console_ns.model(
"MessageFile",
{
"id": fields.String,
"filename": fields.String,
"type": fields.String,
"url": fields.String,
"mime_type": fields.String,
"size": fields.Integer,
"transfer_method": fields.String,
"belongs_to": fields.String(default="user"),
"upload_file_id": fields.String(default=None),
},
)
agent_thought_model = console_ns.model(
"AgentThought",
{
"id": fields.String,
"chain_id": fields.String,
"message_id": fields.String,
"position": fields.Integer,
"thought": fields.String,
"tool": fields.String,
"tool_labels": fields.Raw,
"tool_input": fields.String,
"created_at": TimestampField,
"observation": fields.String,
"files": fields.List(fields.String),
},
)
# Models that depend on simple_account_model
feedback_model = console_ns.model(
"Feedback",
{
"rating": fields.String,
"content": fields.String,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account": fields.Nested(simple_account_model, allow_null=True),
},
)
annotation_model = console_ns.model(
"Annotation",
{
"id": fields.String,
"question": fields.String,
"content": fields.String,
"account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
annotation_hit_history_model = console_ns.model(
"AnnotationHitHistory",
{
"annotation_id": fields.String(attribute="id"),
"annotation_create_account": fields.Nested(simple_account_model, allow_null=True),
"created_at": TimestampField,
},
)
# Message detail model that depends on multiple models
message_detail_model = console_ns.model(
"MessageDetail",
{
"id": fields.String,
"conversation_id": fields.String,
"inputs": FilesContainedField,
"query": fields.String,
"message": fields.Raw,
"message_tokens": fields.Integer,
"answer": fields.String(attribute="re_sign_file_url_answer"),
"answer_tokens": fields.Integer,
"provider_response_latency": fields.Float,
"from_source": fields.String,
"from_end_user_id": fields.String,
"from_account_id": fields.String,
"feedbacks": fields.List(fields.Nested(feedback_model)),
"workflow_run_id": fields.String,
"annotation": fields.Nested(annotation_model, allow_null=True),
"annotation_hit_history": fields.Nested(annotation_hit_history_model, allow_null=True),
"created_at": TimestampField,
"agent_thoughts": fields.List(fields.Nested(agent_thought_model)),
"message_files": fields.List(fields.Nested(message_file_model)),
"extra_contents": fields.List(fields.Raw),
"metadata": fields.Raw(attribute="message_metadata_dict"),
"status": fields.String,
"error": fields.String,
"parent_message_id": fields.String,
},
)
# Message infinite scroll pagination model
message_infinite_scroll_pagination_model = console_ns.model(
"MessageInfiniteScrollPagination",
{
"limit": fields.Integer,
"has_more": fields.Boolean,
"data": fields.List(fields.Nested(message_detail_model)),
},
) )
@@ -173,12 +233,13 @@ class ChatMessageListApi(Resource):
@console_ns.doc(description="Get chat messages for a conversation with pagination") @console_ns.doc(description="Get chat messages for a conversation with pagination")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ChatMessagesQuery.__name__]) @console_ns.expect(console_ns.models[ChatMessagesQuery.__name__])
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__]) @console_ns.response(200, "Success", message_infinite_scroll_pagination_model)
@console_ns.response(404, "Conversation not found") @console_ns.response(404, "Conversation not found")
@login_required @login_required
@account_initialization_required @account_initialization_required
@setup_required @setup_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT]) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@marshal_with(message_infinite_scroll_pagination_model)
@edit_permission_required @edit_permission_required
def get(self, app_model): def get(self, app_model):
args = ChatMessagesQuery.model_validate(request.args.to_dict()) args = ChatMessagesQuery.model_validate(request.args.to_dict())
@@ -238,10 +299,7 @@ class ChatMessageListApi(Resource):
history_messages = list(reversed(history_messages)) history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages) attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate( return InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more)
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/feedbacks") @console_ns.route("/apps/<uuid:app_id>/feedbacks")
@@ -411,12 +469,13 @@ class MessageApi(Resource):
@console_ns.doc("get_message") @console_ns.doc("get_message")
@console_ns.doc(description="Get message details by ID") @console_ns.doc(description="Get message details by ID")
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"}) @console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__]) @console_ns.response(200, "Message retrieved successfully", message_detail_model)
@console_ns.response(404, "Message not found") @console_ns.response(404, "Message not found")
@get_app_model @get_app_model
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@marshal_with(message_detail_model)
def get(self, app_model, message_id: str): def get(self, app_model, message_id: str):
message_id = str(message_id) message_id = str(message_id)
@@ -428,4 +487,4 @@ class MessageApi(Resource):
raise NotFound("Message Not Exists.") raise NotFound("Message Not Exists.")
attach_message_extra_contents([message]) attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json") return message
+21 -23
View File
@@ -1,11 +1,9 @@
import json import json
from typing import Any, cast from typing import cast
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource, fields
from pydantic import BaseModel, Field
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
@@ -20,30 +18,30 @@ from models.model import AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService from services.app_model_config_service import AppModelConfigService
class ModelConfigRequest(BaseModel):
provider: str | None = Field(default=None, description="Model provider")
model: str | None = Field(default=None, description="Model name")
configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters")
opening_statement: str | None = Field(default=None, description="Opening statement")
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration")
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration")
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration")
retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration")
tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools")
dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations")
agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration")
register_schema_models(console_ns, ModelConfigRequest)
@console_ns.route("/apps/<uuid:app_id>/model-config") @console_ns.route("/apps/<uuid:app_id>/model-config")
class ModelConfigResource(Resource): class ModelConfigResource(Resource):
@console_ns.doc("update_app_model_config") @console_ns.doc("update_app_model_config")
@console_ns.doc(description="Update application model configuration") @console_ns.doc(description="Update application model configuration")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__]) @console_ns.expect(
console_ns.model(
"ModelConfigRequest",
{
"provider": fields.String(description="Model provider"),
"model": fields.String(description="Model name"),
"configs": fields.Raw(description="Model configuration parameters"),
"opening_statement": fields.String(description="Opening statement"),
"suggested_questions": fields.List(fields.String(), description="Suggested questions"),
"more_like_this": fields.Raw(description="More like this configuration"),
"speech_to_text": fields.Raw(description="Speech to text configuration"),
"text_to_speech": fields.Raw(description="Text to speech configuration"),
"retrieval_model": fields.Raw(description="Retrieval model configuration"),
"tools": fields.List(fields.Raw(), description="Available tools"),
"dataset_configs": fields.Raw(description="Dataset configurations"),
"agent_mode": fields.Raw(description="Agent mode configuration"),
},
)
)
@console_ns.response(200, "Model configuration updated successfully") @console_ns.response(200, "Model configuration updated successfully")
@console_ns.response(400, "Invalid configuration") @console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "App not found") @console_ns.response(404, "App not found")
+16 -26
View File
@@ -1,12 +1,11 @@
from typing import Literal from typing import Literal
from flask_restx import Resource from flask_restx import Resource, marshal_with
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select from sqlalchemy import select
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from constants.languages import supported_language from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import ( from controllers.console.wraps import (
@@ -16,11 +15,13 @@ from controllers.console.wraps import (
setup_required, setup_required,
) )
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.app_fields import app_site_fields
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required from libs.login import current_account_with_tenant, login_required
from models import Site from models import Site
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class AppSiteUpdatePayload(BaseModel): class AppSiteUpdatePayload(BaseModel):
title: str | None = Field(default=None) title: str | None = Field(default=None)
@@ -48,26 +49,13 @@ class AppSiteUpdatePayload(BaseModel):
return supported_language(value) return supported_language(value)
class AppSiteResponse(ResponseModel): console_ns.schema_model(
app_id: str AppSiteUpdatePayload.__name__,
access_token: str | None = Field(default=None, validation_alias="code") AppSiteUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
code: str | None = None )
title: str
icon: str | None = None
icon_background: str | None = None
description: str | None = None
default_language: str
customize_domain: str | None = None
copyright: str | None = None
privacy_policy: str | None = None
custom_disclaimer: str | None = None
customize_token_strategy: str
prompt_public: bool
show_workflow_steps: bool
use_icon_as_answer_icon: bool
# Register model for flask_restx to avoid dict type issues in Swagger
register_schema_models(console_ns, AppSiteUpdatePayload, AppSiteResponse) app_site_model = console_ns.model("AppSite", app_site_fields)
@console_ns.route("/apps/<uuid:app_id>/site") @console_ns.route("/apps/<uuid:app_id>/site")
@@ -76,7 +64,7 @@ class AppSite(Resource):
@console_ns.doc(description="Update application site configuration") @console_ns.doc(description="Update application site configuration")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__]) @console_ns.expect(console_ns.models[AppSiteUpdatePayload.__name__])
@console_ns.response(200, "Site configuration updated successfully", console_ns.models[AppSiteResponse.__name__]) @console_ns.response(200, "Site configuration updated successfully", app_site_model)
@console_ns.response(403, "Insufficient permissions") @console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "App not found") @console_ns.response(404, "App not found")
@setup_required @setup_required
@@ -84,6 +72,7 @@ class AppSite(Resource):
@edit_permission_required @edit_permission_required
@account_initialization_required @account_initialization_required
@get_app_model @get_app_model
@marshal_with(app_site_model)
def post(self, app_model): def post(self, app_model):
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {}) args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
@@ -117,7 +106,7 @@ class AppSite(Resource):
site.updated_at = naive_utc_now() site.updated_at = naive_utc_now()
db.session.commit() db.session.commit()
return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") return site
@console_ns.route("/apps/<uuid:app_id>/site/access-token-reset") @console_ns.route("/apps/<uuid:app_id>/site/access-token-reset")
@@ -125,7 +114,7 @@ class AppSiteAccessTokenReset(Resource):
@console_ns.doc("reset_app_site_access_token") @console_ns.doc("reset_app_site_access_token")
@console_ns.doc(description="Reset access token for application site") @console_ns.doc(description="Reset access token for application site")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Access token reset successfully", console_ns.models[AppSiteResponse.__name__]) @console_ns.response(200, "Access token reset successfully", app_site_model)
@console_ns.response(403, "Insufficient permissions (admin/owner required)") @console_ns.response(403, "Insufficient permissions (admin/owner required)")
@console_ns.response(404, "App or site not found") @console_ns.response(404, "App or site not found")
@setup_required @setup_required
@@ -133,6 +122,7 @@ class AppSiteAccessTokenReset(Resource):
@is_admin_or_owner_required @is_admin_or_owner_required
@account_initialization_required @account_initialization_required
@get_app_model @get_app_model
@marshal_with(app_site_model)
def post(self, app_model): def post(self, app_model):
current_user, _ = current_account_with_tenant() current_user, _ = current_account_with_tenant()
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1)) site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
@@ -145,4 +135,4 @@ class AppSiteAccessTokenReset(Resource):
site.updated_at = naive_utc_now() site.updated_at = naive_utc_now()
db.session.commit() db.session.commit()
return AppSiteResponse.model_validate(site, from_attributes=True).model_dump(mode="json") return site
+19 -120
View File
@@ -4,13 +4,16 @@ from collections.abc import Sequence
from typing import Any from typing import Any
from flask import abort, request from flask import abort, request
from flask_restx import Resource, fields, marshal, marshal_with from flask_restx import Resource, fields, marshal_with
from graphon.enums import NodeType
from graphon.file import File
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder
from pydantic import BaseModel, Field, ValidationError, field_validator from pydantic import BaseModel, Field, ValidationError, field_validator
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services import services
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
from controllers.console.app.workflow_run import workflow_run_node_execution_model from controllers.console.app.workflow_run import workflow_run_node_execution_model
@@ -35,13 +38,7 @@ from extensions.ext_database import db
from extensions.ext_redis import redis_client from extensions.ext_redis import redis_client
from factories import file_factory, variable_factory from factories import file_factory, variable_factory
from fields.member_fields import simple_account_fields from fields.member_fields import simple_account_fields
from fields.online_user_fields import online_user_list_fields
from fields.workflow_fields import workflow_fields, workflow_pagination_fields from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from graphon.enums import NodeType
from graphon.file import File
from graphon.file import helpers as file_helpers
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs import helper from libs import helper
from libs.datetime_utils import naive_utc_now from libs.datetime_utils import naive_utc_now
from libs.helper import TimestampField, uuid_value from libs.helper import TimestampField, uuid_value
@@ -49,7 +46,6 @@ from libs.login import current_account_with_tenant, login_required
from models import App from models import App
from models.model import AppMode from models.model import AppMode
from models.workflow import Workflow from models.workflow import Workflow
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
from services.app_generate_service import AppGenerateService from services.app_generate_service import AppGenerateService
from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError from services.errors.llm import InvokeRateLimitError
@@ -60,8 +56,6 @@ _file_access_controller = DatabaseFileAccessController()
LISTENING_RETRY_IN = 2000 LISTENING_RETRY_IN = 2000
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published" RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE = "source workflow must be published"
MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS = 1000
WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE = 50
# Register models for flask_restx to avoid dict type issues in Swagger # Register models for flask_restx to avoid dict type issues in Swagger
# Register in dependency order: base models first, then dependent models # Register in dependency order: base models first, then dependent models
@@ -148,6 +142,10 @@ class PublishWorkflowPayload(BaseModel):
marked_comment: str | None = Field(default=None, max_length=100) marked_comment: str | None = Field(default=None, max_length=100)
class DefaultBlockConfigQuery(BaseModel):
q: str | None = None
class ConvertToWorkflowPayload(BaseModel): class ConvertToWorkflowPayload(BaseModel):
name: str | None = None name: str | None = None
icon_type: str | None = None icon_type: str | None = None
@@ -155,17 +153,16 @@ class ConvertToWorkflowPayload(BaseModel):
icon_background: str | None = None icon_background: str | None = None
class WorkflowFeaturesPayload(BaseModel): class WorkflowListQuery(BaseModel):
features: dict[str, Any] = Field(..., description="Workflow feature configuration") page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=10, ge=1, le=100)
user_id: str | None = None
named_only: bool = False
class WorkflowOnlineUsersPayload(BaseModel): class WorkflowUpdatePayload(BaseModel):
app_ids: list[str] = Field(default_factory=list, description="App IDs") marked_name: str | None = Field(default=None, max_length=20)
marked_comment: str | None = Field(default=None, max_length=100)
@field_validator("app_ids")
@classmethod
def normalize_app_ids(cls, app_ids: list[str]) -> list[str]:
return list(dict.fromkeys(app_id.strip() for app_id in app_ids if app_id.strip()))
class DraftWorkflowTriggerRunPayload(BaseModel): class DraftWorkflowTriggerRunPayload(BaseModel):
@@ -191,8 +188,6 @@ reg(DefaultBlockConfigQuery)
reg(ConvertToWorkflowPayload) reg(ConvertToWorkflowPayload)
reg(WorkflowListQuery) reg(WorkflowListQuery)
reg(WorkflowUpdatePayload) reg(WorkflowUpdatePayload)
reg(WorkflowFeaturesPayload)
reg(WorkflowOnlineUsersPayload)
reg(DraftWorkflowTriggerRunPayload) reg(DraftWorkflowTriggerRunPayload)
reg(DraftWorkflowTriggerRunAllPayload) reg(DraftWorkflowTriggerRunAllPayload)
@@ -951,32 +946,6 @@ class ConvertToWorkflowApi(Resource):
} }
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/features")
class WorkflowFeaturesApi(Resource):
"""Update draft workflow features."""
@console_ns.expect(console_ns.models[WorkflowFeaturesPayload.__name__])
@console_ns.doc("update_workflow_features")
@console_ns.doc(description="Update draft workflow features")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Workflow features updated successfully")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@edit_permission_required
def post(self, app_model: App):
current_user, _ = current_account_with_tenant()
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
features = args.features
workflow_service = WorkflowService()
workflow_service.update_draft_workflow_features(app_model=app_model, features=features, account=current_user)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/workflows") @console_ns.route("/apps/<uuid:app_id>/workflows")
class PublishedAllWorkflowApi(Resource): class PublishedAllWorkflowApi(Resource):
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__]) @console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
@@ -988,6 +957,7 @@ class PublishedAllWorkflowApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_pagination_model)
@edit_permission_required @edit_permission_required
def get(self, app_model: App): def get(self, app_model: App):
""" """
@@ -1015,10 +985,9 @@ class PublishedAllWorkflowApi(Resource):
user_id=user_id, user_id=user_id,
named_only=named_only, named_only=named_only,
) )
serialized_workflows = marshal(workflows, workflow_fields_copy)
return { return {
"items": serialized_workflows, "items": workflows,
"page": page, "page": page,
"limit": limit, "limit": limit,
"has_more": has_more, "has_more": has_more,
@@ -1386,73 +1355,3 @@ class DraftWorkflowTriggerRunAllApi(Resource):
"status": "error", "status": "error",
} }
), 400 ), 400
@console_ns.route("/apps/workflows/online-users")
class WorkflowOnlineUsersApi(Resource):
@console_ns.expect(console_ns.models[WorkflowOnlineUsersPayload.__name__])
@console_ns.doc("get_workflow_online_users")
@console_ns.doc(description="Get workflow online users")
@setup_required
@login_required
@account_initialization_required
@marshal_with(online_user_list_fields)
def post(self):
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
app_ids = args.app_ids
if len(app_ids) > MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS:
raise BadRequest(f"Maximum {MAX_WORKFLOW_ONLINE_USERS_REQUEST_IDS} app_ids are allowed per request.")
if not app_ids:
return {"data": []}
_, current_tenant_id = current_account_with_tenant()
workflow_service = WorkflowService()
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
users_json_by_app_id: dict[str, Any] = {}
for start_index in range(0, len(ordered_accessible_app_ids), WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE):
app_id_batch = ordered_accessible_app_ids[
start_index : start_index + WORKFLOW_ONLINE_USERS_REDIS_BATCH_SIZE
]
pipe = redis_client.pipeline(transaction=False)
for app_id in app_id_batch:
pipe.hgetall(f"{WORKFLOW_ONLINE_USERS_PREFIX}{app_id}")
users_json_batch = pipe.execute()
for app_id, users_json in zip(app_id_batch, users_json_batch):
users_json_by_app_id[app_id] = users_json
results = []
for app_id in ordered_accessible_app_ids:
users_json = users_json_by_app_id.get(app_id, {})
users = []
for _, user_info_json in users_json.items():
try:
user_info = json.loads(user_info_json)
except Exception:
continue
if not isinstance(user_info, dict):
continue
avatar = user_info.get("avatar")
if isinstance(avatar, str) and avatar and not avatar.startswith(("http://", "https://")):
try:
user_info["avatar"] = file_helpers.get_signed_file_url(avatar)
except Exception as exc:
logger.warning(
"Failed to sign workflow online user avatar; using original value. "
"app_id=%s avatar=%s error=%s",
app_id,
avatar,
exc,
)
users.append(user_info)
results.append({"app_id": app_id, "users": users})
return {"data": results}
+22 -131
View File
@@ -1,26 +1,27 @@
from datetime import datetime from datetime import datetime
from typing import Any
from dateutil.parser import isoparse from dateutil.parser import isoparse
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource, marshal_with
from graphon.enums import WorkflowExecutionStatus
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from controllers.common.schema import register_schema_models
from controllers.console import console_ns from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required from controllers.console.wraps import account_initialization_required, setup_required
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.workflow_app_log_fields import (
from fields.end_user_fields import SimpleEndUser build_workflow_app_log_pagination_model,
from fields.member_fields import SimpleAccount build_workflow_archived_log_pagination_model,
from graphon.enums import WorkflowExecutionStatus )
from libs.login import login_required from libs.login import login_required
from models import App from models import App
from models.model import AppMode from models.model import AppMode
from services.workflow_app_service import WorkflowAppService from services.workflow_app_service import WorkflowAppService
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class WorkflowAppLogQuery(BaseModel): class WorkflowAppLogQuery(BaseModel):
keyword: str | None = Field(default=None, description="Search keyword for filtering logs") keyword: str | None = Field(default=None, description="Search keyword for filtering logs")
@@ -57,114 +58,14 @@ class WorkflowAppLogQuery(BaseModel):
raise ValueError("Invalid boolean value for detail") raise ValueError("Invalid boolean value for detail")
class WorkflowRunForLogResponse(ResponseModel): console_ns.schema_model(
id: str WorkflowAppLogQuery.__name__, WorkflowAppLogQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
version: str | None = None
status: str | None = None
triggered_from: str | None = None
error: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
total_steps: int | None = None
created_at: int | None = None
finished_at: int | None = None
exceptions_count: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return str(getattr(value, "value", value))
@field_validator("created_at", "finished_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class WorkflowRunForArchivedLogResponse(ResponseModel):
id: str
status: str | None = None
triggered_from: str | None = None
elapsed_time: float | None = None
total_tokens: int | None = None
@field_validator("status", mode="before")
@classmethod
def _normalize_status(cls, value: Any) -> str | None:
if value is None:
return None
if isinstance(value, str):
return value
return str(getattr(value, "value", value))
class WorkflowAppLogPartialResponse(ResponseModel):
id: str
workflow_run: WorkflowRunForLogResponse | None = None
details: Any = None
created_from: str | None = None
created_by_role: str | None = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class WorkflowArchivedLogPartialResponse(ResponseModel):
id: str
workflow_run: WorkflowRunForArchivedLogResponse | None = None
trigger_metadata: Any = None
created_by_account: SimpleAccount | None = None
created_by_end_user: SimpleEndUser | None = None
created_at: int | None = None
@field_validator("created_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
if isinstance(value, datetime):
return int(value.timestamp())
return value
class WorkflowAppLogPaginationResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[WorkflowAppLogPartialResponse]
class WorkflowArchivedLogPaginationResponse(ResponseModel):
page: int
limit: int
total: int
has_more: bool
data: list[WorkflowArchivedLogPartialResponse]
register_schema_models(
console_ns,
WorkflowAppLogQuery,
WorkflowRunForLogResponse,
WorkflowRunForArchivedLogResponse,
WorkflowAppLogPartialResponse,
WorkflowArchivedLogPartialResponse,
WorkflowAppLogPaginationResponse,
WorkflowArchivedLogPaginationResponse,
) )
# Register model for flask_restx to avoid dict type issues in Swagger
workflow_app_log_pagination_model = build_workflow_app_log_pagination_model(console_ns)
workflow_archived_log_pagination_model = build_workflow_archived_log_pagination_model(console_ns)
@console_ns.route("/apps/<uuid:app_id>/workflow-app-logs") @console_ns.route("/apps/<uuid:app_id>/workflow-app-logs")
class WorkflowAppLogApi(Resource): class WorkflowAppLogApi(Resource):
@@ -172,15 +73,12 @@ class WorkflowAppLogApi(Resource):
@console_ns.doc(description="Get workflow application execution logs") @console_ns.doc(description="Get workflow application execution logs")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__]) @console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
@console_ns.response( @console_ns.response(200, "Workflow app logs retrieved successfully", workflow_app_log_pagination_model)
200,
"Workflow app logs retrieved successfully",
console_ns.models[WorkflowAppLogPaginationResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.WORKFLOW])
@marshal_with(workflow_app_log_pagination_model)
def get(self, app_model: App): def get(self, app_model: App):
""" """
Get workflow app logs Get workflow app logs
@@ -189,7 +87,7 @@ class WorkflowAppLogApi(Resource):
# get paginate workflow app logs # get paginate workflow app logs
workflow_app_service = WorkflowAppService() workflow_app_service = WorkflowAppService()
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine).begin() as session:
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs( workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_app_logs(
session=session, session=session,
app_model=app_model, app_model=app_model,
@@ -204,9 +102,7 @@ class WorkflowAppLogApi(Resource):
created_by_account=args.created_by_account, created_by_account=args.created_by_account,
) )
return WorkflowAppLogPaginationResponse.model_validate( return workflow_app_log_pagination
workflow_app_log_pagination, from_attributes=True
).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/workflow-archived-logs") @console_ns.route("/apps/<uuid:app_id>/workflow-archived-logs")
@@ -215,15 +111,12 @@ class WorkflowArchivedLogApi(Resource):
@console_ns.doc(description="Get workflow archived execution logs") @console_ns.doc(description="Get workflow archived execution logs")
@console_ns.doc(params={"app_id": "Application ID"}) @console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__]) @console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
@console_ns.response( @console_ns.response(200, "Workflow archived logs retrieved successfully", workflow_archived_log_pagination_model)
200,
"Workflow archived logs retrieved successfully",
console_ns.models[WorkflowArchivedLogPaginationResponse.__name__],
)
@setup_required @setup_required
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.WORKFLOW])
@marshal_with(workflow_archived_log_pagination_model)
def get(self, app_model: App): def get(self, app_model: App):
""" """
Get workflow archived logs Get workflow archived logs
@@ -231,7 +124,7 @@ class WorkflowArchivedLogApi(Resource):
args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args = WorkflowAppLogQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
workflow_app_service = WorkflowAppService() workflow_app_service = WorkflowAppService()
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine).begin() as session:
workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs( workflow_app_log_pagination = workflow_app_service.get_paginate_workflow_archive_logs(
session=session, session=session,
app_model=app_model, app_model=app_model,
@@ -239,6 +132,4 @@ class WorkflowArchivedLogApi(Resource):
limit=args.limit, limit=args.limit,
) )
return WorkflowArchivedLogPaginationResponse.model_validate( return workflow_app_log_pagination
workflow_app_log_pagination, from_attributes=True
).model_dump(mode="json")
@@ -1,335 +0,0 @@
import logging
from flask_restx import Resource, marshal_with
from pydantic import BaseModel, Field, TypeAdapter
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from fields.member_fields import AccountWithRole
from fields.workflow_comment_fields import (
workflow_comment_basic_fields,
workflow_comment_create_fields,
workflow_comment_detail_fields,
workflow_comment_reply_create_fields,
workflow_comment_reply_update_fields,
workflow_comment_resolve_fields,
workflow_comment_update_fields,
)
from libs.login import current_user, login_required
from models import App
from services.account_service import TenantService
from services.workflow_comment_service import WorkflowCommentService
logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
class WorkflowCommentCreatePayload(BaseModel):
content: str = Field(..., description="Comment content")
position_x: float = Field(..., description="Comment X position")
position_y: float = Field(..., description="Comment Y position")
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
class WorkflowCommentUpdatePayload(BaseModel):
content: str = Field(..., description="Comment content")
position_x: float | None = Field(default=None, description="Comment X position")
position_y: float | None = Field(default=None, description="Comment Y position")
mentioned_user_ids: list[str] | None = Field(
default=None,
description="Mentioned user IDs. Omit to keep existing mentions.",
)
class WorkflowCommentReplyPayload(BaseModel):
content: str = Field(..., description="Reply content")
mentioned_user_ids: list[str] = Field(default_factory=list, description="Mentioned user IDs")
class WorkflowCommentMentionUsersPayload(BaseModel):
users: list[AccountWithRole]
for model in (
WorkflowCommentCreatePayload,
WorkflowCommentUpdatePayload,
WorkflowCommentReplyPayload,
):
console_ns.schema_model(model.__name__, model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
register_schema_models(console_ns, AccountWithRole, WorkflowCommentMentionUsersPayload)
workflow_comment_basic_model = console_ns.model("WorkflowCommentBasic", workflow_comment_basic_fields)
workflow_comment_detail_model = console_ns.model("WorkflowCommentDetail", workflow_comment_detail_fields)
workflow_comment_create_model = console_ns.model("WorkflowCommentCreate", workflow_comment_create_fields)
workflow_comment_update_model = console_ns.model("WorkflowCommentUpdate", workflow_comment_update_fields)
workflow_comment_resolve_model = console_ns.model("WorkflowCommentResolve", workflow_comment_resolve_fields)
workflow_comment_reply_create_model = console_ns.model(
"WorkflowCommentReplyCreate", workflow_comment_reply_create_fields
)
workflow_comment_reply_update_model = console_ns.model(
"WorkflowCommentReplyUpdate", workflow_comment_reply_update_fields
)
@console_ns.route("/apps/<uuid:app_id>/workflow/comments")
class WorkflowCommentListApi(Resource):
"""API for listing and creating workflow comments."""
@console_ns.doc("list_workflow_comments")
@console_ns.doc(description="Get all comments for a workflow")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Comments retrieved successfully", workflow_comment_basic_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_basic_model, envelope="data")
def get(self, app_model: App):
"""Get all comments for a workflow."""
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
return comments
@console_ns.doc("create_workflow_comment")
@console_ns.doc(description="Create a new workflow comment")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[WorkflowCommentCreatePayload.__name__])
@console_ns.response(201, "Comment created successfully", workflow_comment_create_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_create_model)
@edit_permission_required
def post(self, app_model: App):
"""Create a new workflow comment."""
payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {})
result = WorkflowCommentService.create_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
created_by=current_user.id,
content=payload.content,
position_x=payload.position_x,
position_y=payload.position_y,
mentioned_user_ids=payload.mentioned_user_ids,
)
return result, 201
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>")
class WorkflowCommentDetailApi(Resource):
"""API for managing individual workflow comments."""
@console_ns.doc("get_workflow_comment")
@console_ns.doc(description="Get a specific workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.response(200, "Comment retrieved successfully", workflow_comment_detail_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_detail_model)
def get(self, app_model: App, comment_id: str):
"""Get a specific workflow comment."""
comment = WorkflowCommentService.get_comment(
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
)
return comment
@console_ns.doc("update_workflow_comment")
@console_ns.doc(description="Update a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.expect(console_ns.models[WorkflowCommentUpdatePayload.__name__])
@console_ns.response(200, "Comment updated successfully", workflow_comment_update_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_update_model)
@edit_permission_required
def put(self, app_model: App, comment_id: str):
"""Update a workflow comment."""
payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {})
result = WorkflowCommentService.update_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
content=payload.content,
position_x=payload.position_x,
position_y=payload.position_y,
mentioned_user_ids=payload.mentioned_user_ids,
)
return result
@console_ns.doc("delete_workflow_comment")
@console_ns.doc(description="Delete a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.response(204, "Comment deleted successfully")
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@edit_permission_required
def delete(self, app_model: App, comment_id: str):
"""Delete a workflow comment."""
WorkflowCommentService.delete_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
)
return {"result": "success"}, 204
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/resolve")
class WorkflowCommentResolveApi(Resource):
"""API for resolving and reopening workflow comments."""
@console_ns.doc("resolve_workflow_comment")
@console_ns.doc(description="Resolve a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.response(200, "Comment resolved successfully", workflow_comment_resolve_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_resolve_model)
@edit_permission_required
def post(self, app_model: App, comment_id: str):
"""Resolve a workflow comment."""
comment = WorkflowCommentService.resolve_comment(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
user_id=current_user.id,
)
return comment
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies")
class WorkflowCommentReplyApi(Resource):
"""API for managing comment replies."""
@console_ns.doc("create_workflow_comment_reply")
@console_ns.doc(description="Add a reply to a workflow comment")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID"})
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
@console_ns.response(201, "Reply created successfully", workflow_comment_reply_create_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_reply_create_model)
@edit_permission_required
def post(self, app_model: App, comment_id: str):
"""Add a reply to a workflow comment."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
result = WorkflowCommentService.create_reply(
comment_id=comment_id,
content=payload.content,
created_by=current_user.id,
mentioned_user_ids=payload.mentioned_user_ids,
)
return result, 201
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/<string:comment_id>/replies/<string:reply_id>")
class WorkflowCommentReplyDetailApi(Resource):
"""API for managing individual comment replies."""
@console_ns.doc("update_workflow_comment_reply")
@console_ns.doc(description="Update a comment reply")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
@console_ns.expect(console_ns.models[WorkflowCommentReplyPayload.__name__])
@console_ns.response(200, "Reply updated successfully", workflow_comment_reply_update_model)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@marshal_with(workflow_comment_reply_update_model)
@edit_permission_required
def put(self, app_model: App, comment_id: str, reply_id: str):
"""Update a comment reply."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
reply = WorkflowCommentService.update_reply(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
reply_id=reply_id,
user_id=current_user.id,
content=payload.content,
mentioned_user_ids=payload.mentioned_user_ids,
)
return reply
@console_ns.doc("delete_workflow_comment_reply")
@console_ns.doc(description="Delete a comment reply")
@console_ns.doc(params={"app_id": "Application ID", "comment_id": "Comment ID", "reply_id": "Reply ID"})
@console_ns.response(204, "Reply deleted successfully")
@login_required
@setup_required
@account_initialization_required
@get_app_model()
@edit_permission_required
def delete(self, app_model: App, comment_id: str, reply_id: str):
"""Delete a comment reply."""
# Validate comment access first
WorkflowCommentService.validate_comment_access(
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
)
WorkflowCommentService.delete_reply(
tenant_id=current_user.current_tenant_id,
app_id=app_model.id,
comment_id=comment_id,
reply_id=reply_id,
user_id=current_user.id,
)
return {"result": "success"}, 204
@console_ns.route("/apps/<uuid:app_id>/workflow/comments/mention-users")
class WorkflowCommentMentionUsersApi(Resource):
"""API for getting mentionable users for workflow comments."""
@console_ns.doc("workflow_comment_mention_users")
@console_ns.doc(description="Get all users in current tenant for mentions")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200, "Mentionable users retrieved successfully", console_ns.models[WorkflowCommentMentionUsersPayload.__name__]
)
@login_required
@setup_required
@account_initialization_required
@get_app_model()
def get(self, app_model: App):
"""Get all users in current tenant for mentions."""
members = TenantService.get_tenant_members(current_user.current_tenant)
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200
@@ -1,10 +1,14 @@
import logging import logging
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import Any, TypedDict from typing import Any, NoReturn, ParamSpec, TypeVar
from flask import Response, request from flask import Response, request
from flask_restx import Resource, fields, marshal, marshal_with from flask_restx import Resource, fields, marshal, marshal_with
from graphon.file import helpers as file_helpers
from graphon.variables.segment_group import SegmentGroup
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
from graphon.variables.types import SegmentType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -18,13 +22,8 @@ from controllers.web.error import InvalidArgumentError, NotFoundError
from core.app.file_access import DatabaseFileAccessController from core.app.file_access import DatabaseFileAccessController
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
from extensions.ext_database import db from extensions.ext_database import db
from factories import variable_factory
from factories.file_factory import build_from_mapping, build_from_mappings from factories.file_factory import build_from_mapping, build_from_mappings
from factories.variable_factory import build_segment_with_type from factories.variable_factory import build_segment_with_type
from graphon.file import helpers as file_helpers
from graphon.variables.segment_group import SegmentGroup
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
from graphon.variables.types import SegmentType
from libs.login import current_user, login_required from libs.login import current_user, login_required
from models import App, AppMode from models import App, AppMode
from models.workflow import WorkflowDraftVariable from models.workflow import WorkflowDraftVariable
@@ -46,16 +45,6 @@ class WorkflowDraftVariableUpdatePayload(BaseModel):
value: Any | None = Field(default=None, description="Variable value") value: Any | None = Field(default=None, description="Variable value")
class ConversationVariableUpdatePayload(BaseModel):
conversation_variables: list[dict[str, Any]] = Field(
..., description="Conversation variables for the draft workflow"
)
class EnvironmentVariableUpdatePayload(BaseModel):
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
console_ns.schema_model( console_ns.schema_model(
WorkflowDraftVariableListQuery.__name__, WorkflowDraftVariableListQuery.__name__,
WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), WorkflowDraftVariableListQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
@@ -64,26 +53,17 @@ console_ns.schema_model(
WorkflowDraftVariableUpdatePayload.__name__, WorkflowDraftVariableUpdatePayload.__name__,
WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0), WorkflowDraftVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
) )
console_ns.schema_model(
ConversationVariableUpdatePayload.__name__,
ConversationVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
console_ns.schema_model(
EnvironmentVariableUpdatePayload.__name__,
EnvironmentVariableUpdatePayload.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
)
def _convert_values_to_json_serializable_object(value: Segment): def _convert_values_to_json_serializable_object(value: Segment):
match value: if isinstance(value, FileSegment):
case FileSegment(): return value.value.model_dump()
return value.value.model_dump() elif isinstance(value, ArrayFileSegment):
case ArrayFileSegment(): return [i.model_dump() for i in value.value]
return [i.model_dump() for i in value.value] elif isinstance(value, SegmentGroup):
case SegmentGroup(): return [_convert_values_to_json_serializable_object(i) for i in value.value]
return [_convert_values_to_json_serializable_object(i) for i in value.value] else:
case _: return value.value
return value.value
def _serialize_var_value(variable: WorkflowDraftVariable): def _serialize_var_value(variable: WorkflowDraftVariable):
@@ -103,17 +83,10 @@ def _serialize_var_value(variable: WorkflowDraftVariable):
def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str: def _serialize_variable_type(workflow_draft_var: WorkflowDraftVariable) -> str:
value_type = workflow_draft_var.value_type value_type = workflow_draft_var.value_type
return str(value_type.exposed_type()) return value_type.exposed_type().value
class FullContentDict(TypedDict): def _serialize_full_content(variable: WorkflowDraftVariable) -> dict | None:
size_bytes: int | None
value_type: str
length: int | None
download_url: str
def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict | None:
"""Serialize full_content information for large variables.""" """Serialize full_content information for large variables."""
if not variable.is_truncated(): if not variable.is_truncated():
return None return None
@@ -121,13 +94,12 @@ def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict
variable_file = variable.variable_file variable_file = variable.variable_file
assert variable_file is not None assert variable_file is not None
result: FullContentDict = { return {
"size_bytes": variable_file.size, "size_bytes": variable_file.size,
"value_type": str(variable_file.value_type.exposed_type()), "value_type": variable_file.value_type.exposed_type().value,
"length": variable_file.length, "length": variable_file.length,
"download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True), "download_url": file_helpers.get_signed_file_url(variable_file.upload_file_id, as_attachment=True),
} }
return result
def _ensure_variable_access( def _ensure_variable_access(
@@ -220,8 +192,11 @@ workflow_draft_variable_list_model = console_ns.model(
"WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy "WorkflowDraftVariableList", workflow_draft_variable_list_fields_copy
) )
P = ParamSpec("P")
R = TypeVar("R")
def _api_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]:
def _api_prerequisite(f: Callable[P, R]):
"""Common prerequisites for all draft workflow variable APIs. """Common prerequisites for all draft workflow variable APIs.
It ensures the following conditions are satisfied: It ensures the following conditions are satisfied:
@@ -238,7 +213,7 @@ def _api_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]:
@edit_permission_required @edit_permission_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@wraps(f) @wraps(f)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Response: def wrapper(*args: P.args, **kwargs: P.kwargs):
return f(*args, **kwargs) return f(*args, **kwargs)
return wrapper return wrapper
@@ -295,7 +270,7 @@ class WorkflowVariableCollectionApi(Resource):
return Response("", 204) return Response("", 204)
def validate_node_id(node_id: str) -> None: def validate_node_id(node_id: str) -> NoReturn | None:
if node_id in [ if node_id in [
CONVERSATION_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID,
SYSTEM_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID,
@@ -310,6 +285,7 @@ def validate_node_id(node_id: str) -> None:
raise InvalidArgumentError( raise InvalidArgumentError(
f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}", f"invalid node_id, please use correspond api for conversation and system variables, node_id={node_id}",
) )
return None
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables") @console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/variables")
@@ -412,27 +388,24 @@ class VariableApi(Resource):
new_value = None new_value = None
if raw_value is not None: if raw_value is not None:
match variable.value_type: if variable.value_type == SegmentType.FILE:
case SegmentType.FILE: if not isinstance(raw_value, dict):
if not isinstance(raw_value, dict): raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}")
raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") raw_value = build_from_mapping(
raw_value = build_from_mapping( mapping=raw_value,
mapping=raw_value, tenant_id=app_model.tenant_id,
tenant_id=app_model.tenant_id, access_controller=_file_access_controller,
access_controller=_file_access_controller, )
) elif variable.value_type == SegmentType.ARRAY_FILE:
case SegmentType.ARRAY_FILE: if not isinstance(raw_value, list):
if not isinstance(raw_value, list): raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}")
raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") if len(raw_value) > 0 and not isinstance(raw_value[0], dict):
if len(raw_value) > 0 and not isinstance(raw_value[0], dict): raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}")
raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") raw_value = build_from_mappings(
raw_value = build_from_mappings( mappings=raw_value,
mappings=raw_value, tenant_id=app_model.tenant_id,
tenant_id=app_model.tenant_id, access_controller=_file_access_controller,
access_controller=_file_access_controller, )
)
case _:
pass
new_value = build_segment_with_type(variable.value_type, raw_value) new_value = build_segment_with_type(variable.value_type, raw_value)
draft_var_srv.update_variable(variable, name=new_name, value=new_value) draft_var_srv.update_variable(variable, name=new_name, value=new_value)
db.session.commit() db.session.commit()
@@ -530,34 +503,6 @@ class ConversationVariableCollectionApi(Resource):
db.session.commit() db.session.commit()
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID) return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
@console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__])
@console_ns.doc("update_conversation_variables")
@console_ns.doc(description="Update conversation variables for workflow draft")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Conversation variables updated successfully")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=AppMode.ADVANCED_CHAT)
def post(self, app_model: App):
payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {})
workflow_service = WorkflowService()
conversation_variables_list = payload.conversation_variables
conversation_variables = [
variable_factory.build_conversation_variable_from_mapping(obj) for obj in conversation_variables_list
]
workflow_service.update_draft_workflow_conversation_variables(
app_model=app_model,
account=current_user,
conversation_variables=conversation_variables,
)
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables") @console_ns.route("/apps/<uuid:app_id>/workflows/draft/system-variables")
class SystemVariableCollectionApi(Resource): class SystemVariableCollectionApi(Resource):
@@ -599,7 +544,7 @@ class EnvironmentVariableCollectionApi(Resource):
"name": v.name, "name": v.name,
"description": v.description, "description": v.description,
"selector": v.selector, "selector": v.selector,
"value_type": str(v.value_type.exposed_type()), "value_type": v.value_type.exposed_type().value,
"value": v.value, "value": v.value,
# Do not track edited for env vars. # Do not track edited for env vars.
"edited": False, "edited": False,
@@ -609,31 +554,3 @@ class EnvironmentVariableCollectionApi(Resource):
) )
return {"items": env_vars_list} return {"items": env_vars_list}
@console_ns.expect(console_ns.models[EnvironmentVariableUpdatePayload.__name__])
@console_ns.doc("update_environment_variables")
@console_ns.doc(description="Update environment variables for workflow draft")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Environment variables updated successfully")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
def post(self, app_model: App):
payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {})
workflow_service = WorkflowService()
environment_variables_list = payload.environment_variables
environment_variables = [
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
]
workflow_service.update_draft_workflow_environment_variables(
app_model=app_model,
account=current_user,
environment_variables=environment_variables,
)
return {"result": "success"}
+5 -13
View File
@@ -3,6 +3,8 @@ from typing import Literal, TypedDict, cast
from flask import request from flask import request
from flask_restx import Resource, fields, marshal_with from flask_restx import Resource, fields, marshal_with
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from pydantic import BaseModel, Field, field_validator from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
@@ -26,8 +28,6 @@ from fields.workflow_run_fields import (
workflow_run_node_execution_list_fields, workflow_run_node_execution_list_fields,
workflow_run_pagination_fields, workflow_run_pagination_fields,
) )
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
from libs.custom_inputs import time_duration from libs.custom_inputs import time_duration
from libs.helper import uuid_value from libs.helper import uuid_value
@@ -36,7 +36,7 @@ from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowR
from models.workflow import WorkflowRun from models.workflow import WorkflowRun
from repositories.factory import DifyAPIRepositoryFactory from repositories.factory import DifyAPIRepositoryFactory
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
from services.workflow_run_service import WorkflowRunListArgs, WorkflowRunService from services.workflow_run_service import WorkflowRunService
def _build_backstage_input_url(form_token: str | None) -> str | None: def _build_backstage_input_url(form_token: str | None) -> str | None:
@@ -214,11 +214,7 @@ class AdvancedChatAppWorkflowRunListApi(Resource):
Get advanced chat app workflow run list Get advanced chat app workflow run list
""" """
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
args: WorkflowRunListArgs = {"limit": args_model.limit} args = args_model.model_dump(exclude_none=True)
if args_model.last_id is not None:
args["last_id"] = args_model.last_id
if args_model.status is not None:
args["status"] = args_model.status
# Default to DEBUGGING if not specified # Default to DEBUGGING if not specified
triggered_from = ( triggered_from = (
@@ -360,11 +356,7 @@ class WorkflowRunListApi(Resource):
Get workflow run list Get workflow run list
""" """
args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore args_model = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
args: WorkflowRunListArgs = {"limit": args_model.limit} args = args_model.model_dump(exclude_none=True)
if args_model.last_id is not None:
args["last_id"] = args_model.last_id
if args_model.status is not None:
args["status"] = args_model.status
# Default to DEBUGGING for workflow if not specified (backward compatibility) # Default to DEBUGGING for workflow if not specified (backward compatibility)
triggered_from = ( triggered_from = (
+27 -63
View File
@@ -1,17 +1,16 @@
import logging import logging
from datetime import datetime
from flask import request from flask import request
from flask_restx import Resource from flask_restx import Resource, fields, marshal_with
from pydantic import BaseModel, field_validator from pydantic import BaseModel
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import sessionmaker from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import NotFound from werkzeug.exceptions import NotFound
from configs import dify_config from configs import dify_config
from controllers.common.schema import register_schema_models from controllers.common.schema import get_or_create_model
from extensions.ext_database import db from extensions.ext_database import db
from fields.base import ResponseModel from fields.workflow_trigger_fields import trigger_fields, triggers_list_fields, webhook_trigger_fields
from libs.login import current_user, login_required from libs.login import current_user, login_required
from models.enums import AppTriggerStatus from models.enums import AppTriggerStatus
from models.model import Account, App, AppMode from models.model import Account, App, AppMode
@@ -22,6 +21,15 @@ from ..app.wraps import get_app_model
from ..wraps import account_initialization_required, edit_permission_required, setup_required from ..wraps import account_initialization_required, edit_permission_required, setup_required
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
trigger_model = get_or_create_model("WorkflowTrigger", trigger_fields)
triggers_list_fields_copy = triggers_list_fields.copy()
triggers_list_fields_copy["data"] = fields.List(fields.Nested(trigger_model))
triggers_list_model = get_or_create_model("WorkflowTriggerList", triggers_list_fields_copy)
webhook_trigger_model = get_or_create_model("WebhookTrigger", webhook_trigger_fields)
class Parser(BaseModel): class Parser(BaseModel):
@@ -33,52 +41,10 @@ class ParserEnable(BaseModel):
enable_trigger: bool enable_trigger: bool
class WorkflowTriggerResponse(ResponseModel): console_ns.schema_model(Parser.__name__, Parser.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0))
id: str
trigger_type: str
title: str
node_id: str
provider_name: str
icon: str
status: str
created_at: datetime | None = None
updated_at: datetime | None = None
@field_validator("id", "trigger_type", "title", "node_id", "provider_name", "icon", "status", mode="before") console_ns.schema_model(
@classmethod ParserEnable.__name__, ParserEnable.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
def _normalize_string_fields(cls, value: object) -> str:
if isinstance(value, str):
return value
return str(value)
class WorkflowTriggerListResponse(ResponseModel):
data: list[WorkflowTriggerResponse]
class WebhookTriggerResponse(ResponseModel):
id: str
webhook_id: str
webhook_url: str
webhook_debug_url: str
node_id: str
created_at: datetime | None = None
@field_validator("id", "webhook_id", "webhook_url", "webhook_debug_url", "node_id", mode="before")
@classmethod
def _normalize_string_fields(cls, value: object) -> str:
if isinstance(value, str):
return value
return str(value)
register_schema_models(
console_ns,
Parser,
ParserEnable,
WorkflowTriggerResponse,
WorkflowTriggerListResponse,
WebhookTriggerResponse,
) )
@@ -91,28 +57,28 @@ class WebhookTriggerApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.WORKFLOW) @get_app_model(mode=AppMode.WORKFLOW)
@console_ns.response(200, "Success", console_ns.models[WebhookTriggerResponse.__name__]) @marshal_with(webhook_trigger_model)
def get(self, app_model: App): def get(self, app_model: App):
"""Get webhook trigger for a node""" """Get webhook trigger for a node"""
args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore args = Parser.model_validate(request.args.to_dict(flat=True)) # type: ignore
node_id = args.node_id node_id = args.node_id
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine).begin() as session:
# Get webhook trigger for this app and node # Get webhook trigger for this app and node
webhook_trigger = session.scalar( webhook_trigger = (
select(WorkflowWebhookTrigger) session.query(WorkflowWebhookTrigger)
.where( .where(
WorkflowWebhookTrigger.app_id == app_model.id, WorkflowWebhookTrigger.app_id == app_model.id,
WorkflowWebhookTrigger.node_id == node_id, WorkflowWebhookTrigger.node_id == node_id,
) )
.limit(1) .first()
) )
if not webhook_trigger: if not webhook_trigger:
raise NotFound("Webhook trigger not found for this node") raise NotFound("Webhook trigger not found for this node")
return WebhookTriggerResponse.model_validate(webhook_trigger, from_attributes=True).model_dump(mode="json") return webhook_trigger
@console_ns.route("/apps/<uuid:app_id>/triggers") @console_ns.route("/apps/<uuid:app_id>/triggers")
@@ -123,13 +89,13 @@ class AppTriggersApi(Resource):
@login_required @login_required
@account_initialization_required @account_initialization_required
@get_app_model(mode=AppMode.WORKFLOW) @get_app_model(mode=AppMode.WORKFLOW)
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerListResponse.__name__]) @marshal_with(triggers_list_model)
def get(self, app_model: App): def get(self, app_model: App):
"""Get app triggers list""" """Get app triggers list"""
assert isinstance(current_user, Account) assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None assert current_user.current_tenant_id is not None
with sessionmaker(db.engine, expire_on_commit=False).begin() as session: with sessionmaker(db.engine).begin() as session:
# Get all triggers for this app using select API # Get all triggers for this app using select API
triggers = ( triggers = (
session.execute( session.execute(
@@ -152,9 +118,7 @@ class AppTriggersApi(Resource):
else: else:
trigger.icon = "" # type: ignore trigger.icon = "" # type: ignore
return WorkflowTriggerListResponse.model_validate({"data": triggers}, from_attributes=True).model_dump( return {"data": triggers}
mode="json"
)
@console_ns.route("/apps/<uuid:app_id>/trigger-enable") @console_ns.route("/apps/<uuid:app_id>/trigger-enable")
@@ -165,7 +129,7 @@ class AppTriggerEnableApi(Resource):
@account_initialization_required @account_initialization_required
@edit_permission_required @edit_permission_required
@get_app_model(mode=AppMode.WORKFLOW) @get_app_model(mode=AppMode.WORKFLOW)
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerResponse.__name__]) @marshal_with(trigger_model)
def post(self, app_model: App): def post(self, app_model: App):
"""Update app trigger (enable/disable)""" """Update app trigger (enable/disable)"""
args = ParserEnable.model_validate(console_ns.payload) args = ParserEnable.model_validate(console_ns.payload)
@@ -196,4 +160,4 @@ class AppTriggerEnableApi(Resource):
else: else:
trigger.icon = "" # type: ignore trigger.icon = "" # type: ignore
return WorkflowTriggerResponse.model_validate(trigger, from_attributes=True).model_dump(mode="json") return trigger
+12 -47
View File
@@ -1,6 +1,6 @@
from collections.abc import Callable from collections.abc import Callable
from functools import wraps from functools import wraps
from typing import overload from typing import ParamSpec, TypeVar, Union
from sqlalchemy import select from sqlalchemy import select
@@ -9,6 +9,11 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant from libs.login import current_account_with_tenant
from models import App, AppMode from models import App, AppMode
P = ParamSpec("P")
R = TypeVar("R")
P1 = ParamSpec("P1")
R1 = TypeVar("R1")
def _load_app_model(app_id: str) -> App | None: def _load_app_model(app_id: str) -> App | None:
_, current_tenant_id = current_account_with_tenant() _, current_tenant_id = current_account_with_tenant()
@@ -23,30 +28,10 @@ def _load_app_model_with_trial(app_id: str) -> App | None:
return app_model return app_model
@overload def get_app_model(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
def get_app_model[**P, R]( def decorator(view_func: Callable[P1, R1]):
view: Callable[P, R],
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R]: ...
@overload
def get_app_model[**P, R](
view: None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def get_app_model[**P, R](
view: Callable[P, R] | None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func) @wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: def decorated_view(*args: P1.args, **kwargs: P1.kwargs):
if not kwargs.get("app_id"): if not kwargs.get("app_id"):
raise ValueError("missing app_id in path parameters") raise ValueError("missing app_id in path parameters")
@@ -84,30 +69,10 @@ def get_app_model[**P, R](
return decorator(view) return decorator(view)
@overload def get_app_model_with_trial(view: Callable[P, R] | None = None, *, mode: Union[AppMode, list[AppMode], None] = None):
def get_app_model_with_trial[**P, R]( def decorator(view_func: Callable[P, R]):
view: Callable[P, R],
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R]: ...
@overload
def get_app_model_with_trial[**P, R](
view: None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
def get_app_model_with_trial[**P, R](
view: Callable[P, R] | None = None,
*,
mode: AppMode | list[AppMode] | None = None,
) -> Callable[P, R] | Callable[[Callable[P, R]], Callable[P, R]]:
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func) @wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: def decorated_view(*args: P.args, **kwargs: P.kwargs):
if not kwargs.get("app_id"): if not kwargs.get("app_id"):
raise ValueError("missing app_id in path parameters") raise ValueError("missing app_id in path parameters")

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