Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7210f856c9 | ||
|
|
ebcc1200a3 | ||
|
|
e660d7af38 | ||
|
|
d9ccfcbc6e | ||
|
|
a9bcec013f | ||
|
|
aeb7687e2c | ||
|
|
9355d36718 | ||
|
|
a03ee828a3 | ||
|
|
7066372892 | ||
|
|
55f95dbc36 | ||
|
|
8b40de3c4e | ||
|
|
af4b9bfa8f | ||
|
|
b9e3130388 | ||
|
|
12d33652b6 | ||
|
|
fe8cf2aff4 | ||
|
|
d1d190374d | ||
|
|
e1be4e6aa8 | ||
|
|
301a470e7a | ||
|
|
91251ad5a5 | ||
|
|
3f6644a615 | ||
|
|
5edc682c4a | ||
|
|
13c00ecfc4 | ||
|
|
9d545144ce | ||
|
|
2afa39cdcb | ||
|
|
bb1c883be4 | ||
|
|
03861bcee3 | ||
|
|
c34fc429ae | ||
|
|
d110112863 | ||
|
|
934a20e745 | ||
|
|
7e56a244a8 | ||
|
|
6facd9360c | ||
|
|
a18d7f51eb | ||
|
|
680ef077ae | ||
|
|
c26be9d3f4 | ||
|
|
51a8f79d67 | ||
|
|
bb73776339 | ||
|
|
9424bf60b0 | ||
|
|
cbedcd2882 | ||
|
|
1a93af5cd0 | ||
|
|
cd90d7ffc1 | ||
|
|
4bb987eca3 | ||
|
|
4fd4615c56 | ||
|
|
c7d30bf09a | ||
|
|
59dab7deac | ||
|
|
a60cb3b800 | ||
|
|
6164408da1 | ||
|
|
7fc40e6c9e | ||
|
|
d625ac0bf1 | ||
|
|
1082f488a1 | ||
|
|
f1c4c1a5ff | ||
|
|
dd1cdbbd41 | ||
|
|
74a04afe27 | ||
|
|
b108ea42f6 | ||
|
|
1aa6188b7d | ||
|
|
bd0d10ac5c | ||
|
|
2162ea6a68 | ||
|
|
153064bbd4 | ||
|
|
a643b05368 | ||
|
|
279b66bc7f | ||
|
|
e134c1e0d5 | ||
|
|
9127209dd5 | ||
|
|
a2ee151e48 | ||
|
|
9e3e616391 | ||
|
|
837b5cad86 | ||
|
|
1a011dc14a | ||
|
|
bf117dd0c8 | ||
|
|
1e6dc62470 | ||
|
|
0b70eec695 | ||
|
|
e8dc706414 |
@@ -63,7 +63,7 @@ pnpm analyze-component <path> --json
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Complex state logic in component
|
||||
const Configuration: FC = () => {
|
||||
function Configuration() {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
@@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => {
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
const Configuration: FC = () => {
|
||||
function Configuration() {
|
||||
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
||||
return <div>...</div>
|
||||
}
|
||||
@@ -189,8 +189,6 @@ const Template = useMemo(() => {
|
||||
|
||||
**Dify Convention**:
|
||||
- This skill is for component decomposition, not query/mutation design.
|
||||
- When refactoring data fetching, follow `web/AGENTS.md`.
|
||||
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
|
||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
||||
|
||||
|
||||
@@ -60,8 +60,10 @@ const Template = useMemo(() => {
|
||||
**After** (complexity: ~3):
|
||||
|
||||
```typescript
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
// Define lookup table outside component
|
||||
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, FC<TemplateProps>>> = {
|
||||
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
[LanguagesSupported[1]]: TemplateChatZh,
|
||||
[LanguagesSupported[7]]: TemplateChatJa,
|
||||
|
||||
@@ -65,10 +65,10 @@ interface ConfigurationHeaderProps {
|
||||
onPublish: () => void
|
||||
}
|
||||
|
||||
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({
|
||||
function ConfigurationHeader({
|
||||
isAdvancedMode,
|
||||
onPublish,
|
||||
}) => {
|
||||
}: ConfigurationHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
@@ -136,7 +136,7 @@ const AppInfo = () => {
|
||||
}
|
||||
|
||||
// ✅ After: Separate view components
|
||||
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
||||
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
|
||||
return (
|
||||
<div className="expanded">
|
||||
{/* Clean, focused expanded view */}
|
||||
@@ -144,7 +144,7 @@ const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const AppInfoCollapsed: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
||||
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
|
||||
return (
|
||||
<div className="collapsed">
|
||||
{/* Clean, focused collapsed view */}
|
||||
@@ -203,12 +203,12 @@ interface AppInfoModalsProps {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const AppInfoModals: FC<AppInfoModalsProps> = ({
|
||||
function AppInfoModals({
|
||||
appDetail,
|
||||
activeModal,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}) => {
|
||||
}: AppInfoModalsProps) {
|
||||
const handleEdit = async (data) => { /* logic */ }
|
||||
const handleDuplicate = async (data) => { /* logic */ }
|
||||
const handleDelete = async () => { /* logic */ }
|
||||
@@ -296,7 +296,7 @@ interface OperationItemProps {
|
||||
onAction: (id: string) => void
|
||||
}
|
||||
|
||||
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => {
|
||||
function OperationItem({ operation, onAction }: OperationItemProps) {
|
||||
return (
|
||||
<div className="operation-item">
|
||||
<span className="icon">{operation.icon}</span>
|
||||
@@ -435,7 +435,7 @@ interface ChildProps {
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => {
|
||||
function Child({ value, onChange, onSubmit }: ChildProps) {
|
||||
return (
|
||||
<div>
|
||||
<input value={value} onChange={e => onChange(e.target.value)} />
|
||||
|
||||
@@ -112,13 +112,13 @@ export const useModelConfig = ({
|
||||
|
||||
```typescript
|
||||
// Before: 50+ lines of state management
|
||||
const Configuration: FC = () => {
|
||||
function Configuration() {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
// ... lots of related state and effects
|
||||
}
|
||||
|
||||
// After: Clean component
|
||||
const Configuration: FC = () => {
|
||||
function Configuration() {
|
||||
const {
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
@@ -159,8 +159,6 @@ const Configuration: FC = () => {
|
||||
|
||||
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
|
||||
|
||||
- Follow `web/AGENTS.md` first.
|
||||
- Use `frontend-query-mutation` for contracts, query shape, data-fetching wrappers, query/mutation call-site patterns, conditional queries, invalidation, and mutation error handling.
|
||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ Use this skill for Dify's repository-level E2E suite in `e2e/`. Use [`e2e/AGENTS
|
||||
- `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.
|
||||
5. Re-check official Playwright or Cucumber docs with the available documentation tools before introducing a new framework pattern.
|
||||
|
||||
## Local Rules
|
||||
|
||||
|
||||
@@ -9,18 +9,18 @@ Category: Performance
|
||||
|
||||
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
|
||||
|
||||
## Complex prop memoization
|
||||
## Complex prop stability
|
||||
|
||||
IsUrgent: True
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
### Description
|
||||
|
||||
Wrap complex prop values (objects, arrays, maps) in `useMemo` prior to passing them into child components to guarantee stable references and prevent unnecessary renders.
|
||||
Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization.
|
||||
|
||||
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||
|
||||
Wrong:
|
||||
Risky:
|
||||
|
||||
```tsx
|
||||
<HeavyComp
|
||||
@@ -31,7 +31,7 @@ Wrong:
|
||||
/>
|
||||
```
|
||||
|
||||
Right:
|
||||
Better when stable identity matters:
|
||||
|
||||
```tsx
|
||||
const config = useMemo(() => ({
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
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.
|
||||
---
|
||||
|
||||
# Frontend Query & Mutation
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep contract as the single source of truth in `web/contract/*`.
|
||||
- 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.
|
||||
- Treat `web/service/use-*` query or mutation wrappers as legacy migration targets, not the preferred destination.
|
||||
- Keep abstractions minimal to preserve TypeScript inference.
|
||||
|
||||
## Workflow
|
||||
|
||||
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/runtime-rules.md` for conditional queries, default options, cache updates/invalidation, error handling, and legacy migrations.
|
||||
- Read both references when a task spans contract shape and runtime behavior.
|
||||
2. Implement the smallest abstraction that fits the task.
|
||||
- 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.
|
||||
- Create or keep feature hooks only for real orchestration or shared domain behavior.
|
||||
- When touching thin `web/service/use-*` wrappers, migrate them away when feasible.
|
||||
3. Preserve Dify conventions.
|
||||
- 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.
|
||||
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required.
|
||||
|
||||
## Files Commonly Touched
|
||||
|
||||
- `web/contract/console/*.ts`
|
||||
- `web/contract/marketplace.ts`
|
||||
- `web/contract/router.ts`
|
||||
- `web/service/client.ts`
|
||||
- legacy `web/service/use-*.ts` files when migrating wrappers away
|
||||
- component and hook call sites using `consoleQuery` or `marketplaceQuery`
|
||||
|
||||
## References
|
||||
|
||||
- Use `references/contract-patterns.md` for contract shape, router registration, query and mutation helpers, and anti-patterns that degrade inference.
|
||||
- Use `references/runtime-rules.md` for conditional queries, invalidation, `mutate` versus `mutateAsync`, and legacy migration rules.
|
||||
|
||||
Treat this skill as the single query and mutation entry point for Dify frontend work. Keep detailed rules in the reference files instead of duplicating them in project docs.
|
||||
@@ -1,4 +0,0 @@
|
||||
interface:
|
||||
display_name: "Frontend Query & Mutation"
|
||||
short_description: "Dify TanStack Query, oRPC, and default option 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."
|
||||
@@ -1,129 +0,0 @@
|
||||
# Contract Patterns
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Intent
|
||||
- Minimal structure
|
||||
- Core workflow
|
||||
- Query usage decision rule
|
||||
- Mutation usage decision rule
|
||||
- Thin hook decision rule
|
||||
- Anti-patterns
|
||||
- Contract rules
|
||||
- Type export
|
||||
|
||||
## Intent
|
||||
|
||||
- Keep contract as the single source of truth in `web/contract/*`.
|
||||
- Default query usage to call-site `useQuery(consoleQuery|marketplaceQuery.xxx.queryOptions(...))` when endpoint behavior maps 1:1 to the contract.
|
||||
- Keep abstractions minimal and preserve TypeScript inference.
|
||||
|
||||
## Minimal Structure
|
||||
|
||||
```text
|
||||
web/contract/
|
||||
├── base.ts
|
||||
├── router.ts
|
||||
├── marketplace.ts
|
||||
└── console/
|
||||
├── billing.ts
|
||||
└── ...other domains
|
||||
web/service/client.ts
|
||||
```
|
||||
|
||||
## Core Workflow
|
||||
|
||||
1. Define contract in `web/contract/console/{domain}.ts` or `web/contract/marketplace.ts`.
|
||||
- Use `base.route({...}).output(type<...>())` as the baseline.
|
||||
- Add `.input(type<...>())` only when the request has `params`, `query`, or `body`.
|
||||
- For `GET` without input, omit `.input(...)`; do not use `.input(type<unknown>())`.
|
||||
2. Register contract in `web/contract/router.ts`.
|
||||
- Import directly from domain files and nest by API prefix.
|
||||
3. Consume from UI call sites via oRPC query utilities.
|
||||
|
||||
```typescript
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
|
||||
const invoiceQuery = useQuery(consoleQuery.billing.invoices.queryOptions({
|
||||
staleTime: 5 * 60 * 1000,
|
||||
throwOnError: true,
|
||||
select: invoice => invoice.url,
|
||||
}))
|
||||
```
|
||||
|
||||
## Query Usage Decision Rule
|
||||
|
||||
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.
|
||||
3. Create or keep feature hooks only for orchestration.
|
||||
- Combine multiple queries or mutations.
|
||||
- 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
|
||||
const invoicesBaseQueryOptions = () =>
|
||||
consoleQuery.billing.invoices.queryOptions({ retry: false })
|
||||
|
||||
const invoiceQuery = useQuery({
|
||||
...invoicesBaseQueryOptions(),
|
||||
throwOnError: true,
|
||||
})
|
||||
```
|
||||
|
||||
## Mutation Usage Decision Rule
|
||||
|
||||
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.
|
||||
|
||||
```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
|
||||
|
||||
- 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 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.
|
||||
|
||||
## Contract Rules
|
||||
|
||||
- Input structure: always use `{ params, query?, body? }`.
|
||||
- No-input `GET`: omit `.input(...)`; do not use `.input(type<unknown>())`.
|
||||
- Path params: use `{paramName}` in the path and match it in the `params` object.
|
||||
- Router nesting: group by API prefix, for example `/billing/*` becomes `billing: {}`.
|
||||
- No barrel files: import directly from specific files.
|
||||
- Types: import from `@/types/` and use the `type<T>()` helper.
|
||||
- Mutations: prefer `mutationOptions`; use explicit `mutationKey` mainly for defaults, filtering, and devtools.
|
||||
|
||||
## Type Export
|
||||
|
||||
```typescript
|
||||
export type ConsoleInputs = InferContractRouterInputs<typeof consoleRouterContract>
|
||||
```
|
||||
@@ -1,172 +0,0 @@
|
||||
# Runtime Rules
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- Conditional queries
|
||||
- oRPC default options
|
||||
- Cache invalidation
|
||||
- Key API guide
|
||||
- `mutate` vs `mutateAsync`
|
||||
- Legacy migration
|
||||
|
||||
## Conditional Queries
|
||||
|
||||
Prefer contract-shaped `queryOptions(...)`.
|
||||
When required input is missing, prefer `input: skipToken` instead of placeholder params or non-null assertions.
|
||||
Use `enabled` only for extra business gating after the input itself is already valid.
|
||||
|
||||
```typescript
|
||||
import { skipToken, useQuery } from '@tanstack/react-query'
|
||||
|
||||
// Disable the query by skipping input construction.
|
||||
function useAccessMode(appId: string | undefined) {
|
||||
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
|
||||
input: appId
|
||||
? { params: { appId } }
|
||||
: skipToken,
|
||||
}))
|
||||
}
|
||||
|
||||
// Avoid runtime-only guards that bypass type checking.
|
||||
function useBadAccessMode(appId: string | undefined) {
|
||||
return useQuery(consoleQuery.accessControl.appAccessMode.queryOptions({
|
||||
input: { params: { appId: appId! } },
|
||||
enabled: !!appId,
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
Bind shared invalidation in oRPC defaults when it is tied to a contract operation.
|
||||
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.
|
||||
|
||||
Use:
|
||||
|
||||
- `.key()` for namespace or prefix invalidation
|
||||
- `.queryKey(...)` only for exact cache reads or writes such as `getQueryData` and `setQueryData`
|
||||
- `queryClient.invalidateQueries(...)` in mutation `onSuccess`
|
||||
|
||||
Do not use deprecated `useInvalid` from `use-base.ts`.
|
||||
|
||||
```typescript
|
||||
// Feature orchestration owns cache invalidation only when defaults are not enough.
|
||||
export const useUpdateAccessMode = () => {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
return useMutation(consoleQuery.accessControl.updateAccessMode.mutationOptions({
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
|
||||
})
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
// Component only adds UI behavior.
|
||||
updateAccessMode({ appId, mode }, {
|
||||
onSuccess: () => toast.success('...'),
|
||||
})
|
||||
|
||||
// Avoid putting invalidation knowledge in the component.
|
||||
mutate({ appId, mode }, {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: consoleQuery.accessControl.appWhitelistSubjects.key(),
|
||||
})
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
## Key API Guide
|
||||
|
||||
- `.key(...)`
|
||||
- Use for partial matching operations.
|
||||
- Prefer it for invalidation, refetch, and cancel patterns.
|
||||
- Example: `queryClient.invalidateQueries({ queryKey: consoleQuery.billing.key() })`
|
||||
- `.queryKey(...)`
|
||||
- Use for a specific query's full key.
|
||||
- Prefer it for exact cache addressing and direct reads or writes.
|
||||
- `.mutationKey(...)`
|
||||
- Use for a specific mutation's full key.
|
||||
- Prefer it for mutation defaults registration, mutation-status filtering, and devtools grouping.
|
||||
|
||||
## `mutate` vs `mutateAsync`
|
||||
|
||||
Prefer `mutate` by default.
|
||||
Use `mutateAsync` only when Promise semantics are truly required, such as parallel mutations or sequential steps with result dependencies.
|
||||
|
||||
Rules:
|
||||
|
||||
- Event handlers should usually call `mutate(...)` with `onSuccess` or `onError`.
|
||||
- Every `await mutateAsync(...)` must be wrapped in `try/catch`.
|
||||
- Do not use `mutateAsync` when callbacks already express the flow clearly.
|
||||
|
||||
```typescript
|
||||
// Default case.
|
||||
mutation.mutate(data, {
|
||||
onSuccess: result => router.push(result.url),
|
||||
})
|
||||
|
||||
// Promise semantics are required.
|
||||
try {
|
||||
const order = await createOrder.mutateAsync(orderData)
|
||||
await confirmPayment.mutateAsync({ orderId: order.id, token })
|
||||
router.push(`/orders/${order.id}`)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : 'Unknown error')
|
||||
}
|
||||
```
|
||||
|
||||
## Legacy Migration
|
||||
|
||||
When touching old code, migrate it toward these rules:
|
||||
|
||||
| Old pattern | New pattern |
|
||||
|---|---|
|
||||
| `useInvalid(key)` in service wrappers | oRPC defaults, or a feature vertical hook for real orchestration |
|
||||
| component-triggered invalidation after mutation | move invalidation into oRPC defaults or a feature vertical hook |
|
||||
| imperative fetch plus manual invalidation | wrap it in `useMutation(...mutationOptions(...))` |
|
||||
| `await mutateAsync()` without `try/catch` | switch to `mutate(...)` or add `try/catch` |
|
||||
@@ -5,7 +5,7 @@ description: Generate Vitest + React Testing Library tests for Dify frontend com
|
||||
|
||||
# Dify Frontend Testing Skill
|
||||
|
||||
This skill enables Claude to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices.
|
||||
This skill enables Codex to generate high-quality, comprehensive frontend tests for the Dify project following established conventions and best practices.
|
||||
|
||||
> **⚠️ Authoritative Source**: This skill is derived from `web/docs/test.md`. Use Vitest mock/timer APIs (`vi.*`).
|
||||
|
||||
@@ -24,35 +24,27 @@ Apply this skill when the user:
|
||||
**Do NOT apply** when:
|
||||
|
||||
- User is asking about backend/API tests (Python/pytest)
|
||||
- User is asking about E2E tests (Playwright/Cypress)
|
||||
- User is asking about E2E tests (Cucumber + Playwright under `e2e/`)
|
||||
- User is only asking conceptual questions without code context
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Tech Stack
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| Vitest | 4.0.16 | Test runner |
|
||||
| React Testing Library | 16.0 | Component testing |
|
||||
| jsdom | - | Test environment |
|
||||
| nock | 14.0 | HTTP mocking |
|
||||
| TypeScript | 5.x | Type safety |
|
||||
|
||||
### Key Commands
|
||||
|
||||
Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`.
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pnpm test
|
||||
|
||||
# Watch mode
|
||||
pnpm test:watch
|
||||
pnpm test --watch
|
||||
|
||||
# Run specific file
|
||||
pnpm test path/to/file.spec.tsx
|
||||
|
||||
# Generate coverage report
|
||||
pnpm test:coverage
|
||||
pnpm test --coverage
|
||||
|
||||
# Analyze component complexity
|
||||
pnpm analyze-component <path>
|
||||
@@ -228,7 +220,10 @@ Every test should clearly separate:
|
||||
### 2. Black-Box Testing
|
||||
|
||||
- Test observable behavior, not implementation details
|
||||
- Use semantic queries (getByRole, getByLabelText)
|
||||
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
|
||||
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
|
||||
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
|
||||
- Do not assert decorative icons by test id. Assert the named control that contains them, or mark decorative icons `aria-hidden`.
|
||||
- Avoid testing internal state directly
|
||||
- **Prefer pattern matching over hardcoded strings** in assertions:
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ See [Zustand Store Testing](#zustand-store-testing) section for full details.
|
||||
|
||||
| Location | Purpose |
|
||||
|----------|---------|
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `next/image`, `zustand`) |
|
||||
| `web/vitest.setup.ts` | Global mocks shared by all tests (`react-i18next`, `zustand`, clipboard, FloatingPortal, Monaco, localStorage`) |
|
||||
| `web/__mocks__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
||||
@@ -216,28 +216,21 @@ describe('Component', () => {
|
||||
})
|
||||
```
|
||||
|
||||
### 5. HTTP Mocking with Nock
|
||||
### 5. HTTP and `fetch` Mocking
|
||||
|
||||
```typescript
|
||||
import nock from 'nock'
|
||||
|
||||
const GITHUB_HOST = 'https://api.github.com'
|
||||
const GITHUB_PATH = '/repos/owner/repo'
|
||||
|
||||
const mockGithubApi = (status: number, body: Record<string, unknown>, delayMs = 0) => {
|
||||
return nock(GITHUB_HOST)
|
||||
.get(GITHUB_PATH)
|
||||
.delay(delayMs)
|
||||
.reply(status, body)
|
||||
}
|
||||
|
||||
describe('GithubComponent', () => {
|
||||
afterEach(() => {
|
||||
nock.cleanAll()
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should display repo info', async () => {
|
||||
mockGithubApi(200, { name: 'dify', stars: 1000 })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ name: 'dify', stars: 1000 }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
render(<GithubComponent />)
|
||||
|
||||
@@ -247,7 +240,12 @@ describe('GithubComponent', () => {
|
||||
})
|
||||
|
||||
it('should handle API error', async () => {
|
||||
mockGithubApi(500, { message: 'Server error' })
|
||||
vi.mocked(globalThis.fetch).mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ message: 'Server error' }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}),
|
||||
)
|
||||
|
||||
render(<GithubComponent />)
|
||||
|
||||
@@ -258,6 +256,8 @@ describe('GithubComponent', () => {
|
||||
})
|
||||
```
|
||||
|
||||
Prefer mocking `@/service/*` modules or spying on `global.fetch` / `ky` clients with deterministic responses. Do not introduce an HTTP interception dependency such as `nock` or MSW unless it is already declared in the workspace or adding it is part of the task.
|
||||
|
||||
### 6. Context Providers
|
||||
|
||||
```typescript
|
||||
@@ -332,7 +332,7 @@ const renderWithQueryClient = (ui: React.ReactElement) => {
|
||||
1. **Don't mock Zustand store modules** - Use real stores with `setState()`
|
||||
1. Don't mock components you can import directly
|
||||
1. Don't create overly simplified mocks that miss conditional logic
|
||||
1. Don't forget to clean up nock after each test
|
||||
1. Don't leave HTTP mocks or service mock state leaking between tests
|
||||
1. Don't use `any` types in mocks without necessity
|
||||
|
||||
### Mock Decision Tree
|
||||
|
||||
@@ -227,12 +227,12 @@ Failing tests compound:
|
||||
|
||||
**Fix failures immediately before proceeding.**
|
||||
|
||||
## Integration with Claude's Todo Feature
|
||||
## Integration with Codex's Todo Feature
|
||||
|
||||
When using Claude for multi-file testing:
|
||||
When using Codex for multi-file testing:
|
||||
|
||||
1. **Ask Claude to create a todo list** before starting
|
||||
1. **Request one file at a time** or ensure Claude processes incrementally
|
||||
1. **Create a todo list** before starting
|
||||
1. **Process one file at a time**
|
||||
1. **Verify each test passes** before asking for the next
|
||||
1. **Mark todos complete** as you progress
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
|
||||
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
|
||||
|
||||
## Core Defaults
|
||||
|
||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
||||
- Use Tailwind CSS v4.1+ rules via the `tailwind-css-rules` skill. Prefer v4 utilities, `gap`, `text-size/line-height`, `min-h-dvh`, and avoid deprecated utilities and `@apply`.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
|
||||
## Components, Props, And Types
|
||||
|
||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
|
||||
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
|
||||
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
||||
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
|
||||
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
|
||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
|
||||
- Do not use deprecated `useInvalid` or `useReset`.
|
||||
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
|
||||
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
|
||||
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
|
||||
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
|
||||
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
|
||||
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
|
||||
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
|
||||
|
||||
## Navigation And Performance
|
||||
|
||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
||||
@@ -0,0 +1,367 @@
|
||||
---
|
||||
name: tailwind-css-rules
|
||||
description: Tailwind CSS v4.1+ rules and best practices. Use when writing, reviewing, refactoring, or upgrading Tailwind CSS classes and styles, especially v4 utility migrations, layout spacing, typography, responsive variants, dark mode, gradients, CSS variables, and component styling.
|
||||
---
|
||||
|
||||
# Tailwind CSS Rules and Best Practices
|
||||
|
||||
## Core Principles
|
||||
|
||||
- **Always use Tailwind CSS v4.1+** - Ensure the codebase is using the latest version
|
||||
- **Do not use deprecated or removed utilities** - ALWAYS use the replacement
|
||||
- **Never use `@apply`** - Use CSS variables, the `--spacing()` function, or framework components instead
|
||||
- **Check for redundant classes** - Remove any classes that aren't necessary
|
||||
- **Group elements logically** to simplify responsive tweaks later
|
||||
|
||||
## Upgrading to Tailwind CSS v4
|
||||
|
||||
### Before Upgrading
|
||||
|
||||
- **Always read the upgrade documentation first** - Read https://tailwindcss.com/docs/upgrade-guide and https://tailwindcss.com/blog/tailwindcss-v4 before starting an upgrade.
|
||||
- Ensure the git repository is in a clean state before starting
|
||||
|
||||
### Upgrade Process
|
||||
|
||||
1. Run the upgrade command: `npx @tailwindcss/upgrade@latest` for both major and minor updates
|
||||
2. The tool will convert JavaScript config files to the new CSS format
|
||||
3. Review all changes extensively to clean up any false positives
|
||||
4. Test thoroughly across your application
|
||||
|
||||
## Breaking Changes Reference
|
||||
|
||||
### Removed Utilities (NEVER use these in v4)
|
||||
|
||||
| ❌ Deprecated | ✅ Replacement |
|
||||
| ----------------------- | ------------------------------------------------- |
|
||||
| `bg-opacity-*` | Use opacity modifiers like `bg-black/50` |
|
||||
| `text-opacity-*` | Use opacity modifiers like `text-black/50` |
|
||||
| `border-opacity-*` | Use opacity modifiers like `border-black/50` |
|
||||
| `divide-opacity-*` | Use opacity modifiers like `divide-black/50` |
|
||||
| `ring-opacity-*` | Use opacity modifiers like `ring-black/50` |
|
||||
| `placeholder-opacity-*` | Use opacity modifiers like `placeholder-black/50` |
|
||||
| `flex-shrink-*` | `shrink-*` |
|
||||
| `flex-grow-*` | `grow-*` |
|
||||
| `overflow-ellipsis` | `text-ellipsis` |
|
||||
| `decoration-slice` | `box-decoration-slice` |
|
||||
| `decoration-clone` | `box-decoration-clone` |
|
||||
|
||||
### Renamed Utilities
|
||||
|
||||
Use the v4 name when migrating code that still carries Tailwind v3 semantics. Do not blanket-replace existing v4 classes: classes such as `rounded-sm`, `shadow-sm`, `ring-1`, and `ring-2` are valid in this codebase when they intentionally represent the current design scale.
|
||||
|
||||
| ❌ v3 pattern | ✅ v4 pattern |
|
||||
| ------------------- | -------------------------------------------------- |
|
||||
| `bg-gradient-*` | `bg-linear-*` |
|
||||
| old shadow scale | verify against the current Tailwind/design scale |
|
||||
| old blur scale | verify against the current Tailwind/design scale |
|
||||
| old radius scale | use the Dify radius token mapping when applicable |
|
||||
| `outline-none` | `outline-hidden` |
|
||||
| bare `ring` utility | use an explicit ring width such as `ring-1`/`ring-2`/`ring-3` |
|
||||
|
||||
For Figma radius tokens, follow `packages/dify-ui/AGENTS.md`. For example, `--radius/xs` maps to `rounded-sm`; do not rewrite it to `rounded-xs`.
|
||||
|
||||
## Layout and Spacing Rules
|
||||
|
||||
### Flexbox and Grid Spacing
|
||||
|
||||
#### Always use gap utilities for internal spacing
|
||||
|
||||
Gap provides consistent spacing without edge cases (no extra space on last items). It's cleaner and more maintainable than margins on children.
|
||||
|
||||
```html
|
||||
<!-- ❌ Don't do this -->
|
||||
<div class="flex">
|
||||
<div class="mr-4">Item 1</div>
|
||||
<div class="mr-4">Item 2</div>
|
||||
<div>Item 3</div>
|
||||
<!-- No margin on last -->
|
||||
</div>
|
||||
|
||||
<!-- ✅ Do this instead -->
|
||||
<div class="flex gap-4">
|
||||
<div>Item 1</div>
|
||||
<div>Item 2</div>
|
||||
<div>Item 3</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Gap vs Space utilities
|
||||
|
||||
- **Never use `space-x-*` or `space-y-*` in flex/grid layouts** - always use gap
|
||||
- Space utilities add margins to children and have issues with wrapped items
|
||||
- Gap works correctly with flex-wrap and all flex directions
|
||||
|
||||
```html
|
||||
<!-- ❌ Avoid space utilities in flex containers -->
|
||||
<div class="flex flex-wrap space-x-4">
|
||||
<!-- Space utilities break with wrapped items -->
|
||||
</div>
|
||||
|
||||
<!-- ✅ Use gap for consistent spacing -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<!-- Gap works perfectly with wrapping -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### General Spacing Guidelines
|
||||
|
||||
- **Prefer top and left margins** over bottom and right margins (unless conditionally rendered)
|
||||
- **Use padding on parent containers** instead of bottom margins on the last child
|
||||
- **Always use `min-h-dvh` instead of `min-h-screen`** - `min-h-screen` is buggy on mobile Safari
|
||||
- **Prefer `size-*` utilities** over separate `w-*` and `h-*` when setting equal dimensions
|
||||
- For max-widths, prefer the container scale (e.g., `max-w-2xs` over `max-w-72`)
|
||||
|
||||
## Typography Rules
|
||||
|
||||
### Line Heights
|
||||
|
||||
- **Never use `leading-*` classes** - Always use line height modifiers with text size
|
||||
- **Always use fixed line heights from the spacing scale** - Don't use named values
|
||||
|
||||
```html
|
||||
<!-- ❌ Don't do this -->
|
||||
<p class="text-base leading-7">Text with separate line height</p>
|
||||
<p class="text-lg leading-relaxed">Text with named line height</p>
|
||||
|
||||
<!-- ✅ Do this instead -->
|
||||
<p class="text-base/7">Text with line height modifier</p>
|
||||
<p class="text-lg/8">Text with specific line height</p>
|
||||
```
|
||||
|
||||
### Font Size Reference
|
||||
|
||||
Be precise with font sizes - know the actual pixel values:
|
||||
|
||||
- `text-xs` = 12px
|
||||
- `text-sm` = 14px
|
||||
- `text-base` = 16px
|
||||
- `text-lg` = 18px
|
||||
- `text-xl` = 20px
|
||||
|
||||
## Color and Opacity
|
||||
|
||||
### Opacity Modifiers
|
||||
|
||||
**Never use `bg-opacity-*`, `text-opacity-*`, etc.** - use the opacity modifier syntax:
|
||||
|
||||
```html
|
||||
<!-- ❌ Don't do this -->
|
||||
<div class="bg-red-500 bg-opacity-60">Old opacity syntax</div>
|
||||
|
||||
<!-- ✅ Do this instead -->
|
||||
<div class="bg-red-500/60">Modern opacity syntax</div>
|
||||
```
|
||||
|
||||
## Responsive Design
|
||||
|
||||
### Breakpoint Optimization
|
||||
|
||||
- **Check for redundant classes across breakpoints**
|
||||
- **Only add breakpoint variants when values change**
|
||||
|
||||
```html
|
||||
<!-- ❌ Redundant breakpoint classes -->
|
||||
<div class="px-4 md:px-4 lg:px-4">
|
||||
<!-- md:px-4 and lg:px-4 are redundant -->
|
||||
</div>
|
||||
|
||||
<!-- ✅ Efficient breakpoint usage -->
|
||||
<div class="px-4 lg:px-8">
|
||||
<!-- Only specify when value changes -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Dark Mode Best Practices
|
||||
|
||||
- Use the plain `dark:` variant pattern
|
||||
- Put light mode styles first, then dark mode styles
|
||||
- Ensure `dark:` variant comes before other variants
|
||||
|
||||
```html
|
||||
<!-- ✅ Correct dark mode pattern -->
|
||||
<div class="bg-white text-black dark:bg-black dark:text-white">
|
||||
<button class="hover:bg-gray-100 dark:hover:bg-gray-800">Click me</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Gradient Utilities
|
||||
|
||||
- **ALWAYS Use `bg-linear-*` instead of `bg-gradient-*` utilities** - The gradient utilities were renamed in v4
|
||||
- Use the new `bg-radial` or `bg-radial-[<position>]` to create radial gradients
|
||||
- Use the new `bg-conic` or `bg-conic-*` to create conic gradients
|
||||
|
||||
```html
|
||||
<!-- ✅ Use the new gradient utilities -->
|
||||
<div class="h-14 bg-linear-to-br from-violet-500 to-fuchsia-500"></div>
|
||||
<div
|
||||
class="size-18 bg-radial-[at_50%_75%] from-sky-200 via-blue-400 to-indigo-900 to-90%"
|
||||
></div>
|
||||
<div
|
||||
class="size-24 bg-conic-180 from-indigo-600 via-indigo-50 to-indigo-600"
|
||||
></div>
|
||||
|
||||
<!-- ❌ Do not use bg-gradient-* utilities -->
|
||||
<div class="h-14 bg-gradient-to-br from-violet-500 to-fuchsia-500"></div>
|
||||
```
|
||||
|
||||
## Working with CSS Variables
|
||||
|
||||
### Accessing Theme Values
|
||||
|
||||
Tailwind CSS v4 exposes all theme values as CSS variables:
|
||||
|
||||
```css
|
||||
/* Access colors, and other theme values */
|
||||
.custom-element {
|
||||
background: var(--color-red-500);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
```
|
||||
|
||||
### The `--spacing()` Function
|
||||
|
||||
Use the dedicated `--spacing()` function for spacing calculations:
|
||||
|
||||
```css
|
||||
.custom-class {
|
||||
margin-top: calc(100vh - --spacing(16));
|
||||
}
|
||||
```
|
||||
|
||||
### Extending theme values
|
||||
|
||||
Use CSS to extend theme values:
|
||||
|
||||
```css
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-mint-500: oklch(0.72 0.11 178);
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<div class="bg-mint-500">
|
||||
<!-- ... -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## New v4 Features
|
||||
|
||||
### Container Queries
|
||||
|
||||
Use the `@container` class and size variants:
|
||||
|
||||
```html
|
||||
<article class="@container">
|
||||
<div class="flex flex-col @md:flex-row @lg:gap-8">
|
||||
<img class="w-full @md:w-48" />
|
||||
<div class="mt-4 @md:mt-0">
|
||||
<!-- Content adapts to container size -->
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
```
|
||||
|
||||
### Container Query Units
|
||||
|
||||
Use container-based units like `cqw` for responsive sizing:
|
||||
|
||||
```html
|
||||
<div class="@container">
|
||||
<h1 class="text-[50cqw]">Responsive to container width</h1>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Text Shadows (v4.1)
|
||||
|
||||
Use text-shadow-\* utilities from text-shadow-2xs to text-shadow-lg:
|
||||
|
||||
```html
|
||||
<!-- ✅ Text shadow examples -->
|
||||
<h1 class="text-shadow-lg">Large shadow</h1>
|
||||
<p class="text-shadow-sm/50">Small shadow with opacity</p>
|
||||
```
|
||||
|
||||
### Masking (v4.1)
|
||||
|
||||
Use the new composable mask utilities for image and gradient masks:
|
||||
|
||||
```html
|
||||
<!-- ✅ Linear gradient masks on specific sides -->
|
||||
<div class="mask-t-from-50%">Top fade</div>
|
||||
<div class="mask-b-from-20% mask-b-to-80%">Bottom gradient</div>
|
||||
<div class="mask-linear-from-white mask-linear-to-black/60">
|
||||
Fade from white to black
|
||||
</div>
|
||||
|
||||
<!-- ✅ Radial gradient masks -->
|
||||
<div class="mask-radial-[100%_100%] mask-radial-from-75% mask-radial-at-left">
|
||||
Radial mask
|
||||
</div>
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Avoiding Utility Inheritance
|
||||
|
||||
Don't add utilities to parents that you override in children:
|
||||
|
||||
```html
|
||||
<!-- ❌ Avoid this pattern -->
|
||||
<div class="text-center">
|
||||
<h1>Centered Heading</h1>
|
||||
<div class="text-left">Left-aligned content</div>
|
||||
</div>
|
||||
|
||||
<!-- ✅ Better approach -->
|
||||
<div>
|
||||
<h1 class="text-center">Centered Heading</h1>
|
||||
<div>Left-aligned content</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Component Extraction
|
||||
|
||||
- Extract repeated patterns into framework components, not CSS classes
|
||||
- Keep utility classes in templates/JSX
|
||||
- Use data attributes for complex state-based styling
|
||||
|
||||
## CSS Best Practices
|
||||
|
||||
### Nesting Guidelines
|
||||
|
||||
- Use nesting when styling both parent and children
|
||||
- Avoid empty parent selectors
|
||||
|
||||
```css
|
||||
/* ✅ Good nesting - parent has styles */
|
||||
.card {
|
||||
padding: --spacing(4);
|
||||
|
||||
> .card-title {
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
/* ❌ Avoid empty parents */
|
||||
ul {
|
||||
> li {
|
||||
/* Parent has no styles */
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Pitfalls to Avoid
|
||||
|
||||
1. **Using old opacity utilities** - Always use `/opacity` syntax like `bg-red-500/60`
|
||||
2. **Redundant breakpoint classes** - Only specify changes
|
||||
3. **Space utilities in flex/grid** - Always use gap
|
||||
4. **Leading utilities** - Use line-height modifiers like `text-sm/6`
|
||||
5. **Arbitrary values** - Use the design scale
|
||||
6. **@apply directive** - Use components or CSS variables
|
||||
7. **min-h-screen on mobile** - Use min-h-dvh
|
||||
8. **Separate width/height** - Use size utilities when equal
|
||||
9. **Arbitrary values** - Always use Tailwind's predefined scale whenever possible (e.g., use `ml-4` over `ml-[16px]`)
|
||||
@@ -9,6 +9,6 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
@@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111
|
||||
uses: anthropics/claude-code-action@476e359e6203e73dad705c8b322e333fabbd7416 # v1.0.119
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
+1
-1
@@ -250,5 +250,5 @@ scripts/stress-test/reports/
|
||||
|
||||
# Code Agent Folder
|
||||
.qoder/*
|
||||
|
||||
.context/*
|
||||
.eslintcache
|
||||
|
||||
@@ -9,6 +9,7 @@ The codebase is split into:
|
||||
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
||||
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
||||
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
||||
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
|
||||
|
||||
## Backend Workflow
|
||||
|
||||
|
||||
@@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius
|
||||
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
|
||||
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
|
||||
VERSION=latest
|
||||
DOCKER_DIR=docker
|
||||
DOCKER_MIDDLEWARE_ENV=$(DOCKER_DIR)/middleware.env
|
||||
DOCKER_MIDDLEWARE_ENV_EXAMPLE=$(DOCKER_DIR)/envs/middleware.env.example
|
||||
DOCKER_MIDDLEWARE_PROJECT=dify-middlewares-dev
|
||||
|
||||
# Default target - show help
|
||||
.DEFAULT_GOAL := help
|
||||
@@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api
|
||||
# Step 1: Prepare Docker middleware
|
||||
prepare-docker:
|
||||
@echo "🐳 Setting up Docker middleware..."
|
||||
@cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists"
|
||||
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d
|
||||
@if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
|
||||
cp "$(DOCKER_MIDDLEWARE_ENV_EXAMPLE)" "$(DOCKER_MIDDLEWARE_ENV)"; \
|
||||
echo "Docker middleware.env created"; \
|
||||
else \
|
||||
echo "Docker middleware.env already exists"; \
|
||||
fi
|
||||
@cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) up -d
|
||||
@echo "✅ Docker middleware started"
|
||||
|
||||
# Step 2: Prepare web environment
|
||||
@@ -39,12 +48,18 @@ prepare-api:
|
||||
# Clean dev environment
|
||||
dev-clean:
|
||||
@echo "⚠️ Stopping Docker containers..."
|
||||
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev down
|
||||
@if [ -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
|
||||
cd $(DOCKER_DIR) && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p $(DOCKER_MIDDLEWARE_PROJECT) down; \
|
||||
else \
|
||||
echo "Docker middleware.env does not exist, skipping compose down"; \
|
||||
fi
|
||||
@echo "🗑️ Removing volumes..."
|
||||
@rm -rf docker/volumes/db
|
||||
@rm -rf docker/volumes/mysql
|
||||
@rm -rf docker/volumes/redis
|
||||
@rm -rf docker/volumes/plugin_daemon
|
||||
@rm -rf docker/volumes/weaviate
|
||||
@rm -rf docker/volumes/sandbox/dependencies
|
||||
@rm -rf api/storage
|
||||
@echo "✅ Cleanup complete"
|
||||
|
||||
@@ -68,16 +83,15 @@ lint:
|
||||
@echo "✅ Linting complete"
|
||||
|
||||
type-check:
|
||||
@echo "📝 Running type checks (basedpyright + pyrefly + mypy)..."
|
||||
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||
@./dev/pyrefly-check-local
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "📝 Running type checks (pyrefly + mypy)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Type checks complete"
|
||||
|
||||
type-check-core:
|
||||
@echo "📝 Running core type checks (basedpyright + mypy)..."
|
||||
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "📝 Running core type checks (pyrefly + mypy)..."
|
||||
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||
@uv --directory api run mypy --exclude-gitignore --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||
@echo "✅ Core type checks complete"
|
||||
|
||||
test:
|
||||
@@ -132,14 +146,14 @@ help:
|
||||
@echo " make prepare-docker - Set up Docker middleware"
|
||||
@echo " make prepare-web - Set up web environment"
|
||||
@echo " make prepare-api - Set up API environment"
|
||||
@echo " make dev-clean - Stop Docker middleware containers"
|
||||
@echo " make dev-clean - Stop Docker middleware containers and remove dev data"
|
||||
@echo ""
|
||||
@echo "Backend Code Quality:"
|
||||
@echo " make format - Format code with ruff"
|
||||
@echo " make check - Check code with ruff"
|
||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
||||
@echo " make type-check-core - Run core type checks (basedpyright, mypy)"
|
||||
@echo " make type-check - Run type checks (pyrefly, mypy)"
|
||||
@echo " make type-check-core - Run core type checks (pyrefly, mypy)"
|
||||
@echo " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||
@echo ""
|
||||
@echo "Docker Build Targets:"
|
||||
|
||||
+5
-1
@@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001
|
||||
FILES_ACCESS_TIMEOUT=300
|
||||
|
||||
# Collaboration mode toggle
|
||||
ENABLE_COLLABORATION_MODE=false
|
||||
ENABLE_COLLABORATION_MODE=true
|
||||
|
||||
# Access token expiration time in minutes
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||
@@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30
|
||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||
CELERY_BACKEND=redis
|
||||
|
||||
# Ops trace retry configuration
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
|
||||
|
||||
# Database configuration
|
||||
DB_TYPE=postgresql
|
||||
DB_USERNAME=postgres
|
||||
|
||||
+1
-1
@@ -99,7 +99,7 @@ The scripts resolve paths relative to their location, so you can run them from a
|
||||
./dev/reformat # Run all formatters and linters
|
||||
uv run ruff check --fix ./ # Fix linting issues
|
||||
uv run ruff format ./ # Format code
|
||||
uv run basedpyright . # Type checking
|
||||
uv run pyrefly check # Type checking
|
||||
```
|
||||
|
||||
## Generate TS stub
|
||||
|
||||
+2
-2
@@ -117,7 +117,7 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
logger.warning("Failed to add trace headers to response", exc_info=True)
|
||||
return response
|
||||
|
||||
# Capture the decorator's return value to avoid pyright reportUnusedFunction
|
||||
# Capture the decorator return values so static checkers do not treat the hooks as unused.
|
||||
_ = before_request
|
||||
_ = add_trace_headers
|
||||
|
||||
@@ -181,7 +181,6 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_import_modules,
|
||||
ext_orjson,
|
||||
ext_forward_refs,
|
||||
ext_set_secretkey,
|
||||
ext_compress,
|
||||
ext_code_based_extension,
|
||||
ext_database,
|
||||
@@ -189,6 +188,7 @@ def initialize_extensions(app: DifyApp):
|
||||
ext_migrate,
|
||||
ext_redis,
|
||||
ext_storage,
|
||||
ext_set_secretkey,
|
||||
ext_logstore, # Initialize logstore after storage, before celery
|
||||
ext_celery,
|
||||
ext_login,
|
||||
|
||||
@@ -185,9 +185,9 @@ def transform_datasource_credentials(environment: str):
|
||||
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
||||
jina_plugin_id = "langgenius/jina_datasource"
|
||||
if environment == "online":
|
||||
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id) # pyright: ignore[reportPrivateUsage]
|
||||
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
|
||||
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
|
||||
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
|
||||
else:
|
||||
notion_plugin_unique_identifier = None
|
||||
firecrawl_plugin_unique_identifier = None
|
||||
|
||||
@@ -23,6 +23,12 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
||||
)
|
||||
|
||||
ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK: bool = Field(
|
||||
default=False,
|
||||
description="If disabled, credential policy check is only performed when saving workflows."
|
||||
"This helps gain runtime performance by trading off consistency.",
|
||||
)
|
||||
|
||||
|
||||
class EnterpriseTelemetryConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
SECRET_KEY: str = Field(
|
||||
description="Secret key for secure session cookie signing."
|
||||
"Make sure you are changing this key for your deployment with a strong key."
|
||||
"Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.",
|
||||
description="Secret key for secure session cookie signing. "
|
||||
"Leave empty to let Dify generate a persistent key in the storage directory, "
|
||||
"or set a strong value via the `SECRET_KEY` environment variable.",
|
||||
default="",
|
||||
)
|
||||
|
||||
@@ -1137,6 +1137,18 @@ class MultiModalTransferConfig(BaseSettings):
|
||||
)
|
||||
|
||||
|
||||
class OpsTraceConfig(BaseSettings):
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES: PositiveInt = Field(
|
||||
description="Maximum retry attempts for transient ops trace provider dispatch failures.",
|
||||
default=60,
|
||||
)
|
||||
|
||||
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS: PositiveInt = Field(
|
||||
description="Delay in seconds between transient ops trace provider dispatch retry attempts.",
|
||||
default=5,
|
||||
)
|
||||
|
||||
|
||||
class CeleryBeatConfig(BaseSettings):
|
||||
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
||||
description="Interval in days for Celery Beat scheduler execution, default to 1 day",
|
||||
@@ -1298,7 +1310,7 @@ class PositionConfig(BaseSettings):
|
||||
class CollaborationConfig(BaseSettings):
|
||||
ENABLE_COLLABORATION_MODE: bool = Field(
|
||||
description="Whether to enable collaboration mode features across the workspace",
|
||||
default=False,
|
||||
default=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -1417,6 +1429,7 @@ class FeatureConfig(
|
||||
ModelLoadBalanceConfig,
|
||||
ModerationConfig,
|
||||
MultiModalTransferConfig,
|
||||
OpsTraceConfig,
|
||||
PositionConfig,
|
||||
RagEtlConfig,
|
||||
RepositoryConfig,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import Any, Literal, TypedDict
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
from urllib.parse import parse_qsl, quote_plus
|
||||
|
||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||
@@ -50,28 +50,30 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig
|
||||
from .vdb.vikingdb_config import VikingDBConfig
|
||||
from .vdb.weaviate_config import WeaviateConfig
|
||||
|
||||
_VALID_STORAGE_TYPE = Literal[
|
||||
"opendal",
|
||||
"s3",
|
||||
"aliyun-oss",
|
||||
"azure-blob",
|
||||
"baidu-obs",
|
||||
"clickzetta-volume",
|
||||
"google-storage",
|
||||
"huawei-obs",
|
||||
"oci-storage",
|
||||
"tencent-cos",
|
||||
"volcengine-tos",
|
||||
"supabase",
|
||||
"local",
|
||||
]
|
||||
|
||||
|
||||
class StorageConfig(BaseSettings):
|
||||
STORAGE_TYPE: Literal[
|
||||
"opendal",
|
||||
"s3",
|
||||
"aliyun-oss",
|
||||
"azure-blob",
|
||||
"baidu-obs",
|
||||
"clickzetta-volume",
|
||||
"google-storage",
|
||||
"huawei-obs",
|
||||
"oci-storage",
|
||||
"tencent-cos",
|
||||
"volcengine-tos",
|
||||
"supabase",
|
||||
"local",
|
||||
] = Field(
|
||||
STORAGE_TYPE: _VALID_STORAGE_TYPE = Field(
|
||||
description="Type of storage to use."
|
||||
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
|
||||
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
|
||||
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
|
||||
default="opendal",
|
||||
default=cast(_VALID_STORAGE_TYPE, "opendal"),
|
||||
)
|
||||
|
||||
STORAGE_LOCAL_PATH: str = Field(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""SECRET_KEY persistence helpers for runtime setup."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
|
||||
from extensions.ext_storage import storage
|
||||
|
||||
GENERATED_SECRET_KEY_FILENAME = ".dify_secret_key"
|
||||
|
||||
|
||||
def resolve_secret_key(secret_key: str) -> str:
|
||||
"""Return an explicit SECRET_KEY or a generated key persisted in storage."""
|
||||
if secret_key:
|
||||
return secret_key
|
||||
|
||||
return _load_or_create_secret_key()
|
||||
|
||||
|
||||
def _load_or_create_secret_key() -> str:
|
||||
try:
|
||||
persisted_key = storage.load_once(GENERATED_SECRET_KEY_FILENAME).decode("utf-8").strip()
|
||||
if persisted_key:
|
||||
return persisted_key
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
generated_key = secrets.token_urlsafe(48)
|
||||
|
||||
try:
|
||||
storage.save(GENERATED_SECRET_KEY_FILENAME, f"{generated_key}\n".encode())
|
||||
except Exception as exc:
|
||||
raise ValueError(
|
||||
f"SECRET_KEY is not set and could not be generated at {GENERATED_SECRET_KEY_FILENAME}. "
|
||||
"Set SECRET_KEY explicitly or make storage writable."
|
||||
) from exc
|
||||
|
||||
return generated_key
|
||||
@@ -33,7 +33,6 @@ for module_name in RESOURCE_MODULES:
|
||||
# Ensure resource modules are imported so route decorators are evaluated.
|
||||
# Import other controllers
|
||||
from . import (
|
||||
admin,
|
||||
apikey,
|
||||
extension,
|
||||
feature,
|
||||
@@ -117,7 +116,7 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
|
||||
from .socketio import workflow as socketio_workflow
|
||||
|
||||
# Import tag controllers
|
||||
from .tag import tags
|
||||
@@ -142,7 +141,6 @@ api.add_namespace(console_ns)
|
||||
__all__ = [
|
||||
"account",
|
||||
"activate",
|
||||
"admin",
|
||||
"advanced_prompt_template",
|
||||
"agent",
|
||||
"agent_providers",
|
||||
|
||||
@@ -1,64 +1,11 @@
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import cast
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
from constants.languages import supported_language
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import only_edition_cloud
|
||||
from core.db.session_factory import session_factory
|
||||
from extensions.ext_database import db
|
||||
from libs.token import extract_access_token
|
||||
from models.model import App, ExporleBanner, InstalledApp, RecommendedApp, TrialApp
|
||||
from services.billing_service import BillingService, LangContentDict
|
||||
|
||||
|
||||
class InsertExploreAppPayload(BaseModel):
|
||||
app_id: str = Field(...)
|
||||
desc: str | None = None
|
||||
copyright: str | None = None
|
||||
privacy_policy: str | None = None
|
||||
custom_disclaimer: str | None = None
|
||||
language: str = Field(...)
|
||||
category: str = Field(...)
|
||||
position: int = Field(...)
|
||||
can_trial: bool = Field(default=False)
|
||||
trial_limit: int = Field(default=0)
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def validate_language(cls, value: str) -> str:
|
||||
return supported_language(value)
|
||||
|
||||
|
||||
class InsertExploreBannerPayload(BaseModel):
|
||||
category: str = Field(...)
|
||||
title: str = Field(...)
|
||||
description: str = Field(...)
|
||||
img_src: str = Field(..., alias="img-src")
|
||||
language: str = Field(default="en-US")
|
||||
link: str = Field(...)
|
||||
sort: int = Field(...)
|
||||
|
||||
@field_validator("language")
|
||||
@classmethod
|
||||
def validate_language(cls, value: str) -> str:
|
||||
return supported_language(value)
|
||||
|
||||
model_config = {"populate_by_name": True}
|
||||
|
||||
|
||||
register_schema_models(console_ns, InsertExploreAppPayload, InsertExploreBannerPayload)
|
||||
|
||||
|
||||
def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
@@ -76,353 +23,3 @@ def admin_required[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
@console_ns.route("/admin/insert-explore-apps")
|
||||
class InsertExploreAppListApi(Resource):
|
||||
@console_ns.doc("insert_explore_app")
|
||||
@console_ns.doc(description="Insert or update an app in the explore list")
|
||||
@console_ns.expect(console_ns.models[InsertExploreAppPayload.__name__])
|
||||
@console_ns.response(200, "App updated successfully")
|
||||
@console_ns.response(201, "App inserted successfully")
|
||||
@console_ns.response(404, "App not found")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
payload = InsertExploreAppPayload.model_validate(console_ns.payload)
|
||||
|
||||
app = db.session.execute(select(App).where(App.id == payload.app_id)).scalar_one_or_none()
|
||||
if not app:
|
||||
raise NotFound(f"App '{payload.app_id}' is not found")
|
||||
|
||||
site = app.site
|
||||
if not site:
|
||||
desc = payload.desc or ""
|
||||
copy_right = payload.copyright or ""
|
||||
privacy_policy = payload.privacy_policy or ""
|
||||
custom_disclaimer = payload.custom_disclaimer or ""
|
||||
else:
|
||||
desc = site.description or payload.desc or ""
|
||||
copy_right = site.copyright or payload.copyright or ""
|
||||
privacy_policy = site.privacy_policy or payload.privacy_policy or ""
|
||||
custom_disclaimer = site.custom_disclaimer or payload.custom_disclaimer or ""
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
recommended_app = session.execute(
|
||||
select(RecommendedApp).where(RecommendedApp.app_id == payload.app_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not recommended_app:
|
||||
recommended_app = RecommendedApp(
|
||||
app_id=app.id,
|
||||
description=desc,
|
||||
copyright=copy_right,
|
||||
privacy_policy=privacy_policy,
|
||||
custom_disclaimer=custom_disclaimer,
|
||||
language=payload.language,
|
||||
category=payload.category,
|
||||
position=payload.position,
|
||||
)
|
||||
|
||||
db.session.add(recommended_app)
|
||||
if payload.can_trial:
|
||||
trial_app = db.session.execute(
|
||||
select(TrialApp).where(TrialApp.app_id == payload.app_id)
|
||||
).scalar_one_or_none()
|
||||
if not trial_app:
|
||||
db.session.add(
|
||||
TrialApp(
|
||||
app_id=payload.app_id,
|
||||
tenant_id=app.tenant_id,
|
||||
trial_limit=payload.trial_limit,
|
||||
)
|
||||
)
|
||||
else:
|
||||
trial_app.trial_limit = payload.trial_limit
|
||||
|
||||
app.is_public = True
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 201
|
||||
else:
|
||||
recommended_app.description = desc
|
||||
recommended_app.copyright = copy_right
|
||||
recommended_app.privacy_policy = privacy_policy
|
||||
recommended_app.custom_disclaimer = custom_disclaimer
|
||||
recommended_app.language = payload.language
|
||||
recommended_app.category = payload.category
|
||||
recommended_app.position = payload.position
|
||||
|
||||
if payload.can_trial:
|
||||
trial_app = db.session.execute(
|
||||
select(TrialApp).where(TrialApp.app_id == payload.app_id)
|
||||
).scalar_one_or_none()
|
||||
if not trial_app:
|
||||
db.session.add(
|
||||
TrialApp(
|
||||
app_id=payload.app_id,
|
||||
tenant_id=app.tenant_id,
|
||||
trial_limit=payload.trial_limit,
|
||||
)
|
||||
)
|
||||
else:
|
||||
trial_app.trial_limit = payload.trial_limit
|
||||
app.is_public = True
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 200
|
||||
|
||||
|
||||
@console_ns.route("/admin/insert-explore-apps/<uuid:app_id>")
|
||||
class InsertExploreAppApi(Resource):
|
||||
@console_ns.doc("delete_explore_app")
|
||||
@console_ns.doc(description="Remove an app from the explore list")
|
||||
@console_ns.doc(params={"app_id": "Application ID to remove"})
|
||||
@console_ns.response(204, "App removed successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def delete(self, app_id: UUID):
|
||||
with session_factory.create_session() as session:
|
||||
recommended_app = session.execute(
|
||||
select(RecommendedApp).where(RecommendedApp.app_id == str(app_id))
|
||||
).scalar_one_or_none()
|
||||
|
||||
if not recommended_app:
|
||||
return {"result": "success"}, 204
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
app = session.execute(select(App).where(App.id == recommended_app.app_id)).scalar_one_or_none()
|
||||
|
||||
if app:
|
||||
app.is_public = False
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
installed_apps = (
|
||||
session.execute(
|
||||
select(InstalledApp).where(
|
||||
InstalledApp.app_id == recommended_app.app_id,
|
||||
InstalledApp.tenant_id != InstalledApp.app_owner_tenant_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
|
||||
for installed_app in installed_apps:
|
||||
session.delete(installed_app)
|
||||
|
||||
trial_app = session.execute(
|
||||
select(TrialApp).where(TrialApp.app_id == recommended_app.app_id)
|
||||
).scalar_one_or_none()
|
||||
if trial_app:
|
||||
session.delete(trial_app)
|
||||
|
||||
db.session.delete(recommended_app)
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
@console_ns.route("/admin/insert-explore-banner")
|
||||
class InsertExploreBannerApi(Resource):
|
||||
@console_ns.doc("insert_explore_banner")
|
||||
@console_ns.doc(description="Insert an explore banner")
|
||||
@console_ns.expect(console_ns.models[InsertExploreBannerPayload.__name__])
|
||||
@console_ns.response(201, "Banner inserted successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
payload = InsertExploreBannerPayload.model_validate(console_ns.payload)
|
||||
|
||||
banner = ExporleBanner(
|
||||
content={
|
||||
"category": payload.category,
|
||||
"title": payload.title,
|
||||
"description": payload.description,
|
||||
"img-src": payload.img_src,
|
||||
},
|
||||
link=payload.link,
|
||||
sort=payload.sort,
|
||||
language=payload.language,
|
||||
)
|
||||
db.session.add(banner)
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 201
|
||||
|
||||
|
||||
@console_ns.route("/admin/delete-explore-banner/<uuid:banner_id>")
|
||||
class DeleteExploreBannerApi(Resource):
|
||||
@console_ns.doc("delete_explore_banner")
|
||||
@console_ns.doc(description="Delete an explore banner")
|
||||
@console_ns.doc(params={"banner_id": "Banner ID to delete"})
|
||||
@console_ns.response(204, "Banner deleted successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def delete(self, banner_id):
|
||||
banner = db.session.execute(select(ExporleBanner).where(ExporleBanner.id == banner_id)).scalar_one_or_none()
|
||||
if not banner:
|
||||
raise NotFound(f"Banner '{banner_id}' is not found")
|
||||
|
||||
db.session.delete(banner)
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}, 204
|
||||
|
||||
|
||||
class LangContentPayload(BaseModel):
|
||||
lang: str = Field(..., description="Language tag: 'zh' | 'en' | 'jp'")
|
||||
title: str = Field(...)
|
||||
subtitle: str | None = Field(default=None)
|
||||
body: str = Field(...)
|
||||
title_pic_url: str | None = Field(default=None)
|
||||
|
||||
|
||||
class UpsertNotificationPayload(BaseModel):
|
||||
notification_id: str | None = Field(default=None, description="Omit to create; supply UUID to update")
|
||||
contents: list[LangContentPayload] = Field(..., min_length=1)
|
||||
start_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-01T00:00:00Z")
|
||||
end_time: str | None = Field(default=None, description="RFC3339, e.g. 2026-03-20T23:59:59Z")
|
||||
frequency: str = Field(default="once", description="'once' | 'every_page_load'")
|
||||
status: str = Field(default="active", description="'active' | 'inactive'")
|
||||
|
||||
|
||||
class BatchAddNotificationAccountsPayload(BaseModel):
|
||||
notification_id: str = Field(...)
|
||||
user_email: list[str] = Field(..., description="List of account email addresses")
|
||||
|
||||
|
||||
register_schema_models(console_ns, UpsertNotificationPayload, BatchAddNotificationAccountsPayload)
|
||||
|
||||
|
||||
@console_ns.route("/admin/upsert_notification")
|
||||
class UpsertNotificationApi(Resource):
|
||||
@console_ns.doc("upsert_notification")
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Create or update an in-product notification. "
|
||||
"Supply notification_id to update an existing one; omit it to create a new one. "
|
||||
"Pass at least one language variant in contents (zh / en / jp)."
|
||||
)
|
||||
)
|
||||
@console_ns.expect(console_ns.models[UpsertNotificationPayload.__name__])
|
||||
@console_ns.response(200, "Notification upserted successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
payload = UpsertNotificationPayload.model_validate(console_ns.payload)
|
||||
result = BillingService.upsert_notification(
|
||||
contents=[cast(LangContentDict, c.model_dump()) for c in payload.contents],
|
||||
frequency=payload.frequency,
|
||||
status=payload.status,
|
||||
notification_id=payload.notification_id,
|
||||
start_time=payload.start_time,
|
||||
end_time=payload.end_time,
|
||||
)
|
||||
return {"result": "success", "notification_id": result.get("notificationId")}, 200
|
||||
|
||||
|
||||
@console_ns.route("/admin/batch_add_notification_accounts")
|
||||
class BatchAddNotificationAccountsApi(Resource):
|
||||
@console_ns.doc("batch_add_notification_accounts")
|
||||
@console_ns.doc(
|
||||
description=(
|
||||
"Register target accounts for a notification by email address. "
|
||||
'JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. '
|
||||
"File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) "
|
||||
"plus a 'notification_id' field. "
|
||||
"Emails that do not match any account are silently skipped."
|
||||
)
|
||||
)
|
||||
@console_ns.response(200, "Accounts added successfully")
|
||||
@only_edition_cloud
|
||||
@admin_required
|
||||
def post(self):
|
||||
from models.account import Account
|
||||
|
||||
if "file" in request.files:
|
||||
notification_id = request.form.get("notification_id", "").strip()
|
||||
if not notification_id:
|
||||
raise BadRequest("notification_id is required.")
|
||||
emails = self._parse_emails_from_file()
|
||||
else:
|
||||
payload = BatchAddNotificationAccountsPayload.model_validate(console_ns.payload)
|
||||
notification_id = payload.notification_id
|
||||
emails = payload.user_email
|
||||
|
||||
if not emails:
|
||||
raise BadRequest("No valid email addresses provided.")
|
||||
|
||||
# Resolve emails → account IDs in chunks to avoid large IN-clause
|
||||
account_ids: list[str] = []
|
||||
chunk_size = 500
|
||||
for i in range(0, len(emails), chunk_size):
|
||||
chunk = emails[i : i + chunk_size]
|
||||
rows = db.session.execute(select(Account.id, Account.email).where(Account.email.in_(chunk))).all()
|
||||
account_ids.extend(str(row.id) for row in rows)
|
||||
|
||||
if not account_ids:
|
||||
raise BadRequest("None of the provided emails matched an existing account.")
|
||||
|
||||
# Send to dify-saas in batches of 1000
|
||||
total_count = 0
|
||||
batch_size = 1000
|
||||
for i in range(0, len(account_ids), batch_size):
|
||||
batch = account_ids[i : i + batch_size]
|
||||
result = BillingService.batch_add_notification_accounts(
|
||||
notification_id=notification_id,
|
||||
account_ids=batch,
|
||||
)
|
||||
total_count += result.get("count", 0)
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
"emails_provided": len(emails),
|
||||
"accounts_matched": len(account_ids),
|
||||
"count": total_count,
|
||||
}, 200
|
||||
|
||||
@staticmethod
|
||||
def _parse_emails_from_file() -> list[str]:
|
||||
"""Parse email addresses from an uploaded CSV or TXT file."""
|
||||
file = request.files["file"]
|
||||
if not file.filename:
|
||||
raise BadRequest("Uploaded file has no filename.")
|
||||
|
||||
filename_lower = file.filename.lower()
|
||||
if not filename_lower.endswith((".csv", ".txt")):
|
||||
raise BadRequest("Invalid file type. Only CSV (.csv) and TXT (.txt) files are allowed.")
|
||||
|
||||
try:
|
||||
content = file.stream.read().decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
file.stream.seek(0)
|
||||
content = file.stream.read().decode("gbk")
|
||||
except UnicodeDecodeError:
|
||||
raise BadRequest("Unable to decode the file. Please use UTF-8 or GBK encoding.")
|
||||
|
||||
emails: list[str] = []
|
||||
if filename_lower.endswith(".csv"):
|
||||
reader = csv.reader(io.StringIO(content))
|
||||
for row in reader:
|
||||
for cell in row:
|
||||
cell = cell.strip()
|
||||
if cell:
|
||||
emails.append(cell)
|
||||
else:
|
||||
for line in content.splitlines():
|
||||
line = line.strip()
|
||||
if line:
|
||||
emails.append(line)
|
||||
|
||||
# Deduplicate while preserving order
|
||||
seen: set[str] = set()
|
||||
unique_emails: list[str] = []
|
||||
for email in emails:
|
||||
if email.lower() not in seen:
|
||||
seen.add(email.lower())
|
||||
unique_emails.append(email)
|
||||
|
||||
return unique_emails
|
||||
|
||||
@@ -39,7 +39,7 @@ from libs.login import current_account_with_tenant, login_required
|
||||
from models import App, DatasetPermissionEnum, Workflow
|
||||
from models.model import IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import AppService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
@@ -478,11 +478,18 @@ class AppListApi(Resource):
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
|
||||
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
|
||||
args_dict = args.model_dump()
|
||||
params = AppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode=args.mode,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
# get app list
|
||||
app_service = AppService()
|
||||
app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, args_dict)
|
||||
app_pagination = app_service.get_paginate_apps(current_user.id, current_tenant_id, params)
|
||||
if not app_pagination:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json"), 200
|
||||
@@ -546,9 +553,17 @@ class AppListApi(Resource):
|
||||
"""Create app"""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
args = CreateAppPayload.model_validate(console_ns.payload)
|
||||
params = CreateAppParams(
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
mode=args.mode,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(current_tenant_id, args.model_dump(), current_user)
|
||||
app = app_service.create_app(current_tenant_id, params, current_user)
|
||||
app_detail = AppDetail.model_validate(app, from_attributes=True)
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@@ -606,63 +606,63 @@ class DatasetIndexingEstimateApi(Resource):
|
||||
# validate args
|
||||
DocumentService.estimate_args_validate(args)
|
||||
extract_settings = []
|
||||
if args["info_list"]["data_source_type"] == "upload_file":
|
||||
file_ids = args["info_list"]["file_info_list"]["file_ids"]
|
||||
file_details = db.session.scalars(
|
||||
select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids))
|
||||
).all()
|
||||
match args["info_list"]["data_source_type"]:
|
||||
case "upload_file":
|
||||
file_ids = args["info_list"]["file_info_list"]["file_ids"]
|
||||
file_details = db.session.scalars(
|
||||
select(UploadFile).where(UploadFile.tenant_id == current_tenant_id, UploadFile.id.in_(file_ids))
|
||||
).all()
|
||||
if file_details is None:
|
||||
raise NotFound("File not found.")
|
||||
|
||||
if file_details is None:
|
||||
raise NotFound("File not found.")
|
||||
|
||||
if file_details:
|
||||
for file_detail in file_details:
|
||||
if file_details:
|
||||
for file_detail in file_details:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.FILE,
|
||||
upload_file=file_detail,
|
||||
document_model=args["doc_form"],
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
case "notion_import":
|
||||
notion_info_list = args["info_list"]["notion_info_list"]
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info["workspace_id"]
|
||||
credential_id = notion_info.get("credential_id")
|
||||
for page in notion_info["pages"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
{
|
||||
"credential_id": credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_obj_id": page["page_id"],
|
||||
"notion_page_type": page["type"],
|
||||
"tenant_id": current_tenant_id,
|
||||
}
|
||||
),
|
||||
document_model=args["doc_form"],
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
case "website_crawl":
|
||||
website_info_list = args["info_list"]["website_info_list"]
|
||||
for url in website_info_list["urls"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.FILE,
|
||||
upload_file=file_detail,
|
||||
document_model=args["doc_form"],
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
elif args["info_list"]["data_source_type"] == "notion_import":
|
||||
notion_info_list = args["info_list"]["notion_info_list"]
|
||||
for notion_info in notion_info_list:
|
||||
workspace_id = notion_info["workspace_id"]
|
||||
credential_id = notion_info.get("credential_id")
|
||||
for page in notion_info["pages"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.NOTION,
|
||||
notion_info=NotionInfo.model_validate(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"credential_id": credential_id,
|
||||
"notion_workspace_id": workspace_id,
|
||||
"notion_obj_id": page["page_id"],
|
||||
"notion_page_type": page["type"],
|
||||
"provider": website_info_list["provider"],
|
||||
"job_id": website_info_list["job_id"],
|
||||
"url": url,
|
||||
"tenant_id": current_tenant_id,
|
||||
"mode": "crawl",
|
||||
"only_main_content": website_info_list["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=args["doc_form"],
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
elif args["info_list"]["data_source_type"] == "website_crawl":
|
||||
website_info_list = args["info_list"]["website_info_list"]
|
||||
for url in website_info_list["urls"]:
|
||||
extract_setting = ExtractSetting(
|
||||
datasource_type=DatasourceType.WEBSITE,
|
||||
website_info=WebsiteInfo.model_validate(
|
||||
{
|
||||
"provider": website_info_list["provider"],
|
||||
"job_id": website_info_list["job_id"],
|
||||
"url": url,
|
||||
"tenant_id": current_tenant_id,
|
||||
"mode": "crawl",
|
||||
"only_main_content": website_info_list["only_main_content"],
|
||||
}
|
||||
),
|
||||
document_model=args["doc_form"],
|
||||
)
|
||||
extract_settings.append(extract_setting)
|
||||
else:
|
||||
raise ValueError("Data source type not support")
|
||||
case _:
|
||||
raise ValueError("Data source type not support")
|
||||
indexing_runner = IndexingRunner()
|
||||
try:
|
||||
response = indexing_runner.indexing_estimate(
|
||||
|
||||
@@ -369,28 +369,31 @@ class DatasetDocumentListApi(Resource):
|
||||
else:
|
||||
sort_logic = asc
|
||||
|
||||
if sort == "hit_count":
|
||||
sub_query = (
|
||||
sa.select(DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count"))
|
||||
.where(DocumentSegment.dataset_id == str(dataset_id))
|
||||
.group_by(DocumentSegment.document_id)
|
||||
.subquery()
|
||||
)
|
||||
match sort:
|
||||
case "hit_count":
|
||||
sub_query = (
|
||||
sa.select(
|
||||
DocumentSegment.document_id, sa.func.sum(DocumentSegment.hit_count).label("total_hit_count")
|
||||
)
|
||||
.where(DocumentSegment.dataset_id == str(dataset_id))
|
||||
.group_by(DocumentSegment.document_id)
|
||||
.subquery()
|
||||
)
|
||||
|
||||
query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by(
|
||||
sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)),
|
||||
sort_logic(Document.position),
|
||||
)
|
||||
elif sort == "created_at":
|
||||
query = query.order_by(
|
||||
sort_logic(Document.created_at),
|
||||
sort_logic(Document.position),
|
||||
)
|
||||
else:
|
||||
query = query.order_by(
|
||||
desc(Document.created_at),
|
||||
desc(Document.position),
|
||||
)
|
||||
query = query.outerjoin(sub_query, sub_query.c.document_id == Document.id).order_by(
|
||||
sort_logic(sa.func.coalesce(sub_query.c.total_hit_count, 0)),
|
||||
sort_logic(Document.position),
|
||||
)
|
||||
case "created_at":
|
||||
query = query.order_by(
|
||||
sort_logic(Document.created_at),
|
||||
sort_logic(Document.position),
|
||||
)
|
||||
case _:
|
||||
query = query.order_by(
|
||||
desc(Document.created_at),
|
||||
desc(Document.position),
|
||||
)
|
||||
|
||||
paginated_documents = db.paginate(select=query, page=page, per_page=limit, max_per_page=100, error_out=False)
|
||||
documents = paginated_documents.items
|
||||
|
||||
@@ -39,11 +39,8 @@ class HitTestingPayload(BaseModel):
|
||||
|
||||
class DatasetsHitTestingBase:
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_query(query: Any) -> str:
|
||||
"""Return the user-visible query string from legacy and current response shapes."""
|
||||
if isinstance(query, str):
|
||||
return query
|
||||
|
||||
def _extract_hit_testing_query(query: Any) -> str:
|
||||
"""Return the query string from the service response shape."""
|
||||
if isinstance(query, dict):
|
||||
content = query.get("content")
|
||||
if isinstance(content, str):
|
||||
@@ -52,15 +49,15 @@ class DatasetsHitTestingBase:
|
||||
raise ValueError("Invalid hit testing query response")
|
||||
|
||||
@staticmethod
|
||||
def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
|
||||
"""Coerce nullable collection fields into lists before response validation."""
|
||||
def _prepare_hit_testing_records(records: Any) -> list[dict[str, Any]]:
|
||||
"""Ensure collection fields match the API schema before response validation."""
|
||||
if not isinstance(records, list):
|
||||
return []
|
||||
raise ValueError("Invalid hit testing records response")
|
||||
|
||||
normalized_records: list[dict[str, Any]] = []
|
||||
for record in records:
|
||||
if not isinstance(record, dict):
|
||||
continue
|
||||
raise ValueError("Invalid hit testing record response")
|
||||
|
||||
normalized_record = dict(record)
|
||||
segment = normalized_record.get("segment")
|
||||
@@ -118,8 +115,8 @@ class DatasetsHitTestingBase:
|
||||
limit=10,
|
||||
)
|
||||
return {
|
||||
"query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
|
||||
"records": DatasetsHitTestingBase._normalize_hit_testing_records(
|
||||
"query": DatasetsHitTestingBase._extract_hit_testing_query(response.get("query")),
|
||||
"records": DatasetsHitTestingBase._prepare_hit_testing_records(
|
||||
marshal(response.get("records", []), hit_testing_record_fields)
|
||||
),
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ app_detail_fields_with_site_copy["tags"] = fields.List(fields.Nested(tag_model))
|
||||
app_detail_fields_with_site_copy["site"] = fields.Nested(site_model)
|
||||
app_detail_with_site_model = get_or_create_model("TrialAppDetailWithSite", app_detail_fields_with_site_copy)
|
||||
|
||||
simple_account_model = get_or_create_model("SimpleAccount", simple_account_fields)
|
||||
simple_account_model = get_or_create_model("TrialSimpleAccount", simple_account_fields)
|
||||
conversation_variable_model = get_or_create_model("TrialConversationVariable", conversation_variable_fields)
|
||||
pipeline_variable_model = get_or_create_model("TrialPipelineVariable", pipeline_variable_fields)
|
||||
|
||||
|
||||
@@ -70,6 +70,12 @@ register_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _is_role_enabled(role: TenantAccountRole | str, tenant_id: str) -> bool:
|
||||
if role != TenantAccountRole.DATASET_OPERATOR:
|
||||
return True
|
||||
return FeatureService.get_features(tenant_id=tenant_id).dataset_operator_enabled
|
||||
|
||||
|
||||
@console_ns.route("/workspaces/current/members")
|
||||
class MemberListApi(Resource):
|
||||
"""List all members of current tenant."""
|
||||
@@ -110,6 +116,8 @@ class MemberInviteEmailApi(Resource):
|
||||
inviter = current_user
|
||||
if not inviter.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(invitee_role, inviter.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
|
||||
# Check workspace permission for member invitations
|
||||
from libs.workspace_permission import check_workspace_member_invite_permission
|
||||
@@ -208,6 +216,8 @@ class MemberUpdateRoleApi(Resource):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
if not current_user.current_tenant:
|
||||
raise ValueError("No current tenant")
|
||||
if not _is_role_enabled(new_role, current_user.current_tenant.id):
|
||||
return {"code": "invalid-role", "message": "Invalid role"}, 400
|
||||
member = db.session.get(Account, str(member_id))
|
||||
if not member:
|
||||
abort(404)
|
||||
@@ -215,11 +225,17 @@ class MemberUpdateRoleApi(Resource):
|
||||
try:
|
||||
assert member is not None, "Member not found"
|
||||
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
|
||||
except services.errors.account.CannotOperateSelfError as e:
|
||||
return {"code": "cannot-operate-self", "message": str(e)}, 400
|
||||
except services.errors.account.NoPermissionError as e:
|
||||
return {"code": "forbidden", "message": str(e)}, 403
|
||||
except services.errors.account.MemberNotInTenantError as e:
|
||||
return {"code": "member-not-found", "message": str(e)}, 404
|
||||
except services.errors.account.RoleAlreadyAssignedError as e:
|
||||
return {"code": "role-already-assigned", "message": str(e)}, 400
|
||||
except Exception as e:
|
||||
raise ValueError(str(e))
|
||||
|
||||
# todo: 403
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
|
||||
@@ -136,7 +136,7 @@ def _create_document_by_text(tenant_id: str, dataset_id: UUID) -> tuple[Mapping[
|
||||
if not dataset:
|
||||
raise ValueError("Dataset does not exist.")
|
||||
|
||||
if not dataset.indexing_technique and not args["indexing_technique"]:
|
||||
if not dataset.indexing_technique and not args.get("indexing_technique"):
|
||||
raise ValueError("indexing_technique is required.")
|
||||
|
||||
embedding_model_provider = payload.embedding_model_provider
|
||||
|
||||
@@ -532,7 +532,6 @@ class BaseAgentRunner(AppRunner):
|
||||
file_objs = file_factory.build_from_message_files(
|
||||
message_files=files,
|
||||
tenant_id=self.tenant_id,
|
||||
config=file_extra_config,
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
if not file_objs:
|
||||
|
||||
@@ -253,7 +253,20 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
):
|
||||
"""
|
||||
Resume a paused advanced chat execution.
|
||||
|
||||
``trace_manager`` is transient and excluded from generate-entity serialization,
|
||||
so resumed executions rebuild it here before persistence layers receive the entity.
|
||||
"""
|
||||
if application_generate_entity.trace_manager is None:
|
||||
application_generate_entity = application_generate_entity.model_copy(
|
||||
update={
|
||||
"trace_manager": TraceQueueManager(
|
||||
app_id=app_model.id,
|
||||
user_id=user.id if isinstance(user, Account) else user.session_id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return self._generate(
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
|
||||
@@ -178,7 +178,7 @@ class AgentChatAppConfigManager(BaseAppConfigManager):
|
||||
if not isinstance(agent_mode, dict):
|
||||
raise ValueError("agent_mode must be of object type")
|
||||
|
||||
# FIXME(-LAN-): Cast needed due to basedpyright limitation with dict type narrowing
|
||||
# FIXME(-LAN-): Cast needed because static checkers do not narrow this dict value.
|
||||
agent_mode = cast(dict[str, Any], agent_mode)
|
||||
|
||||
if "enabled" not in agent_mode or not agent_mode["enabled"]:
|
||||
|
||||
@@ -32,7 +32,7 @@ from core.app.entities.task_entities import (
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
|
||||
from core.db.session_factory import session_factory
|
||||
from core.helper.trace_id_helper import extract_external_trace_id_from_args
|
||||
from core.helper.trace_id_helper import extract_external_trace_id_from_args, extract_parent_trace_context_from_args
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
@@ -166,6 +166,7 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
|
||||
extras = {
|
||||
**extract_external_trace_id_from_args(args),
|
||||
**extract_parent_trace_context_from_args(args),
|
||||
}
|
||||
workflow_run_id = str(workflow_run_id or uuid.uuid4())
|
||||
# FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args
|
||||
@@ -252,7 +253,20 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
) -> Mapping[str, Any] | Generator[str | Mapping[str, Any], None, None]:
|
||||
"""
|
||||
Resume a paused workflow execution using the persisted runtime state.
|
||||
|
||||
``trace_manager`` is transient and excluded from generate-entity serialization,
|
||||
so resumed executions rebuild it here before persistence layers receive the entity.
|
||||
"""
|
||||
if application_generate_entity.trace_manager is None:
|
||||
application_generate_entity = application_generate_entity.model_copy(
|
||||
update={
|
||||
"trace_manager": TraceQueueManager(
|
||||
app_id=app_model.id,
|
||||
user_id=user.id if isinstance(user, Account) else user.session_id,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
return self._generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
|
||||
@@ -128,7 +128,7 @@ class DifyWorkflowFileRuntime(WorkflowFileRuntimeProtocol):
|
||||
|
||||
@staticmethod
|
||||
def _secret_key() -> bytes:
|
||||
return dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
return dify_config.SECRET_KEY.encode()
|
||||
|
||||
def _sign_query(self, *, payload: str) -> dict[str, str]:
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
@@ -15,6 +15,7 @@ from datetime import datetime
|
||||
from typing import Any, Union
|
||||
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, WorkflowAppGenerateEntity
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.entities.trace_entity import TraceTaskName
|
||||
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
|
||||
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
|
||||
@@ -403,8 +404,13 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
|
||||
conversation_id = self._system_variables().get(SystemVariableKey.CONVERSATION_ID.value)
|
||||
external_trace_id = None
|
||||
parent_trace_context = None
|
||||
if isinstance(self._application_generate_entity, (WorkflowAppGenerateEntity, AdvancedChatAppGenerateEntity)):
|
||||
external_trace_id = self._application_generate_entity.extras.get("external_trace_id")
|
||||
extras = self._application_generate_entity.extras
|
||||
external_trace_id = extras.get("external_trace_id")
|
||||
parent_trace_context = extras.get("parent_trace_context")
|
||||
if isinstance(parent_trace_context, ParentTraceContext):
|
||||
parent_trace_context = parent_trace_context.model_dump(exclude_none=True)
|
||||
|
||||
trace_task = TraceTask(
|
||||
TraceTaskName.WORKFLOW_TRACE,
|
||||
@@ -412,6 +418,7 @@ class WorkflowPersistenceLayer(GraphEngineLayer):
|
||||
conversation_id=conversation_id,
|
||||
user_id=self._trace_manager.user_id,
|
||||
external_trace_id=external_trace_id,
|
||||
parent_trace_context=parent_trace_context,
|
||||
)
|
||||
self._trace_manager.add_trace_task(trace_task)
|
||||
|
||||
|
||||
@@ -35,8 +35,11 @@ class DatasourceFileManager:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
sign = hmac.new(
|
||||
dify_config.SECRET_KEY.encode(),
|
||||
data_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
@@ -47,8 +50,11 @@ class DatasourceFileManager:
|
||||
verify signature
|
||||
"""
|
||||
data_to_sign = f"file-preview|{datasource_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_sign = hmac.new(
|
||||
dify_config.SECRET_KEY.encode(),
|
||||
data_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||
|
||||
# verify signature
|
||||
|
||||
@@ -171,9 +171,9 @@ class ProviderConfiguration(BaseModel):
|
||||
current_credential_id = self.custom_configuration.provider.current_credential_id
|
||||
|
||||
if current_credential_id:
|
||||
from core.helper.credential_utils import check_credential_policy_compliance
|
||||
from core.helper.credential_utils import runtime_check_credential_policy_compliance
|
||||
|
||||
check_credential_policy_compliance(
|
||||
runtime_check_credential_policy_compliance(
|
||||
credential_id=current_credential_id,
|
||||
provider=self.provider.provider,
|
||||
credential_type=PluginCredentialType.MODEL,
|
||||
@@ -182,9 +182,9 @@ class ProviderConfiguration(BaseModel):
|
||||
# no current credential id, check all available credentials
|
||||
if self.custom_configuration.provider:
|
||||
for credential_configuration in self.custom_configuration.provider.available_credentials:
|
||||
from core.helper.credential_utils import check_credential_policy_compliance
|
||||
from core.helper.credential_utils import runtime_check_credential_policy_compliance
|
||||
|
||||
check_credential_policy_compliance(
|
||||
runtime_check_credential_policy_compliance(
|
||||
credential_id=credential_configuration.credential_id,
|
||||
provider=self.provider.provider,
|
||||
credential_type=PluginCredentialType.MODEL,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
class LLMError(ValueError):
|
||||
"""Base class for all LLM exceptions."""
|
||||
|
||||
description: str | None = None
|
||||
description: str = ""
|
||||
|
||||
def __init__(self, description: str | None = None):
|
||||
def __init__(self, description: str = ""):
|
||||
self.description = description
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
Credential utility functions for checking credential existence and policy compliance.
|
||||
"""
|
||||
|
||||
from configs import dify_config
|
||||
from core.entities import PluginCredentialType
|
||||
|
||||
|
||||
@@ -39,6 +40,16 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
|
||||
return False
|
||||
|
||||
|
||||
def runtime_check_credential_policy_compliance(
|
||||
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
|
||||
):
|
||||
if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK:
|
||||
return
|
||||
check_credential_policy_compliance(
|
||||
credential_id=credential_id, provider=provider, credential_type=credential_type, check_existence=check_existence
|
||||
)
|
||||
|
||||
|
||||
def check_credential_policy_compliance(
|
||||
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
|
||||
) -> None:
|
||||
|
||||
@@ -3,6 +3,17 @@ import re
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, StrictStr, ValidationError
|
||||
|
||||
|
||||
class ParentTraceContext(BaseModel):
|
||||
"""Typed parent trace context propagated from an outer workflow tool node."""
|
||||
|
||||
parent_workflow_run_id: StrictStr
|
||||
parent_node_execution_id: StrictStr | None = None
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def is_valid_trace_id(trace_id: str) -> bool:
|
||||
"""
|
||||
@@ -61,6 +72,30 @@ def extract_external_trace_id_from_args(args: Mapping[str, Any]):
|
||||
return {}
|
||||
|
||||
|
||||
def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str, ParentTraceContext]:
|
||||
"""
|
||||
Extract 'parent_trace_context' from args.
|
||||
|
||||
Returns a dict suitable for use in extras when both parent identifiers exist.
|
||||
Returns an empty dict if the context is missing or incomplete.
|
||||
"""
|
||||
parent_trace_context = args.get("parent_trace_context")
|
||||
if isinstance(parent_trace_context, ParentTraceContext):
|
||||
context = parent_trace_context
|
||||
elif isinstance(parent_trace_context, Mapping):
|
||||
try:
|
||||
context = ParentTraceContext.model_validate(parent_trace_context)
|
||||
except ValidationError:
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
|
||||
if context.parent_node_execution_id is None:
|
||||
return {}
|
||||
|
||||
return {"parent_trace_context": context}
|
||||
|
||||
|
||||
def get_trace_id_from_otel_context() -> str | None:
|
||||
"""
|
||||
Retrieve the current trace ID from the active OpenTelemetry trace context.
|
||||
|
||||
@@ -324,9 +324,10 @@ class IndexingRunner:
|
||||
# one extract_setting is one source document
|
||||
for extract_setting in extract_settings:
|
||||
# extract
|
||||
processing_rule = DatasetProcessRule(
|
||||
mode=tmp_processing_rule["mode"], rules=json.dumps(tmp_processing_rule["rules"])
|
||||
)
|
||||
processing_rule = {
|
||||
"mode": tmp_processing_rule["mode"],
|
||||
"rules": tmp_processing_rule.get("rules"),
|
||||
}
|
||||
# Extract document content
|
||||
text_docs = index_processor.extract(extract_setting, process_rule_mode=tmp_processing_rule["mode"])
|
||||
# Cleaning and segmentation
|
||||
@@ -334,7 +335,7 @@ class IndexingRunner:
|
||||
text_docs,
|
||||
current_user=None,
|
||||
embedding_model_instance=embedding_model_instance,
|
||||
process_rule=processing_rule.to_dict(),
|
||||
process_rule=processing_rule,
|
||||
tenant_id=tenant_id,
|
||||
doc_language=doc_language,
|
||||
preview=True,
|
||||
|
||||
@@ -435,7 +435,7 @@ class LLMGenerator:
|
||||
stream=False,
|
||||
)
|
||||
|
||||
# Runtime type check since pyright has issues with the overload
|
||||
# Runtime type check for overload narrowing.
|
||||
if not isinstance(result, LLMResult):
|
||||
raise TypeError("Expected LLMResult when stream=False")
|
||||
response = result
|
||||
|
||||
+16
-2
@@ -2,7 +2,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel
|
||||
from pydantic import BaseModel, ConfigDict, Field, FileUrl, RootModel, model_validator
|
||||
from pydantic.networks import AnyUrl, UrlConstraints
|
||||
|
||||
"""
|
||||
@@ -173,7 +173,21 @@ class JSONRPCError(BaseModel):
|
||||
|
||||
|
||||
class JSONRPCMessage(RootModel[JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError]):
|
||||
pass
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _select_message_type(
|
||||
cls, value: Any
|
||||
) -> JSONRPCRequest | JSONRPCNotification | JSONRPCResponse | JSONRPCError | Any:
|
||||
if isinstance(value, dict):
|
||||
if "result" in value:
|
||||
return JSONRPCResponse.model_validate(value)
|
||||
if "error" in value:
|
||||
return JSONRPCError.model_validate(value)
|
||||
if "method" in value:
|
||||
if "id" in value:
|
||||
return JSONRPCRequest.model_validate(value)
|
||||
return JSONRPCNotification.model_validate(value)
|
||||
return value
|
||||
|
||||
|
||||
class EmptyResult(Result):
|
||||
|
||||
@@ -86,12 +86,10 @@ class TokenBufferMemory:
|
||||
|
||||
detail = ImagePromptMessageContent.DETAIL.HIGH
|
||||
if file_extra_config and app_record:
|
||||
# Build files directly without filtering by belongs_to
|
||||
file_objs = [
|
||||
file_factory.build_from_message_file(
|
||||
message_file=message_file,
|
||||
tenant_id=app_record.tenant_id,
|
||||
config=file_extra_config,
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
for message_file in message_files
|
||||
|
||||
@@ -391,10 +391,10 @@ class ModelInstance:
|
||||
|
||||
# Additional policy compliance check as fallback (in case fetch_next didn't catch it)
|
||||
try:
|
||||
from core.helper.credential_utils import check_credential_policy_compliance
|
||||
from core.helper.credential_utils import runtime_check_credential_policy_compliance
|
||||
|
||||
if lb_config.credential_id:
|
||||
check_credential_policy_compliance(
|
||||
runtime_check_credential_policy_compliance(
|
||||
credential_id=lb_config.credential_id,
|
||||
provider=self.provider,
|
||||
credential_type=PluginCredentialType.MODEL,
|
||||
@@ -630,10 +630,10 @@ class LBModelManager:
|
||||
|
||||
# Check policy compliance for the selected configuration
|
||||
try:
|
||||
from core.helper.credential_utils import check_credential_policy_compliance
|
||||
from core.helper.credential_utils import runtime_check_credential_policy_compliance
|
||||
|
||||
if config.credential_id:
|
||||
check_credential_policy_compliance(
|
||||
runtime_check_credential_policy_compliance(
|
||||
credential_id=config.credential_id,
|
||||
provider=self._provider,
|
||||
credential_type=PluginCredentialType.MODEL,
|
||||
|
||||
@@ -5,6 +5,8 @@ from typing import Any, Union
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, field_serializer, field_validator
|
||||
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
|
||||
|
||||
class BaseTraceInfo(BaseModel):
|
||||
message_id: str | None = None
|
||||
@@ -51,8 +53,8 @@ class BaseTraceInfo(BaseModel):
|
||||
def resolved_parent_context(self) -> tuple[str | None, str | None]:
|
||||
"""Resolve cross-workflow parent linking from metadata.
|
||||
|
||||
Extracts typed parent IDs from the untyped ``parent_trace_context``
|
||||
metadata dict (set by tool_node when invoking nested workflows).
|
||||
Extracts typed parent IDs from the ``parent_trace_context`` metadata
|
||||
payload (set by tool_node when invoking nested workflows).
|
||||
|
||||
Returns:
|
||||
(trace_correlation_override, parent_span_id_source) where
|
||||
@@ -60,13 +62,18 @@ class BaseTraceInfo(BaseModel):
|
||||
parent_span_id_source is the outer node_execution_id.
|
||||
"""
|
||||
parent_ctx = self.metadata.get("parent_trace_context")
|
||||
if not isinstance(parent_ctx, dict):
|
||||
if isinstance(parent_ctx, ParentTraceContext):
|
||||
context = parent_ctx
|
||||
elif isinstance(parent_ctx, Mapping):
|
||||
try:
|
||||
context = ParentTraceContext.model_validate(parent_ctx)
|
||||
except ValueError:
|
||||
return None, None
|
||||
else:
|
||||
return None, None
|
||||
trace_override = parent_ctx.get("parent_workflow_run_id")
|
||||
parent_span = parent_ctx.get("parent_node_execution_id")
|
||||
return (
|
||||
trace_override if isinstance(trace_override, str) else None,
|
||||
parent_span if isinstance(parent_span, str) else None,
|
||||
context.parent_workflow_run_id,
|
||||
context.parent_node_execution_id,
|
||||
)
|
||||
|
||||
@field_serializer("start_time", "end_time")
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Core exceptions shared by ops trace dispatchers and trace providers.
|
||||
|
||||
Provider packages may raise these types to request generic task behavior, but
|
||||
generic Celery tasks should not import provider-specific exception classes.
|
||||
"""
|
||||
|
||||
|
||||
class RetryableTraceDispatchError(RuntimeError):
|
||||
"""Base class for transient trace dispatch failures that Celery may retry."""
|
||||
|
||||
|
||||
class PendingTraceParentContextError(RetryableTraceDispatchError):
|
||||
"""Raised when a nested trace arrives before its parent span context is available."""
|
||||
|
||||
parent_node_execution_id: str
|
||||
|
||||
def __init__(self, parent_node_execution_id: str) -> None:
|
||||
self.parent_node_execution_id = parent_node_execution_id
|
||||
super().__init__(
|
||||
"Pending trace parent context for parent_node_execution_id="
|
||||
f"{parent_node_execution_id}. Retry after the parent span context is published."
|
||||
)
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.helper.encrypter import batch_decrypt_token, encrypt_token, obfuscated_token
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.ops.entities.config_entity import (
|
||||
OPS_FILE_PATH,
|
||||
BaseTracingConfig,
|
||||
@@ -52,6 +53,17 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dump_parent_trace_context(parent_trace_context: Any) -> dict[str, str] | None:
|
||||
if isinstance(parent_trace_context, ParentTraceContext):
|
||||
return parent_trace_context.model_dump(exclude_none=True)
|
||||
if isinstance(parent_trace_context, dict):
|
||||
try:
|
||||
return ParentTraceContext.model_validate(parent_trace_context).model_dump(exclude_none=True)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
class _AppTracingConfig(TypedDict, total=False):
|
||||
enabled: bool
|
||||
tracing_provider: str | None
|
||||
@@ -857,8 +869,9 @@ class TraceTask:
|
||||
}
|
||||
|
||||
parent_trace_context = self.kwargs.get("parent_trace_context")
|
||||
if parent_trace_context:
|
||||
metadata["parent_trace_context"] = parent_trace_context
|
||||
dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context)
|
||||
if dumped_parent_trace_context:
|
||||
metadata["parent_trace_context"] = dumped_parent_trace_context
|
||||
|
||||
workflow_trace_info = WorkflowTraceInfo(
|
||||
trace_id=self.trace_id,
|
||||
@@ -1371,13 +1384,14 @@ class TraceTask:
|
||||
}
|
||||
|
||||
parent_trace_context = node_data.get("parent_trace_context")
|
||||
if parent_trace_context:
|
||||
metadata["parent_trace_context"] = parent_trace_context
|
||||
dumped_parent_trace_context = _dump_parent_trace_context(parent_trace_context)
|
||||
if dumped_parent_trace_context:
|
||||
metadata["parent_trace_context"] = dumped_parent_trace_context
|
||||
|
||||
message_id: str | None = None
|
||||
conversation_id = node_data.get("conversation_id")
|
||||
workflow_execution_id = node_data.get("workflow_execution_id")
|
||||
if conversation_id and workflow_execution_id and not parent_trace_context:
|
||||
if conversation_id and workflow_execution_id and not dumped_parent_trace_context:
|
||||
with Session(db.engine) as session:
|
||||
msg_id = session.scalar(
|
||||
select(Message.id).where(
|
||||
|
||||
@@ -161,35 +161,39 @@ class AdvancedPromptTransform(PromptTransform):
|
||||
prompt_messages: list[PromptMessage] = []
|
||||
for prompt_item in prompt_template:
|
||||
raw_prompt = prompt_item.text
|
||||
|
||||
if prompt_item.edition_type == "basic" or not prompt_item.edition_type:
|
||||
if self.with_variable_tmpl:
|
||||
vp = VariablePool.empty()
|
||||
for k, v in inputs.items():
|
||||
if k.startswith("#"):
|
||||
vp.add(k[1:-1].split("."), v)
|
||||
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
|
||||
prompt = vp.convert_template(raw_prompt).text
|
||||
else:
|
||||
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
|
||||
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
|
||||
prompt_inputs = self._set_context_variable(
|
||||
context=context, parser=parser, prompt_inputs=prompt_inputs
|
||||
)
|
||||
prompt = parser.format(prompt_inputs)
|
||||
elif prompt_item.edition_type == "jinja2":
|
||||
prompt = raw_prompt
|
||||
prompt_inputs = inputs
|
||||
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
|
||||
else:
|
||||
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
|
||||
|
||||
if prompt_item.role == PromptMessageRole.USER:
|
||||
prompt_messages.append(UserPromptMessage(content=prompt))
|
||||
elif prompt_item.role == PromptMessageRole.SYSTEM and prompt:
|
||||
prompt_messages.append(SystemPromptMessage(content=prompt))
|
||||
elif prompt_item.role == PromptMessageRole.ASSISTANT:
|
||||
prompt_messages.append(AssistantPromptMessage(content=prompt))
|
||||
edition_type = prompt_item.edition_type or "basic"
|
||||
match edition_type:
|
||||
case "basic":
|
||||
if self.with_variable_tmpl:
|
||||
vp = VariablePool.empty()
|
||||
for k, v in inputs.items():
|
||||
if k.startswith("#"):
|
||||
vp.add(k[1:-1].split("."), v)
|
||||
raw_prompt = raw_prompt.replace("{{#context#}}", context or "")
|
||||
prompt = vp.convert_template(raw_prompt).text
|
||||
else:
|
||||
parser = PromptTemplateParser(template=raw_prompt, with_variable_tmpl=self.with_variable_tmpl)
|
||||
prompt_inputs: Mapping[str, str] = {k: inputs[k] for k in parser.variable_keys if k in inputs}
|
||||
prompt_inputs = self._set_context_variable(
|
||||
context=context, parser=parser, prompt_inputs=prompt_inputs
|
||||
)
|
||||
prompt = parser.format(prompt_inputs)
|
||||
case "jinja2":
|
||||
prompt = raw_prompt
|
||||
prompt_inputs = inputs
|
||||
prompt = Jinja2Formatter.format(template=prompt, inputs=prompt_inputs)
|
||||
case _:
|
||||
raise ValueError(f"Invalid edition type: {prompt_item.edition_type}")
|
||||
match prompt_item.role:
|
||||
case PromptMessageRole.USER:
|
||||
prompt_messages.append(UserPromptMessage(content=prompt))
|
||||
case PromptMessageRole.SYSTEM:
|
||||
if prompt:
|
||||
prompt_messages.append(SystemPromptMessage(content=prompt))
|
||||
case PromptMessageRole.ASSISTANT:
|
||||
prompt_messages.append(AssistantPromptMessage(content=prompt))
|
||||
case PromptMessageRole.TOOL:
|
||||
pass
|
||||
|
||||
if query and memory_config and memory_config.query_prompt_template:
|
||||
parser = PromptTemplateParser(
|
||||
|
||||
@@ -123,12 +123,15 @@ class SimplePromptTransform(PromptTransform):
|
||||
|
||||
for v in special_variable_keys:
|
||||
# support #context#, #query# and #histories#
|
||||
if v == "#context#":
|
||||
variables["#context#"] = context or ""
|
||||
elif v == "#query#":
|
||||
variables["#query#"] = query or ""
|
||||
elif v == "#histories#":
|
||||
variables["#histories#"] = histories or ""
|
||||
match v:
|
||||
case "#context#":
|
||||
variables["#context#"] = context or ""
|
||||
case "#query#":
|
||||
variables["#query#"] = query or ""
|
||||
case "#histories#":
|
||||
variables["#histories#"] = histories or ""
|
||||
case _:
|
||||
pass
|
||||
|
||||
prompt_template = prompt_template_config["prompt_template"]
|
||||
if not isinstance(prompt_template, PromptTemplateParser):
|
||||
|
||||
@@ -245,6 +245,7 @@ class Jieba(BaseKeyword):
|
||||
segment = pre_segment_data["segment"]
|
||||
if pre_segment_data["keywords"]:
|
||||
segment.keywords = pre_segment_data["keywords"]
|
||||
assert segment.index_node_id
|
||||
keyword_table = self._add_text_to_keyword_table(
|
||||
keyword_table or {}, segment.index_node_id, pre_segment_data["keywords"]
|
||||
)
|
||||
@@ -253,6 +254,7 @@ class Jieba(BaseKeyword):
|
||||
|
||||
keywords = keyword_table_handler.extract_keywords(segment.content, keyword_number)
|
||||
segment.keywords = list(keywords)
|
||||
assert segment.index_node_id
|
||||
keyword_table = self._add_text_to_keyword_table(
|
||||
keyword_table or {}, segment.index_node_id, list(keywords)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
@@ -526,7 +527,7 @@ class RetrievalService:
|
||||
index_node_ids = [i for i in index_node_ids if i]
|
||||
|
||||
segment_ids: list[str] = []
|
||||
index_node_segments: list[DocumentSegment] = []
|
||||
index_node_segments: Sequence[DocumentSegment] = []
|
||||
segments: list[DocumentSegment] = []
|
||||
attachment_map: dict[str, list[AttachmentInfoDict]] = {}
|
||||
child_chunk_map: dict[str, list[ChildChunk]] = {}
|
||||
@@ -568,8 +569,9 @@ class RetrievalService:
|
||||
DocumentSegment.status == "completed",
|
||||
DocumentSegment.index_node_id.in_(index_node_ids),
|
||||
)
|
||||
index_node_segments = session.execute(document_segment_stmt).scalars().all() # type: ignore
|
||||
index_node_segments = session.execute(document_segment_stmt).scalars().all()
|
||||
for index_node_segment in index_node_segments:
|
||||
assert index_node_segment.index_node_id
|
||||
doc_segment_map[index_node_segment.id] = [index_node_segment.index_node_id]
|
||||
|
||||
if segment_ids:
|
||||
|
||||
@@ -50,6 +50,7 @@ class DatasetDocumentStore:
|
||||
|
||||
output = {}
|
||||
for document_segment in document_segments:
|
||||
assert document_segment.index_node_id
|
||||
doc_id = document_segment.index_node_id
|
||||
output[doc_id] = Document(
|
||||
page_content=document_segment.content,
|
||||
@@ -103,7 +104,7 @@ class DatasetDocumentStore:
|
||||
|
||||
if not segment_document:
|
||||
max_position += 1
|
||||
|
||||
assert self._document_id
|
||||
segment_document = DocumentSegment(
|
||||
tenant_id=self._dataset.tenant_id,
|
||||
dataset_id=self._dataset.id,
|
||||
|
||||
@@ -183,34 +183,35 @@ class ExtractProcessor:
|
||||
return extractor.extract()
|
||||
elif extract_setting.datasource_type == DatasourceType.WEBSITE:
|
||||
assert extract_setting.website_info is not None, "website_info is required"
|
||||
if extract_setting.website_info.provider == "firecrawl":
|
||||
extractor = FirecrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
elif extract_setting.website_info.provider == "watercrawl":
|
||||
extractor = WaterCrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
elif extract_setting.website_info.provider == "jinareader":
|
||||
extractor = JinaReaderWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
else:
|
||||
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
|
||||
match extract_setting.website_info.provider:
|
||||
case "firecrawl":
|
||||
extractor = FirecrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case "watercrawl":
|
||||
extractor = WaterCrawlWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case "jinareader":
|
||||
extractor = JinaReaderWebExtractor(
|
||||
url=extract_setting.website_info.url,
|
||||
job_id=extract_setting.website_info.job_id,
|
||||
tenant_id=extract_setting.website_info.tenant_id,
|
||||
mode=extract_setting.website_info.mode,
|
||||
only_main_content=extract_setting.website_info.only_main_content,
|
||||
)
|
||||
return extractor.extract()
|
||||
case _:
|
||||
raise ValueError(f"Unsupported website provider: {extract_setting.website_info.provider}")
|
||||
else:
|
||||
raise ValueError(f"Unsupported datasource type: {extract_setting.datasource_type}")
|
||||
|
||||
@@ -19,7 +19,7 @@ class UnstructuredWordExtractor(BaseExtractor):
|
||||
|
||||
def extract(self) -> list[Document]:
|
||||
from unstructured.__version__ import __version__ as __unstructured_version__
|
||||
from unstructured.file_utils.filetype import ( # pyright: ignore[reportPrivateImportUsage]
|
||||
from unstructured.file_utils.filetype import (
|
||||
FileType,
|
||||
detect_filetype,
|
||||
)
|
||||
@@ -27,7 +27,7 @@ class UnstructuredWordExtractor(BaseExtractor):
|
||||
unstructured_version = tuple(int(x) for x in __unstructured_version__.split("."))
|
||||
# check the file extension
|
||||
try:
|
||||
import magic # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
import magic # noqa: F401
|
||||
|
||||
is_doc = detect_filetype(self._file_path) == FileType.DOC
|
||||
except ImportError:
|
||||
|
||||
@@ -84,7 +84,7 @@ class IndexProcessor:
|
||||
select(DocumentSegment).where(DocumentSegment.document_id == original_document_id)
|
||||
).all()
|
||||
if segments:
|
||||
index_node_ids = [segment.index_node_id for segment in segments]
|
||||
index_node_ids = [segment.index_node_id for segment in segments if segment.index_node_id]
|
||||
|
||||
indexing_start_at = time.perf_counter()
|
||||
# delete from vector index
|
||||
|
||||
@@ -29,6 +29,7 @@ from libs import helper
|
||||
from models import Account
|
||||
from models.dataset import ChildChunk, Dataset, DatasetProcessRule, DocumentSegment
|
||||
from models.dataset import Document as DatasetDocument
|
||||
from models.enums import ProcessRuleMode
|
||||
from services.account_service import AccountService
|
||||
from services.summary_index_service import SummaryIndexService
|
||||
|
||||
@@ -325,7 +326,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
|
||||
# update document parent mode
|
||||
dataset_process_rule = DatasetProcessRule(
|
||||
dataset_id=dataset.id,
|
||||
mode="hierarchical",
|
||||
mode=ProcessRuleMode.HIERARCHICAL,
|
||||
rules=json.dumps(
|
||||
{
|
||||
"parent_mode": parent_childs.parent_mode,
|
||||
|
||||
@@ -28,10 +28,10 @@ class FunctionCallMultiDatasetRouter:
|
||||
SystemPromptMessage(content="You are a helpful AI assistant."),
|
||||
UserPromptMessage(content=query),
|
||||
]
|
||||
result: LLMResult = model_instance.invoke_llm( # pyright: ignore[reportCallIssue, reportArgumentType]
|
||||
result: LLMResult = model_instance.invoke_llm( # pyrefly: ignore[no-matching-overload]
|
||||
prompt_messages=prompt_messages,
|
||||
tools=dataset_tools,
|
||||
stream=False, # pyright: ignore[reportArgumentType]
|
||||
stream=False,
|
||||
model_parameters={"temperature": 0.2, "top_p": 0.3, "max_tokens": 1500},
|
||||
)
|
||||
usage = result.usage or LLMUsage.empty_usage()
|
||||
|
||||
@@ -8,6 +8,10 @@ import urllib.parse
|
||||
from configs import dify_config
|
||||
|
||||
|
||||
def _secret_key() -> bytes:
|
||||
return dify_config.SECRET_KEY.encode()
|
||||
|
||||
|
||||
def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True) -> str:
|
||||
"""
|
||||
sign file to get a temporary url for plugin access
|
||||
@@ -19,8 +23,7 @@ def sign_tool_file(tool_file_id: str, extension: str, for_external: bool = True)
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
@@ -39,8 +42,7 @@ def sign_upload_file_preview_url(upload_file_id: str, extension: str) -> str:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
@@ -51,8 +53,7 @@ def verify_tool_file_signature(file_id: str, timestamp: str, nonce: str, sign: s
|
||||
verify signature
|
||||
"""
|
||||
data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||
|
||||
# verify signature
|
||||
@@ -71,8 +72,7 @@ def get_signed_file_url_for_plugin(filename: str, mimetype: str, tenant_id: str,
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
@@ -92,8 +92,7 @@ def verify_plugin_file_signature(
|
||||
"""Verify the signature used by the plugin-facing file upload endpoint."""
|
||||
|
||||
data_to_sign = f"upload|{filename}|{mimetype}|{tenant_id}|{user_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_sign = hmac.new(_secret_key(), data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||
|
||||
if sign != recalculated_encoded_sign:
|
||||
|
||||
@@ -51,8 +51,11 @@ class ToolFileManager:
|
||||
timestamp = str(int(time.time()))
|
||||
nonce = os.urandom(16).hex()
|
||||
data_to_sign = f"file-preview|{tool_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
sign = hmac.new(
|
||||
dify_config.SECRET_KEY.encode(),
|
||||
data_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
return f"{file_preview_url}?timestamp={timestamp}&nonce={nonce}&sign={encoded_sign}"
|
||||
@@ -63,8 +66,11 @@ class ToolFileManager:
|
||||
verify signature
|
||||
"""
|
||||
data_to_sign = f"file-preview|{file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
recalculated_sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
recalculated_sign = hmac.new(
|
||||
dify_config.SECRET_KEY.encode(),
|
||||
data_to_sign.encode(),
|
||||
hashlib.sha256,
|
||||
).digest()
|
||||
recalculated_encoded_sign = base64.urlsafe_b64encode(recalculated_sign).decode()
|
||||
|
||||
# verify signature
|
||||
|
||||
@@ -264,9 +264,9 @@ class ToolManager:
|
||||
if builtin_provider is None:
|
||||
raise ToolProviderNotFoundError(f"builtin provider {provider_id} not found")
|
||||
|
||||
from core.helper.credential_utils import check_credential_policy_compliance
|
||||
from core.helper.credential_utils import runtime_check_credential_policy_compliance
|
||||
|
||||
check_credential_policy_compliance(
|
||||
runtime_check_credential_policy_compliance(
|
||||
credential_id=builtin_provider.id,
|
||||
provider=provider_id,
|
||||
credential_type=PluginCredentialType.TOOL,
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.db.session_factory import session_factory
|
||||
from core.helper.trace_id_helper import ParentTraceContext, extract_parent_trace_context_from_args
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.entities.tool_entities import (
|
||||
@@ -36,6 +37,8 @@ class WorkflowTool(Tool):
|
||||
Workflow tool.
|
||||
"""
|
||||
|
||||
_parent_trace_context: ParentTraceContext | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workflow_app_id: str,
|
||||
@@ -54,6 +57,7 @@ class WorkflowTool(Tool):
|
||||
self.workflow_call_depth = workflow_call_depth
|
||||
self.label = label
|
||||
self._latest_usage = LLMUsage.empty_usage()
|
||||
self._parent_trace_context = None
|
||||
|
||||
super().__init__(entity=entity, runtime=runtime)
|
||||
|
||||
@@ -94,11 +98,17 @@ class WorkflowTool(Tool):
|
||||
|
||||
self._latest_usage = LLMUsage.empty_usage()
|
||||
|
||||
generator_args: dict[str, Any] = {"inputs": tool_parameters, "files": files}
|
||||
if self._parent_trace_context:
|
||||
generator_args.update(
|
||||
extract_parent_trace_context_from_args({"parent_trace_context": self._parent_trace_context})
|
||||
)
|
||||
|
||||
result = generator.generate(
|
||||
app_model=app,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args={"inputs": tool_parameters, "files": files},
|
||||
args=generator_args,
|
||||
invoke_from=self.runtime.invoke_from,
|
||||
streaming=False,
|
||||
call_depth=self.workflow_call_depth + 1,
|
||||
@@ -194,7 +204,7 @@ class WorkflowTool(Tool):
|
||||
|
||||
:return: the new tool
|
||||
"""
|
||||
return self.__class__(
|
||||
forked = self.__class__(
|
||||
entity=self.entity.model_copy(),
|
||||
runtime=runtime,
|
||||
workflow_app_id=self.workflow_app_id,
|
||||
@@ -204,6 +214,24 @@ class WorkflowTool(Tool):
|
||||
version=self.version,
|
||||
label=self.label,
|
||||
)
|
||||
forked._parent_trace_context = self._parent_trace_context.model_copy() if self._parent_trace_context else None
|
||||
return forked
|
||||
|
||||
def set_parent_trace_context(
|
||||
self,
|
||||
*,
|
||||
parent_workflow_run_id: str,
|
||||
parent_node_execution_id: str,
|
||||
) -> None:
|
||||
"""Attach outer workflow trace context without exposing it as tool input."""
|
||||
self._parent_trace_context = ParentTraceContext(
|
||||
parent_workflow_run_id=parent_workflow_run_id,
|
||||
parent_node_execution_id=parent_node_execution_id,
|
||||
)
|
||||
|
||||
def clear_parent_trace_context(self) -> None:
|
||||
"""Remove parent trace context before invoking this tool outside a nested workflow."""
|
||||
self._parent_trace_context = None
|
||||
|
||||
def _resolve_user(self, user_id: str) -> Account | EndUser | None:
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from sqlalchemy.orm import Session
|
||||
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler
|
||||
from core.helper.trace_id_helper import ParentTraceContext
|
||||
from core.llm_generator.output_parser.errors import OutputParserError
|
||||
from core.llm_generator.output_parser.structured_output import invoke_llm_with_structured_output
|
||||
from core.model_manager import ModelInstance
|
||||
@@ -358,6 +359,7 @@ class _WorkflowToolRuntimeBinding:
|
||||
|
||||
tool: Tool
|
||||
conversation_id: str | None = None
|
||||
parent_trace_context: ParentTraceContext | None = None
|
||||
|
||||
|
||||
class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
@@ -378,6 +380,7 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
node_id: str,
|
||||
node_data: ToolNodeData,
|
||||
variable_pool,
|
||||
node_execution_id: str | None = None,
|
||||
) -> ToolRuntimeHandle:
|
||||
try:
|
||||
tool_runtime = ToolManager.get_workflow_tool_runtime(
|
||||
@@ -397,7 +400,25 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
conversation_id = (
|
||||
None if variable_pool is None else get_system_text(variable_pool, SystemVariableKey.CONVERSATION_ID)
|
||||
)
|
||||
return ToolRuntimeHandle(raw=_WorkflowToolRuntimeBinding(tool=tool_runtime, conversation_id=conversation_id))
|
||||
parent_trace_context: ParentTraceContext | None = None
|
||||
if self._is_workflow_tool_provider(node_data):
|
||||
outer_workflow_run_id = (
|
||||
None
|
||||
if variable_pool is None
|
||||
else get_system_text(variable_pool, SystemVariableKey.WORKFLOW_EXECUTION_ID)
|
||||
)
|
||||
if isinstance(outer_workflow_run_id, str) and isinstance(node_execution_id, str):
|
||||
parent_trace_context = ParentTraceContext(
|
||||
parent_workflow_run_id=outer_workflow_run_id,
|
||||
parent_node_execution_id=node_execution_id,
|
||||
)
|
||||
return ToolRuntimeHandle(
|
||||
raw=_WorkflowToolRuntimeBinding(
|
||||
tool=tool_runtime,
|
||||
conversation_id=conversation_id,
|
||||
parent_trace_context=parent_trace_context,
|
||||
)
|
||||
)
|
||||
|
||||
def get_runtime_parameters(
|
||||
self,
|
||||
@@ -421,6 +442,13 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
runtime_binding = self._binding_from_handle(tool_runtime)
|
||||
tool = runtime_binding.tool
|
||||
callback = DifyWorkflowCallbackHandler()
|
||||
if runtime_binding.parent_trace_context and hasattr(tool, "set_parent_trace_context"):
|
||||
tool.set_parent_trace_context(
|
||||
parent_workflow_run_id=runtime_binding.parent_trace_context.parent_workflow_run_id,
|
||||
parent_node_execution_id=runtime_binding.parent_trace_context.parent_node_execution_id,
|
||||
)
|
||||
elif hasattr(tool, "clear_parent_trace_context"):
|
||||
tool.clear_parent_trace_context()
|
||||
|
||||
try:
|
||||
messages = ToolEngine.generic_invoke(
|
||||
@@ -513,6 +541,10 @@ class DifyToolNodeRuntime(ToolNodeRuntimeProtocol):
|
||||
credential_id=node_data.credential_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _is_workflow_tool_provider(node_data: ToolNodeData) -> bool:
|
||||
return node_data.provider_type.value == CoreToolProviderType.WORKFLOW.value
|
||||
|
||||
def _adapt_messages(
|
||||
self,
|
||||
messages: Generator[CoreToolInvokeMessage, None, None],
|
||||
|
||||
@@ -5,19 +5,20 @@ import threading
|
||||
from flask import Response
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.console.admin import admin_required
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
@app.after_request
|
||||
def after_request(response): # pyright: ignore[reportUnusedFunction]
|
||||
def after_request(response):
|
||||
"""Add Version headers to the response."""
|
||||
response.headers.add("X-Version", dify_config.project.version)
|
||||
response.headers.add("X-Env", dify_config.DEPLOY_ENV)
|
||||
return response
|
||||
|
||||
@app.route("/health")
|
||||
def health(): # pyright: ignore[reportUnusedFunction]
|
||||
def health():
|
||||
return Response(
|
||||
json.dumps({"pid": os.getpid(), "status": "ok", "version": dify_config.project.version}),
|
||||
status=200,
|
||||
@@ -25,7 +26,8 @@ def init_app(app: DifyApp):
|
||||
)
|
||||
|
||||
@app.route("/threads")
|
||||
def threads(): # pyright: ignore[reportUnusedFunction]
|
||||
@admin_required
|
||||
def threads():
|
||||
num_threads = threading.active_count()
|
||||
threads = threading.enumerate()
|
||||
|
||||
@@ -50,7 +52,8 @@ def init_app(app: DifyApp):
|
||||
}
|
||||
|
||||
@app.route("/db-pool-stat")
|
||||
def pool_stat(): # pyright: ignore[reportUnusedFunction]
|
||||
@admin_required
|
||||
def pool_stat():
|
||||
from extensions.ext_database import db
|
||||
|
||||
engine = db.engine
|
||||
|
||||
@@ -33,7 +33,7 @@ def _setup_gevent_compatibility():
|
||||
return
|
||||
|
||||
@event.listens_for(Pool, "reset")
|
||||
def _safe_reset(dbapi_connection, connection_record, reset_state): # pyright: ignore[reportUnusedFunction]
|
||||
def _safe_reset(dbapi_connection, connection_record, reset_state):
|
||||
if reset_state.terminate_only:
|
||||
return
|
||||
|
||||
|
||||
@@ -2,4 +2,4 @@ from dify_app import DifyApp
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
from events import event_handlers # noqa: F401 # pyright: ignore[reportUnusedImport]
|
||||
from events import event_handlers # noqa: F401
|
||||
|
||||
@@ -67,7 +67,7 @@ class Mail:
|
||||
case _:
|
||||
raise ValueError(f"Unsupported mail type {mail_type}")
|
||||
|
||||
def send(self, to: str, subject: str, html: str, from_: str | None = None):
|
||||
def send(self, to: str, subject: str, html: str, from_: str = ""):
|
||||
if not self._client:
|
||||
raise ValueError("Mail client is not initialized")
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
from configs import dify_config
|
||||
from configs.secret_key import resolve_secret_key
|
||||
from dify_app import DifyApp
|
||||
|
||||
|
||||
def init_app(app: DifyApp):
|
||||
app.secret_key = dify_config.SECRET_KEY
|
||||
def init_app(app: DifyApp) -> None:
|
||||
"""Resolve SECRET_KEY after config loading and before session/login setup."""
|
||||
secret_key = dify_config.SECRET_KEY
|
||||
if not secret_key:
|
||||
secret_key = resolve_secret_key(secret_key)
|
||||
dify_config.SECRET_KEY = secret_key
|
||||
app.config["SECRET_KEY"] = secret_key
|
||||
app.secret_key = secret_key
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from typing import Any, cast
|
||||
|
||||
import socketio # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
sio = socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS)
|
||||
# TODO: FIXME(chariri) - Casting to any because app_factory attaches the
|
||||
# current app as the `app` attribute on this - Bad.
|
||||
sio = cast(Any, socketio.Server(async_mode="gevent", cors_allowed_origins=dify_config.CONSOLE_CORS_ALLOW_ORIGINS))
|
||||
|
||||
@@ -198,7 +198,7 @@ class AliyunLogStore:
|
||||
)
|
||||
|
||||
# Append Dify identification to the existing user agent
|
||||
original_user_agent = self.client._user_agent # pyright: ignore[reportPrivateUsage]
|
||||
original_user_agent = self.client._user_agent
|
||||
dify_version = dify_config.project.version
|
||||
enhanced_user_agent = f"Dify,Dify-{dify_version},{original_user_agent}"
|
||||
self.client.set_user_agent(enhanced_user_agent)
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
"""Adapters from persisted message files to graph-layer file values."""
|
||||
"""Adapters from persisted message files to graph-layer file values.
|
||||
|
||||
Replay paths only: files in conversation history were validated at upload time,
|
||||
so these helpers deliberately do not accept (or forward) a ``FileUploadConfig`` —
|
||||
re-validation here would break replays whenever workflow ``file_upload`` config
|
||||
drifts between rounds. Mirrors ``build_file_from_stored_mapping`` in
|
||||
``models/utils/file_input_compat.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from core.app.file_access import FileAccessControllerProtocol
|
||||
from graphon.file import File, FileBelongsTo, FileTransferMethod, FileUploadConfig
|
||||
from graphon.file import File, FileBelongsTo, FileTransferMethod
|
||||
from models import MessageFile
|
||||
|
||||
from .builders import build_from_mapping
|
||||
@@ -15,14 +22,12 @@ def build_from_message_files(
|
||||
*,
|
||||
message_files: Sequence[MessageFile],
|
||||
tenant_id: str,
|
||||
config: FileUploadConfig | None = None,
|
||||
access_controller: FileAccessControllerProtocol,
|
||||
) -> Sequence[File]:
|
||||
return [
|
||||
build_from_message_file(
|
||||
message_file=message_file,
|
||||
tenant_id=tenant_id,
|
||||
config=config,
|
||||
access_controller=access_controller,
|
||||
)
|
||||
for message_file in message_files
|
||||
@@ -34,7 +39,6 @@ def build_from_message_file(
|
||||
*,
|
||||
message_file: MessageFile,
|
||||
tenant_id: str,
|
||||
config: FileUploadConfig | None,
|
||||
access_controller: FileAccessControllerProtocol,
|
||||
) -> File:
|
||||
mapping = {
|
||||
@@ -54,6 +58,5 @@ def build_from_message_file(
|
||||
return build_from_mapping(
|
||||
mapping=mapping,
|
||||
tenant_id=tenant_id,
|
||||
config=config,
|
||||
access_controller=access_controller,
|
||||
)
|
||||
|
||||
@@ -2,9 +2,25 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
from graphon.file import FileTransferMethod, FileType, FileUploadConfig
|
||||
|
||||
|
||||
def _normalize_extension(extension: str) -> str:
|
||||
s = extension.strip().lower()
|
||||
if not s:
|
||||
return ""
|
||||
return s if s.startswith(".") else "." + s
|
||||
|
||||
|
||||
def _extension_matches(extension: str, whitelist: Iterable[str]) -> bool:
|
||||
normalized = _normalize_extension(extension)
|
||||
if not normalized:
|
||||
return False
|
||||
return normalized in {_normalize_extension(e) for e in whitelist}
|
||||
|
||||
|
||||
def is_file_valid_with_config(
|
||||
*,
|
||||
input_file_type: str,
|
||||
@@ -12,22 +28,31 @@ def is_file_valid_with_config(
|
||||
file_transfer_method: FileTransferMethod,
|
||||
config: FileUploadConfig,
|
||||
) -> bool:
|
||||
# FIXME(QIN2DIM): Always allow tool files (files generated by the assistant/model)
|
||||
# These are internally generated and should bypass user upload restrictions
|
||||
"""Return whether the file is allowed by the upload config.
|
||||
|
||||
``allowed_file_types`` lists the buckets a file may fall into; ``CUSTOM`` is
|
||||
a fallback bucket gated by ``allowed_file_extensions`` (case- and
|
||||
dot-insensitive). Tool-generated files bypass user-facing config.
|
||||
"""
|
||||
if file_transfer_method == FileTransferMethod.TOOL_FILE:
|
||||
return True
|
||||
|
||||
if (
|
||||
config.allowed_file_types
|
||||
and input_file_type not in config.allowed_file_types
|
||||
and input_file_type != FileType.CUSTOM
|
||||
):
|
||||
allowed_types = config.allowed_file_types or []
|
||||
custom_allowed = FileType.CUSTOM in allowed_types
|
||||
type_allowed = not allowed_types or input_file_type in allowed_types
|
||||
|
||||
if not type_allowed and not custom_allowed:
|
||||
return False
|
||||
|
||||
# When the file is in the CUSTOM bucket, the extension whitelist is authoritative.
|
||||
# An explicitly set whitelist (including the empty list) is enforced; empty == deny —
|
||||
# the UI never submits an empty list, so this guards against DSL/API paths that
|
||||
# bypass the UI from accidentally widening the allowlist.
|
||||
in_custom_bucket = input_file_type == FileType.CUSTOM or not type_allowed
|
||||
if (
|
||||
input_file_type == FileType.CUSTOM
|
||||
in_custom_bucket
|
||||
and config.allowed_file_extensions is not None
|
||||
and file_extension not in config.allowed_file_extensions
|
||||
and not _extension_matches(file_extension, config.allowed_file_extensions)
|
||||
):
|
||||
return False
|
||||
|
||||
|
||||
+40
-25
@@ -1,19 +1,22 @@
|
||||
"""Workflow comment models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Index, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from models.base import TypeBase
|
||||
|
||||
from .account import Account
|
||||
from .base import Base, gen_uuidv7_string
|
||||
from .base import gen_uuidv7_string
|
||||
from .engine import db
|
||||
from .types import StringUUID
|
||||
|
||||
|
||||
class WorkflowComment(Base):
|
||||
class WorkflowComment(TypeBase):
|
||||
"""Workflow comment model for canvas commenting functionality.
|
||||
|
||||
Comments are associated with apps rather than specific workflow versions,
|
||||
@@ -42,27 +45,33 @@ class WorkflowComment(Base):
|
||||
Index("workflow_comments_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position_x: Mapped[float] = mapped_column(sa.Float)
|
||||
position_y: Mapped[float] = mapped_column(sa.Float)
|
||||
content: Mapped[str] = mapped_column(sa.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"))
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
|
||||
resolved_by: Mapped[str | None] = mapped_column(StringUUID)
|
||||
resolved_at: Mapped[datetime | None] = mapped_column(sa.DateTime, default=None)
|
||||
resolved_by: Mapped[str | None] = mapped_column(StringUUID, default=None)
|
||||
|
||||
resolved: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false"), default=False)
|
||||
# Relationships
|
||||
replies: Mapped[list["WorkflowCommentReply"]] = relationship(
|
||||
"WorkflowCommentReply", back_populates="comment", cascade="all, delete-orphan"
|
||||
replies: Mapped[list[WorkflowCommentReply]] = relationship(
|
||||
lambda: WorkflowCommentReply, back_populates="comment", cascade="all, delete-orphan", init=False
|
||||
)
|
||||
mentions: Mapped[list["WorkflowCommentMention"]] = relationship(
|
||||
"WorkflowCommentMention", back_populates="comment", cascade="all, delete-orphan"
|
||||
mentions: Mapped[list[WorkflowCommentMention]] = relationship(
|
||||
lambda: WorkflowCommentMention, back_populates="comment", cascade="all, delete-orphan", init=False
|
||||
)
|
||||
|
||||
@property
|
||||
@@ -131,7 +140,7 @@ class WorkflowComment(Base):
|
||||
return participants
|
||||
|
||||
|
||||
class WorkflowCommentReply(Base):
|
||||
class WorkflowCommentReply(TypeBase):
|
||||
"""Workflow comment reply model.
|
||||
|
||||
Attributes:
|
||||
@@ -149,18 +158,24 @@ class WorkflowCommentReply(Base):
|
||||
Index("comment_replies_created_at_idx", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
content: Mapped[str] = mapped_column(sa.Text, nullable=False)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp()
|
||||
sa.DateTime,
|
||||
nullable=False,
|
||||
server_default=func.current_timestamp(),
|
||||
onupdate=func.current_timestamp(),
|
||||
init=False,
|
||||
)
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="replies")
|
||||
comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="replies", init=False)
|
||||
|
||||
@property
|
||||
def created_by_account(self):
|
||||
@@ -174,7 +189,7 @@ class WorkflowCommentReply(Base):
|
||||
self._created_by_account_cache = account
|
||||
|
||||
|
||||
class WorkflowCommentMention(Base):
|
||||
class WorkflowCommentMention(TypeBase):
|
||||
"""Workflow comment mention model.
|
||||
|
||||
Mentions are only for internal accounts since end users
|
||||
@@ -194,18 +209,18 @@ class WorkflowCommentMention(Base):
|
||||
Index("comment_mentions_user_idx", "mentioned_user_id"),
|
||||
)
|
||||
|
||||
id: Mapped[str] = mapped_column(StringUUID, default=gen_uuidv7_string)
|
||||
id: Mapped[str] = mapped_column(StringUUID, default_factory=gen_uuidv7_string, init=False)
|
||||
comment_id: Mapped[str] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comments.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
reply_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True
|
||||
)
|
||||
mentioned_user_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
reply_id: Mapped[str | None] = mapped_column(
|
||||
StringUUID, sa.ForeignKey("workflow_comment_replies.id", ondelete="CASCADE"), nullable=True, default=None
|
||||
)
|
||||
|
||||
# Relationships
|
||||
comment: Mapped["WorkflowComment"] = relationship("WorkflowComment", back_populates="mentions")
|
||||
reply: Mapped[Optional["WorkflowCommentReply"]] = relationship("WorkflowCommentReply")
|
||||
comment: Mapped[WorkflowComment] = relationship(lambda: WorkflowComment, back_populates="mentions", init=False)
|
||||
reply: Mapped[WorkflowCommentReply | None] = relationship(lambda: WorkflowCommentReply, init=False)
|
||||
|
||||
@property
|
||||
def mentioned_user_account(self):
|
||||
|
||||
+48
-40
@@ -8,10 +8,9 @@ import os
|
||||
import pickle
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from json import JSONDecodeError
|
||||
from typing import Any, TypedDict, cast
|
||||
from typing import Any, ClassVar, TypedDict, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
@@ -441,23 +440,27 @@ class Dataset(Base):
|
||||
return f"{dify_config.VECTOR_INDEX_NAME_PREFIX}_{normalized_dataset_id}_Node"
|
||||
|
||||
|
||||
class DatasetProcessRule(Base): # bug
|
||||
class DatasetProcessRule(TypeBase):
|
||||
__tablename__ = "dataset_process_rules"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="dataset_process_rule_pkey"),
|
||||
sa.Index("dataset_process_rule_dataset_id_idx", "dataset_id"),
|
||||
)
|
||||
|
||||
id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4()))
|
||||
dataset_id = mapped_column(StringUUID, nullable=False)
|
||||
mode = mapped_column(EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'"))
|
||||
rules = mapped_column(LongText, nullable=True)
|
||||
created_by = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False)
|
||||
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
mode: Mapped[ProcessRuleMode] = mapped_column(
|
||||
EnumText(ProcessRuleMode, length=255), nullable=False, server_default=sa.text("'automatic'")
|
||||
)
|
||||
rules: Mapped[str | None] = mapped_column(LongText, nullable=True)
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
MODES = ["automatic", "custom", "hierarchical"]
|
||||
PRE_PROCESSING_RULES = ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"]
|
||||
AUTOMATIC_RULES: AutomaticRulesConfig = {
|
||||
AUTOMATIC_RULES: ClassVar[AutomaticRulesConfig] = {
|
||||
"pre_processing_rules": [
|
||||
{"id": "remove_extra_spaces", "enabled": True},
|
||||
{"id": "remove_urls_emails", "enabled": False},
|
||||
@@ -827,7 +830,7 @@ class Document(Base):
|
||||
)
|
||||
|
||||
|
||||
class DocumentSegment(Base):
|
||||
class DocumentSegment(TypeBase):
|
||||
__tablename__ = "document_segments"
|
||||
__table_args__ = (
|
||||
sa.PrimaryKeyConstraint("id", name="document_segment_pkey"),
|
||||
@@ -840,35 +843,40 @@ class DocumentSegment(Base):
|
||||
)
|
||||
|
||||
# initial fields
|
||||
id = mapped_column(StringUUID, nullable=False, default=lambda: str(uuid4()))
|
||||
tenant_id = mapped_column(StringUUID, nullable=False)
|
||||
dataset_id = mapped_column(StringUUID, nullable=False)
|
||||
document_id = mapped_column(StringUUID, nullable=False)
|
||||
id: Mapped[str] = mapped_column(StringUUID, nullable=False, default_factory=lambda: str(uuid4()), init=False)
|
||||
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
dataset_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
document_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
position: Mapped[int]
|
||||
content = mapped_column(LongText, nullable=False)
|
||||
answer = mapped_column(LongText, nullable=True)
|
||||
content: Mapped[str] = mapped_column(LongText, nullable=False)
|
||||
word_count: Mapped[int]
|
||||
tokens: Mapped[int]
|
||||
|
||||
# indexing fields
|
||||
keywords = mapped_column(sa.JSON, nullable=True)
|
||||
index_node_id = mapped_column(String(255), nullable=True)
|
||||
index_node_hash = mapped_column(String(255), nullable=True)
|
||||
|
||||
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||
# basic fields
|
||||
# indexing fields
|
||||
index_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
index_node_hash: Mapped[str | None] = mapped_column(String(255), nullable=True, default=None)
|
||||
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"), default=True)
|
||||
answer: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
keywords: Mapped[Any] = mapped_column(sa.JSON, nullable=True, default=None)
|
||||
disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
disabled_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
status: Mapped[SegmentStatus] = mapped_column(
|
||||
EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'"), default=SegmentStatus.WAITING
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True, default=None)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
error: Mapped[str | None] = mapped_column(LongText, nullable=True, default=None)
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, default=None)
|
||||
hit_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=0)
|
||||
enabled: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
|
||||
disabled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
disabled_by = mapped_column(StringUUID, nullable=True)
|
||||
status: Mapped[str] = mapped_column(EnumText(SegmentStatus, length=255), server_default=sa.text("'waiting'"))
|
||||
created_by = mapped_column(StringUUID, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
updated_by = mapped_column(StringUUID, nullable=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp())
|
||||
indexing_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
error = mapped_column(LongText, nullable=True)
|
||||
stopped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
@property
|
||||
def dataset(self):
|
||||
@@ -895,7 +903,7 @@ class DocumentSegment(Base):
|
||||
)
|
||||
|
||||
@property
|
||||
def child_chunks(self) -> Sequence[Any]:
|
||||
def child_chunks(self):
|
||||
if not self.document:
|
||||
return []
|
||||
process_rule = self.document.dataset_process_rule
|
||||
@@ -910,7 +918,7 @@ class DocumentSegment(Base):
|
||||
return child_chunks or []
|
||||
return []
|
||||
|
||||
def get_child_chunks(self) -> Sequence[Any]:
|
||||
def get_child_chunks(self):
|
||||
if not self.document:
|
||||
return []
|
||||
process_rule = self.document.dataset_process_rule
|
||||
@@ -941,7 +949,7 @@ class DocumentSegment(Base):
|
||||
nonce = os.urandom(16).hex()
|
||||
timestamp = str(int(time.time()))
|
||||
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
secret_key = dify_config.SECRET_KEY.encode()
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
@@ -958,7 +966,7 @@ class DocumentSegment(Base):
|
||||
nonce = os.urandom(16).hex()
|
||||
timestamp = str(int(time.time()))
|
||||
data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
secret_key = dify_config.SECRET_KEY.encode()
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
@@ -977,7 +985,7 @@ class DocumentSegment(Base):
|
||||
nonce = os.urandom(16).hex()
|
||||
timestamp = str(int(time.time()))
|
||||
data_to_sign = f"file-preview|{upload_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
secret_key = dify_config.SECRET_KEY.encode()
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
@@ -1015,7 +1023,7 @@ class DocumentSegment(Base):
|
||||
nonce = os.urandom(16).hex()
|
||||
timestamp = str(int(time.time()))
|
||||
data_to_sign = f"image-preview|{upload_file_id}|{timestamp}|{nonce}"
|
||||
secret_key = dify_config.SECRET_KEY.encode() if dify_config.SECRET_KEY else b""
|
||||
secret_key = dify_config.SECRET_KEY.encode()
|
||||
sign = hmac.new(secret_key, data_to_sign.encode(), hashlib.sha256).digest()
|
||||
encoded_sign = base64.urlsafe_b64encode(sign).decode()
|
||||
|
||||
|
||||
@@ -340,116 +340,6 @@ Check if activation token is valid
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | [ActivationCheckResponse](#activationcheckresponse) |
|
||||
|
||||
### /admin/batch_add_notification_accounts
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Register target accounts for a notification by email address. JSON body: {"notification_id": "...", "user_email": ["a@example.com", ...]}. File upload: multipart/form-data with a 'file' field (CSV or TXT, one email per line) plus a 'notification_id' field. Emails that do not match any account are silently skipped.
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Accounts added successfully |
|
||||
|
||||
### /admin/delete-explore-banner/{banner_id}
|
||||
|
||||
#### DELETE
|
||||
##### Description
|
||||
|
||||
Delete an explore banner
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| banner_id | path | Banner ID to delete | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 204 | Banner deleted successfully |
|
||||
|
||||
### /admin/insert-explore-apps
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Insert or update an app in the explore list
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [InsertExploreAppPayload](#insertexploreapppayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | App updated successfully |
|
||||
| 201 | App inserted successfully |
|
||||
| 404 | App not found |
|
||||
|
||||
### /admin/insert-explore-apps/{app_id}
|
||||
|
||||
#### DELETE
|
||||
##### Description
|
||||
|
||||
Remove an app from the explore list
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | Application ID to remove | Yes | string |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 204 | App removed successfully |
|
||||
|
||||
### /admin/insert-explore-banner
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Insert an explore banner
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [InsertExploreBannerPayload](#insertexplorebannerpayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 201 | Banner inserted successfully |
|
||||
|
||||
### /admin/upsert_notification
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Create or update an in-product notification. Supply notification_id to update an existing one; omit it to create a new one. Pass at least one language variant in contents (zh / en / jp).
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [UpsertNotificationPayload](#upsertnotificationpayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Notification upserted successfully |
|
||||
|
||||
### /all-workspaces
|
||||
|
||||
#### GET
|
||||
@@ -10731,13 +10621,6 @@ AppMCPServer Status Enum
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| text | string | Transcribed text from audio | Yes |
|
||||
|
||||
#### BatchAddNotificationAccountsPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| notification_id | string | | Yes |
|
||||
| user_email | [ string ] | List of account email addresses | Yes |
|
||||
|
||||
#### BatchImportPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12233,33 +12116,6 @@ Form input types.
|
||||
| model_type | [ModelType](#modeltype) | | Yes |
|
||||
| provider | | | No |
|
||||
|
||||
#### InsertExploreAppPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | Yes |
|
||||
| can_trial | boolean | | No |
|
||||
| category | string | | Yes |
|
||||
| copyright | | | No |
|
||||
| custom_disclaimer | | | No |
|
||||
| desc | | | No |
|
||||
| language | string | | Yes |
|
||||
| position | integer | | Yes |
|
||||
| privacy_policy | | | No |
|
||||
| trial_limit | integer | | No |
|
||||
|
||||
#### InsertExploreBannerPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| category | string | | Yes |
|
||||
| description | string | | Yes |
|
||||
| img-src | string | | Yes |
|
||||
| language | string | | No |
|
||||
| link | string | | Yes |
|
||||
| sort | integer | | Yes |
|
||||
| title | string | | Yes |
|
||||
|
||||
#### InstallPermission
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -12370,16 +12226,6 @@ Enum class for large language model mode.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| LLMMode | string | Enum class for large language model mode. | |
|
||||
|
||||
#### LangContentPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| body | string | | Yes |
|
||||
| lang | string | Language tag: 'zh' \| 'en' \| 'jp' | Yes |
|
||||
| subtitle | | | No |
|
||||
| title | string | | Yes |
|
||||
| title_pic_url | | | No |
|
||||
|
||||
#### LegacyEndpointUpdatePayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13786,6 +13632,14 @@ Tag type
|
||||
| unit | string | | No |
|
||||
| variable | string | | No |
|
||||
|
||||
#### TrialSimpleAccount
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | No |
|
||||
| id | string | | No |
|
||||
| name | string | | No |
|
||||
|
||||
#### TrialSite
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -13829,7 +13683,7 @@ Tag type
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| conversation_variables | [ [TrialConversationVariable](#trialconversationvariable) ] | | No |
|
||||
| created_at | object | | No |
|
||||
| created_by | [SimpleAccount](#simpleaccount) | | No |
|
||||
| created_by | [TrialSimpleAccount](#trialsimpleaccount) | | No |
|
||||
| environment_variables | [ object ] | | No |
|
||||
| features | object | | No |
|
||||
| graph | object | | No |
|
||||
@@ -13840,7 +13694,7 @@ Tag type
|
||||
| rag_pipeline_variables | [ [TrialPipelineVariable](#trialpipelinevariable) ] | | No |
|
||||
| tool_published | boolean | | No |
|
||||
| updated_at | object | | No |
|
||||
| updated_by | [SimpleAccount](#simpleaccount) | | No |
|
||||
| updated_by | [TrialSimpleAccount](#trialsimpleaccount) | | No |
|
||||
| version | string | | No |
|
||||
|
||||
#### TrialWorkflowPartial
|
||||
@@ -13929,17 +13783,6 @@ Tag type
|
||||
| video_file_size_limit | integer | | Yes |
|
||||
| workflow_file_upload_limit | integer | | Yes |
|
||||
|
||||
#### UpsertNotificationPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| contents | [ [LangContentPayload](#langcontentpayload) ] | | Yes |
|
||||
| end_time | | RFC3339, e.g. 2026-03-20T23:59:59Z | No |
|
||||
| frequency | string | 'once' \| 'every_page_load' | No |
|
||||
| notification_id | | Omit to create; supply UUID to update | No |
|
||||
| start_time | | RFC3339, e.g. 2026-03-01T00:00:00Z | No |
|
||||
| status | string | 'active' \| 'inactive' | No |
|
||||
|
||||
#### UserAction
|
||||
|
||||
User action configuration.
|
||||
|
||||
+465
-28
@@ -1,9 +1,11 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import traceback
|
||||
from collections.abc import Mapping, Sequence
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Union, cast
|
||||
from typing import Any, Protocol, Union, cast
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from openinference.semconv.trace import (
|
||||
@@ -19,7 +21,7 @@ from opentelemetry.sdk import trace as trace_sdk
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
|
||||
from opentelemetry.semconv.attributes import exception_attributes
|
||||
from opentelemetry.trace import Span, Status, StatusCode, set_span_in_context, use_span
|
||||
from opentelemetry.trace import Span, Status, StatusCode, get_current_span, set_span_in_context, use_span
|
||||
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
|
||||
from opentelemetry.util.types import AttributeValue
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -36,16 +38,106 @@ from core.ops.entities.trace_entity import (
|
||||
TraceTaskName,
|
||||
WorkflowTraceInfo,
|
||||
)
|
||||
from core.ops.exceptions import PendingTraceParentContextError
|
||||
from core.ops.utils import JSON_DICT_ADAPTER
|
||||
from core.repositories import DifyCoreRepositoryFactory
|
||||
from dify_trace_arize_phoenix.config import ArizeConfig, PhoenixConfig
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.enums import WorkflowNodeExecutionStatus
|
||||
from models.model import EndUser, MessageFile
|
||||
from models.workflow import WorkflowNodeExecutionTriggeredFrom
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# This parent-span carrier store is intentionally Phoenix-local for the current
|
||||
# nested workflow tracing feature. If other trace providers need the same
|
||||
# cross-task parent restoration behavior, move the storage and retry signaling
|
||||
# behind a core trace coordination interface instead of duplicating it here.
|
||||
_PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS = 300
|
||||
_TRACEPARENT_PATTERN = re.compile(
|
||||
r"^(?P<version>[0-9a-f]{2})-(?P<trace_id>[0-9a-f]{32})-(?P<span_id>[0-9a-f]{16})-(?P<flags>[0-9a-f]{2})$"
|
||||
)
|
||||
|
||||
|
||||
def _phoenix_parent_span_redis_key(parent_node_execution_id: str) -> str:
|
||||
"""Build the Redis key that stores a restorable Phoenix parent span carrier."""
|
||||
return f"trace:phoenix:parent_span:{parent_node_execution_id}"
|
||||
|
||||
|
||||
def _publish_parent_span_context(parent_node_execution_id: str, carrier: Mapping[str, str]) -> None:
|
||||
"""Persist a tracecontext carrier so nested workflow spans can restore the tool span parent."""
|
||||
redis_client.setex(
|
||||
_phoenix_parent_span_redis_key(parent_node_execution_id),
|
||||
_PHOENIX_PARENT_SPAN_CONTEXT_TTL_SECONDS,
|
||||
safe_json_dumps(dict(carrier)),
|
||||
)
|
||||
|
||||
|
||||
def _resolve_published_parent_span_context(parent_node_execution_id: str) -> dict[str, str]:
|
||||
"""Load a previously published tool-span carrier for nested workflow parenting."""
|
||||
raw_carrier = redis_client.get(_phoenix_parent_span_redis_key(parent_node_execution_id))
|
||||
if raw_carrier is None:
|
||||
raise PendingTraceParentContextError(parent_node_execution_id)
|
||||
|
||||
if isinstance(raw_carrier, bytes):
|
||||
raw_carrier = raw_carrier.decode("utf-8")
|
||||
|
||||
carrier = json.loads(raw_carrier)
|
||||
if not isinstance(carrier, dict):
|
||||
raise ValueError(
|
||||
"Phoenix parent span context must be stored as a JSON object: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
normalized_carrier = {str(key): str(value) for key, value in carrier.items()}
|
||||
if not normalized_carrier:
|
||||
raise ValueError(
|
||||
f"Phoenix parent span context payload is empty: parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
traceparent = normalized_carrier.get("traceparent")
|
||||
if not isinstance(traceparent, str):
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload is missing traceparent: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
traceparent_match = _TRACEPARENT_PATTERN.fullmatch(traceparent)
|
||||
if traceparent_match is None:
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload has invalid traceparent format: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
if traceparent_match.group("version") == "ff":
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload has unsupported traceparent version: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
if traceparent_match.group("trace_id") == "0" * 32:
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload has zero trace_id in traceparent: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
if traceparent_match.group("span_id") == "0" * 16:
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload has zero span_id in traceparent: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
extracted_context = TraceContextTextMapPropagator().extract(carrier=normalized_carrier)
|
||||
extracted_span_context = get_current_span(extracted_context).get_span_context()
|
||||
if not extracted_span_context.is_valid or not extracted_span_context.is_remote:
|
||||
raise ValueError(
|
||||
"Phoenix parent span context payload could not be restored into a valid parent span: "
|
||||
f"parent_node_execution_id={parent_node_execution_id}"
|
||||
)
|
||||
|
||||
return normalized_carrier
|
||||
|
||||
|
||||
def setup_tracer(arize_phoenix_config: ArizeConfig | PhoenixConfig) -> tuple[trace_sdk.Tracer, SimpleSpanProcessor]:
|
||||
"""Configure OpenTelemetry tracer with OTLP exporter for Arize/Phoenix."""
|
||||
@@ -177,6 +269,246 @@ def _get_node_span_kind(node_type: str) -> OpenInferenceSpanKindValues:
|
||||
return _NODE_TYPE_TO_SPAN_KIND.get(node_type, OpenInferenceSpanKindValues.CHAIN)
|
||||
|
||||
|
||||
def _resolve_workflow_session_id(trace_info: WorkflowTraceInfo) -> str:
|
||||
"""Resolve the workflow session ID for Phoenix workflow spans."""
|
||||
if trace_info.conversation_id:
|
||||
return trace_info.conversation_id
|
||||
|
||||
parent_workflow_run_id, _ = _resolve_workflow_parent_context(trace_info)
|
||||
if parent_workflow_run_id:
|
||||
return parent_workflow_run_id
|
||||
|
||||
return trace_info.workflow_run_id
|
||||
|
||||
|
||||
def _resolve_workflow_parent_context(trace_info: BaseTraceInfo) -> tuple[str | None, str | None]:
|
||||
"""Expose the typed parent context already resolved on the trace info."""
|
||||
return trace_info.resolved_parent_context
|
||||
|
||||
|
||||
def _resolve_workflow_root_trace_id(trace_info: WorkflowTraceInfo) -> str:
|
||||
"""Resolve the canonical root trace ID for Phoenix workflow spans."""
|
||||
trace_correlation_override, _ = _resolve_workflow_parent_context(trace_info)
|
||||
return trace_correlation_override or trace_info.resolved_trace_id or trace_info.workflow_run_id
|
||||
|
||||
|
||||
class _NodeExecutionIdentityLike(Protocol):
|
||||
@property
|
||||
def node_execution_id(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def node_id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def predecessor_node_id(self) -> str | None: ...
|
||||
|
||||
|
||||
class _NodeExecutionLike(_NodeExecutionIdentityLike, Protocol):
|
||||
@property
|
||||
def id(self) -> str: ...
|
||||
|
||||
@property
|
||||
def node_type(self) -> str: ...
|
||||
|
||||
@property
|
||||
def title(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def inputs(self) -> Mapping[str, Any] | None: ...
|
||||
|
||||
@property
|
||||
def process_data(self) -> Mapping[str, Any] | None: ...
|
||||
|
||||
@property
|
||||
def outputs(self) -> Mapping[str, Any] | None: ...
|
||||
|
||||
@property
|
||||
def status(self) -> WorkflowNodeExecutionStatus: ...
|
||||
|
||||
@property
|
||||
def error(self) -> str | None: ...
|
||||
|
||||
@property
|
||||
def elapsed_time(self) -> float | None: ...
|
||||
|
||||
@property
|
||||
def metadata(self) -> Mapping[Any, Any] | None: ...
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime | None: ...
|
||||
|
||||
|
||||
_PHOENIX_STRUCTURED_NODE_TYPES = frozenset({"start", "end", "loop", "iteration"})
|
||||
|
||||
|
||||
def _resolve_workflow_span_name(trace_info: WorkflowTraceInfo) -> str:
|
||||
"""Resolve the Phoenix workflow span display name."""
|
||||
workflow_run_id = trace_info.workflow_run_id.strip() if trace_info.workflow_run_id else ""
|
||||
if workflow_run_id:
|
||||
return f"{TraceTaskName.WORKFLOW_TRACE.value}_{workflow_run_id}"
|
||||
return TraceTaskName.WORKFLOW_TRACE.value
|
||||
|
||||
|
||||
def _build_node_title_by_id(trace_info: WorkflowTraceInfo) -> dict[str, str]:
|
||||
"""Build an authoritative node-title index from the persisted workflow graph."""
|
||||
workflow_data = trace_info.workflow_data
|
||||
workflow_graph = getattr(workflow_data, "graph_dict", None)
|
||||
if not isinstance(workflow_graph, Mapping):
|
||||
workflow_graph = workflow_data.get("graph") if isinstance(workflow_data, Mapping) else None
|
||||
if not isinstance(workflow_graph, Mapping):
|
||||
return {}
|
||||
|
||||
graph_nodes = workflow_graph.get("nodes")
|
||||
if not isinstance(graph_nodes, Sequence):
|
||||
return {}
|
||||
|
||||
node_title_by_id: dict[str, str] = {}
|
||||
for graph_node in graph_nodes:
|
||||
if not isinstance(graph_node, Mapping):
|
||||
continue
|
||||
node_id = graph_node.get("id")
|
||||
node_data = graph_node.get("data")
|
||||
if not isinstance(node_id, str) or not isinstance(node_data, Mapping):
|
||||
continue
|
||||
node_title = node_data.get("title")
|
||||
if isinstance(node_title, str) and node_title.strip():
|
||||
node_title_by_id[node_id] = node_title.strip()
|
||||
|
||||
return node_title_by_id
|
||||
|
||||
|
||||
def _resolve_workflow_node_span_name(
|
||||
node_execution: _NodeExecutionLike,
|
||||
node_title_by_id: Mapping[str, str] | None = None,
|
||||
) -> str:
|
||||
"""Resolve the Phoenix workflow node span display name."""
|
||||
node_type = str(node_execution.node_type or "")
|
||||
graph_node_title = None
|
||||
if node_title_by_id is not None and isinstance(node_execution.node_id, str):
|
||||
graph_node_title = node_title_by_id.get(node_execution.node_id)
|
||||
|
||||
node_title = graph_node_title or (node_execution.title.strip() if isinstance(node_execution.title, str) else "")
|
||||
if node_title:
|
||||
return f"{node_type}_{node_title}"
|
||||
return node_type
|
||||
|
||||
|
||||
def _get_node_execution_id(node_execution: _NodeExecutionIdentityLike) -> str:
|
||||
"""Return the stable execution identifier for a workflow node execution."""
|
||||
return str(getattr(node_execution, "id", None) or node_execution.node_execution_id)
|
||||
|
||||
|
||||
def _build_execution_id_by_node_id(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]:
|
||||
"""Index unique workflow graph node ids by execution id.
|
||||
|
||||
This Phoenix-local hierarchy reconstruction intentionally drops ambiguous
|
||||
node ids instead of guessing based on repository order. That keeps parent
|
||||
selection deterministic until upstream tracing exposes explicit parent span
|
||||
data for repeated executions.
|
||||
"""
|
||||
execution_id_by_node_id: dict[str, str] = {}
|
||||
ambiguous_node_ids: set[str] = set()
|
||||
|
||||
for node_execution in node_executions:
|
||||
node_id = node_execution.node_id
|
||||
if not isinstance(node_id, str):
|
||||
continue
|
||||
execution_id = _get_node_execution_id(node_execution)
|
||||
|
||||
if node_id in ambiguous_node_ids:
|
||||
continue
|
||||
|
||||
existing_execution_id = execution_id_by_node_id.get(node_id)
|
||||
if existing_execution_id is None:
|
||||
execution_id_by_node_id[node_id] = execution_id
|
||||
continue
|
||||
|
||||
if existing_execution_id != execution_id:
|
||||
ambiguous_node_ids.add(node_id)
|
||||
execution_id_by_node_id.pop(node_id, None)
|
||||
|
||||
return execution_id_by_node_id
|
||||
|
||||
|
||||
def _build_graph_parent_index(node_executions: Sequence[_NodeExecutionIdentityLike]) -> dict[str, str]:
|
||||
"""Build an execution-id parent index from predecessor node ids."""
|
||||
execution_id_by_node_id = _build_execution_id_by_node_id(node_executions)
|
||||
graph_parent_index: dict[str, str] = {}
|
||||
|
||||
for node_execution in node_executions:
|
||||
predecessor_node_id = node_execution.predecessor_node_id
|
||||
if not isinstance(predecessor_node_id, str):
|
||||
continue
|
||||
|
||||
predecessor_execution_id = execution_id_by_node_id.get(predecessor_node_id)
|
||||
if predecessor_execution_id is not None:
|
||||
execution_id = _get_node_execution_id(node_execution)
|
||||
graph_parent_index[execution_id] = predecessor_execution_id
|
||||
|
||||
return graph_parent_index
|
||||
|
||||
|
||||
def _resolve_structured_parent_execution_id(
|
||||
node_execution: object, execution_id_by_node_id: Mapping[str, str]
|
||||
) -> str | None:
|
||||
"""Resolve Phoenix-local structured parents from loop/iteration node ids.
|
||||
|
||||
Any execution carrying ``iteration_id`` or ``loop_id`` belongs to an
|
||||
enclosing structured node. When predecessor node ids are ambiguous because
|
||||
the graph node repeats inside that structure, Phoenix can still keep the
|
||||
child span under the enclosing loop/iteration span without relying on
|
||||
execution-order heuristics.
|
||||
"""
|
||||
execution_metadata = getattr(node_execution, "execution_metadata_dict", None)
|
||||
if not isinstance(execution_metadata, Mapping):
|
||||
execution_metadata = getattr(node_execution, "metadata", None)
|
||||
if not isinstance(execution_metadata, Mapping):
|
||||
execution_metadata = {}
|
||||
|
||||
for enclosing_node_id in (
|
||||
getattr(node_execution, "iteration_id", None),
|
||||
getattr(node_execution, "loop_id", None),
|
||||
execution_metadata.get("iteration_id"),
|
||||
execution_metadata.get("loop_id"),
|
||||
):
|
||||
if not isinstance(enclosing_node_id, str):
|
||||
continue
|
||||
|
||||
enclosing_execution_id = execution_id_by_node_id.get(enclosing_node_id)
|
||||
if enclosing_execution_id is not None:
|
||||
return enclosing_execution_id
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_node_parent(
|
||||
execution_id: str,
|
||||
predecessor_execution_id: str | None,
|
||||
structured_parent_execution_id: str | None,
|
||||
span_by_execution_id: Mapping[str, Span],
|
||||
graph_parent_index: Mapping[str, str],
|
||||
workflow_span: Span,
|
||||
) -> Span:
|
||||
"""Resolve the parent span for a workflow node execution."""
|
||||
if predecessor_execution_id is not None:
|
||||
predecessor_span = span_by_execution_id.get(predecessor_execution_id)
|
||||
if predecessor_span is not None:
|
||||
return predecessor_span
|
||||
|
||||
graph_parent_execution_id = graph_parent_index.get(execution_id)
|
||||
if graph_parent_execution_id is not None:
|
||||
graph_parent_span = span_by_execution_id.get(graph_parent_execution_id)
|
||||
if graph_parent_span is not None:
|
||||
return graph_parent_span
|
||||
|
||||
if structured_parent_execution_id is not None:
|
||||
structured_parent_span = span_by_execution_id.get(structured_parent_execution_id)
|
||||
if structured_parent_span is not None:
|
||||
return structured_parent_span
|
||||
|
||||
return workflow_span
|
||||
|
||||
|
||||
class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -189,6 +521,8 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
|
||||
self.propagator = TraceContextTextMapPropagator()
|
||||
self.dify_trace_ids: set[str] = set()
|
||||
self.root_span_carriers: dict[str, dict[str, str]] = {}
|
||||
self.carrier: dict[str, str] = {}
|
||||
|
||||
def trace(self, trace_info: BaseTraceInfo):
|
||||
logger.info("[Arize/Phoenix] Trace Entity Info: %s", trace_info)
|
||||
@@ -235,13 +569,41 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
file_list=safe_json_dumps(file_list),
|
||||
query=trace_info.query or "",
|
||||
)
|
||||
workflow_session_id = _resolve_workflow_session_id(trace_info)
|
||||
parent_workflow_run_id, parent_node_execution_id = _resolve_workflow_parent_context(trace_info)
|
||||
logger.info(
|
||||
"[Arize/Phoenix] Workflow session resolution: workflow_run_id=%s conversation_id=%s "
|
||||
"parent_workflow_run_id=%s parent_node_execution_id=%s resolved_session_id=%s",
|
||||
trace_info.workflow_run_id,
|
||||
trace_info.conversation_id,
|
||||
parent_workflow_run_id,
|
||||
parent_node_execution_id,
|
||||
workflow_session_id,
|
||||
)
|
||||
|
||||
dify_trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id
|
||||
self.ensure_root_span(dify_trace_id)
|
||||
root_span_context = self.propagator.extract(carrier=self.carrier)
|
||||
if parent_node_execution_id:
|
||||
workflow_parent_carrier = _resolve_published_parent_span_context(parent_node_execution_id)
|
||||
else:
|
||||
root_trace_id = _resolve_workflow_root_trace_id(trace_info)
|
||||
workflow_root_span_name: str | None = trace_info.workflow_run_id
|
||||
if not isinstance(workflow_root_span_name, str) or not workflow_root_span_name.strip():
|
||||
workflow_root_span_name = None
|
||||
|
||||
workflow_parent_carrier = self.ensure_root_span(
|
||||
root_trace_id,
|
||||
root_span_name=workflow_root_span_name,
|
||||
root_span_attributes={
|
||||
SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs),
|
||||
SpanAttributes.INPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
|
||||
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs),
|
||||
SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
|
||||
},
|
||||
)
|
||||
|
||||
workflow_span_context = self.propagator.extract(carrier=workflow_parent_carrier)
|
||||
|
||||
workflow_span = self.tracer.start_span(
|
||||
name=TraceTaskName.WORKFLOW_TRACE.value,
|
||||
name=_resolve_workflow_span_name(trace_info),
|
||||
attributes={
|
||||
SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
|
||||
SpanAttributes.INPUT_VALUE: safe_json_dumps(trace_info.workflow_run_inputs),
|
||||
@@ -249,10 +611,10 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(trace_info.workflow_run_outputs),
|
||||
SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
|
||||
SpanAttributes.METADATA: safe_json_dumps(metadata),
|
||||
SpanAttributes.SESSION_ID: trace_info.conversation_id or "",
|
||||
SpanAttributes.SESSION_ID: workflow_session_id or "",
|
||||
},
|
||||
start_time=datetime_to_nanos(trace_info.start_time),
|
||||
context=root_span_context,
|
||||
context=workflow_span_context,
|
||||
)
|
||||
|
||||
# Through workflow_run_id, get all_nodes_execution using repository
|
||||
@@ -276,16 +638,50 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
workflow_node_executions = workflow_node_execution_repository.get_by_workflow_execution(
|
||||
workflow_execution_id=trace_info.workflow_run_id
|
||||
)
|
||||
node_title_by_id = _build_node_title_by_id(trace_info)
|
||||
execution_id_by_node_id = _build_execution_id_by_node_id(workflow_node_executions)
|
||||
graph_parent_index = _build_graph_parent_index(workflow_node_executions)
|
||||
node_execution_by_execution_id = {
|
||||
_get_node_execution_id(node_execution): node_execution for node_execution in workflow_node_executions
|
||||
}
|
||||
span_by_execution_id: dict[str, Span] = {}
|
||||
emitting_execution_ids: set[str] = set()
|
||||
|
||||
workflow_span_error: Exception | str | None = trace_info.error
|
||||
try:
|
||||
for node_execution in workflow_node_executions:
|
||||
|
||||
def emit_node_span(node_execution: _NodeExecutionLike) -> Span:
|
||||
execution_id = _get_node_execution_id(node_execution)
|
||||
existing_span = span_by_execution_id.get(execution_id)
|
||||
if existing_span is not None:
|
||||
return existing_span
|
||||
|
||||
graph_parent_execution_id = graph_parent_index.get(execution_id)
|
||||
structured_parent_execution_id = _resolve_structured_parent_execution_id(
|
||||
node_execution, execution_id_by_node_id
|
||||
)
|
||||
|
||||
if execution_id not in emitting_execution_ids:
|
||||
emitting_execution_ids.add(execution_id)
|
||||
try:
|
||||
for parent_execution_id in (graph_parent_execution_id, structured_parent_execution_id):
|
||||
if parent_execution_id is None or parent_execution_id == execution_id:
|
||||
continue
|
||||
if parent_execution_id in span_by_execution_id:
|
||||
continue
|
||||
parent_node_execution = node_execution_by_execution_id.get(parent_execution_id)
|
||||
if parent_node_execution is not None:
|
||||
emit_node_span(parent_node_execution)
|
||||
finally:
|
||||
emitting_execution_ids.discard(execution_id)
|
||||
|
||||
tenant_id = trace_info.tenant_id # Use from trace_info instead
|
||||
app_id = trace_info.metadata.get("app_id") # Use from trace_info instead
|
||||
inputs_value = node_execution.inputs or {}
|
||||
outputs_value = node_execution.outputs or {}
|
||||
|
||||
created_at = node_execution.created_at or datetime.now()
|
||||
elapsed_time = node_execution.elapsed_time
|
||||
elapsed_time = node_execution.elapsed_time or 0
|
||||
finished_at = created_at + timedelta(seconds=elapsed_time)
|
||||
|
||||
process_data = node_execution.process_data or {}
|
||||
@@ -324,9 +720,17 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
node_metadata["prompt_tokens"] = usage_data.get("prompt_tokens", 0)
|
||||
node_metadata["completion_tokens"] = usage_data.get("completion_tokens", 0)
|
||||
|
||||
workflow_span_context = set_span_in_context(workflow_span)
|
||||
parent_span = _resolve_node_parent(
|
||||
execution_id=execution_id,
|
||||
predecessor_execution_id=None,
|
||||
structured_parent_execution_id=structured_parent_execution_id,
|
||||
span_by_execution_id=span_by_execution_id,
|
||||
graph_parent_index=graph_parent_index,
|
||||
workflow_span=workflow_span,
|
||||
)
|
||||
workflow_span_context = set_span_in_context(parent_span)
|
||||
node_span = self.tracer.start_span(
|
||||
name=node_execution.node_type,
|
||||
name=_resolve_workflow_node_span_name(node_execution, node_title_by_id),
|
||||
attributes={
|
||||
SpanAttributes.OPENINFERENCE_SPAN_KIND: span_kind.value,
|
||||
SpanAttributes.INPUT_VALUE: safe_json_dumps(inputs_value),
|
||||
@@ -334,13 +738,20 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
SpanAttributes.OUTPUT_VALUE: safe_json_dumps(outputs_value),
|
||||
SpanAttributes.OUTPUT_MIME_TYPE: OpenInferenceMimeTypeValues.JSON.value,
|
||||
SpanAttributes.METADATA: safe_json_dumps(node_metadata),
|
||||
SpanAttributes.SESSION_ID: trace_info.conversation_id or "",
|
||||
SpanAttributes.SESSION_ID: workflow_session_id or "",
|
||||
},
|
||||
start_time=datetime_to_nanos(created_at),
|
||||
context=workflow_span_context,
|
||||
)
|
||||
|
||||
span_by_execution_id[execution_id] = node_span
|
||||
node_span_error: Exception | str | None = None
|
||||
try:
|
||||
if node_execution.node_type == "tool":
|
||||
parent_span_carrier: dict[str, str] = {}
|
||||
with use_span(node_span, end_on_exit=False):
|
||||
self.propagator.inject(carrier=parent_span_carrier)
|
||||
_publish_parent_span_context(execution_id, parent_span_carrier)
|
||||
|
||||
if node_execution.node_type == "llm":
|
||||
llm_attributes: dict[str, Any] = {
|
||||
SpanAttributes.INPUT_VALUE: json.dumps(process_data.get("prompts", []), ensure_ascii=False),
|
||||
@@ -362,17 +773,26 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
)
|
||||
llm_attributes.update(self._construct_llm_attributes(process_data.get("prompts", [])))
|
||||
node_span.set_attributes(llm_attributes)
|
||||
except Exception as e:
|
||||
node_span_error = e
|
||||
raise
|
||||
finally:
|
||||
if node_execution.status == WorkflowNodeExecutionStatus.FAILED:
|
||||
if node_span_error is not None:
|
||||
set_span_status(node_span, node_span_error)
|
||||
elif node_execution.status == WorkflowNodeExecutionStatus.FAILED:
|
||||
set_span_status(node_span, node_execution.error)
|
||||
else:
|
||||
set_span_status(node_span)
|
||||
node_span.end(end_time=datetime_to_nanos(finished_at))
|
||||
return node_span
|
||||
|
||||
for node_execution in workflow_node_executions:
|
||||
emit_node_span(node_execution)
|
||||
except Exception as e:
|
||||
workflow_span_error = e
|
||||
raise
|
||||
finally:
|
||||
if trace_info.error:
|
||||
set_span_status(workflow_span, trace_info.error)
|
||||
else:
|
||||
set_span_status(workflow_span)
|
||||
set_span_status(workflow_span, workflow_span_error)
|
||||
workflow_span.end(end_time=datetime_to_nanos(trace_info.end_time))
|
||||
|
||||
def message_trace(self, trace_info: MessageTraceInfo):
|
||||
@@ -735,22 +1155,39 @@ class ArizePhoenixDataTrace(BaseTraceInstance):
|
||||
finally:
|
||||
span.end(end_time=datetime_to_nanos(trace_info.end_time))
|
||||
|
||||
def ensure_root_span(self, dify_trace_id: str | None):
|
||||
def ensure_root_span(
|
||||
self,
|
||||
dify_trace_id: str | None,
|
||||
*,
|
||||
root_span_name: str | None = None,
|
||||
root_span_attributes: Mapping[str, AttributeValue] | None = None,
|
||||
):
|
||||
"""Ensure a unique root span exists for the given Dify trace ID."""
|
||||
if str(dify_trace_id) not in self.dify_trace_ids:
|
||||
self.carrier: dict[str, str] = {}
|
||||
trace_key = str(dify_trace_id)
|
||||
if trace_key not in self.dify_trace_ids:
|
||||
carrier: dict[str, str] = {}
|
||||
|
||||
root_span = self.tracer.start_span(name="Dify")
|
||||
root_span.set_attribute(SpanAttributes.OPENINFERENCE_SPAN_KIND, OpenInferenceSpanKindValues.CHAIN.value)
|
||||
root_span.set_attribute("dify_project_name", str(self.project))
|
||||
root_span.set_attribute("dify_trace_id", str(dify_trace_id))
|
||||
span_name = root_span_name.strip() if isinstance(root_span_name, str) and root_span_name.strip() else "Dify"
|
||||
root_span_attributes_dict: dict[str, AttributeValue] = {
|
||||
SpanAttributes.OPENINFERENCE_SPAN_KIND: OpenInferenceSpanKindValues.CHAIN.value,
|
||||
"dify_project_name": str(self.project),
|
||||
"dify_trace_id": trace_key,
|
||||
}
|
||||
if root_span_attributes:
|
||||
root_span_attributes_dict.update(root_span_attributes)
|
||||
|
||||
root_span = self.tracer.start_span(name=span_name, attributes=root_span_attributes_dict)
|
||||
|
||||
with use_span(root_span, end_on_exit=False):
|
||||
self.propagator.inject(carrier=self.carrier)
|
||||
self.propagator.inject(carrier=carrier)
|
||||
|
||||
set_span_status(root_span)
|
||||
root_span.end()
|
||||
self.dify_trace_ids.add(str(dify_trace_id))
|
||||
self.dify_trace_ids.add(trace_key)
|
||||
self.root_span_carriers[trace_key] = carrier
|
||||
|
||||
self.carrier = self.root_span_carriers[trace_key]
|
||||
return self.carrier
|
||||
|
||||
def api_check(self):
|
||||
try:
|
||||
|
||||
+1183
-11
File diff suppressed because it is too large
Load Diff
@@ -1,36 +0,0 @@
|
||||
from dify_trace_arize_phoenix.arize_phoenix_trace import _NODE_TYPE_TO_SPAN_KIND, _get_node_span_kind
|
||||
from openinference.semconv.trace import OpenInferenceSpanKindValues
|
||||
|
||||
from graphon.enums import BUILT_IN_NODE_TYPES, BuiltinNodeTypes
|
||||
|
||||
|
||||
class TestGetNodeSpanKind:
|
||||
"""Tests for _get_node_span_kind helper."""
|
||||
|
||||
def test_all_node_types_are_mapped_correctly(self):
|
||||
"""Ensure every built-in node type is mapped to the correct span kind."""
|
||||
# Mappings for node types that have a specialised span kind.
|
||||
special_mappings = {
|
||||
BuiltinNodeTypes.LLM: OpenInferenceSpanKindValues.LLM,
|
||||
BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: OpenInferenceSpanKindValues.RETRIEVER,
|
||||
BuiltinNodeTypes.TOOL: OpenInferenceSpanKindValues.TOOL,
|
||||
BuiltinNodeTypes.AGENT: OpenInferenceSpanKindValues.AGENT,
|
||||
}
|
||||
|
||||
# Test that every built-in node type is mapped to the correct span kind.
|
||||
# Node types not in `special_mappings` should default to CHAIN.
|
||||
for node_type in BUILT_IN_NODE_TYPES:
|
||||
expected_span_kind = special_mappings.get(node_type, OpenInferenceSpanKindValues.CHAIN)
|
||||
actual_span_kind = _get_node_span_kind(node_type)
|
||||
assert actual_span_kind == expected_span_kind, (
|
||||
f"Node type {node_type!r} was mapped to {actual_span_kind}, but {expected_span_kind} was expected."
|
||||
)
|
||||
|
||||
def test_unknown_string_defaults_to_chain(self):
|
||||
"""An unrecognised node type string should still return CHAIN."""
|
||||
assert _get_node_span_kind("some-future-node-type") == OpenInferenceSpanKindValues.CHAIN
|
||||
|
||||
def test_stale_dataset_retrieval_not_in_mapping(self):
|
||||
"""The old 'dataset_retrieval' string was never a valid NodeType value;
|
||||
make sure it is not present in the mapping dictionary."""
|
||||
assert "dataset_retrieval" not in _NODE_TYPE_TO_SPAN_KIND
|
||||
@@ -13,6 +13,8 @@ from langfuse.api import (
|
||||
TraceBody,
|
||||
)
|
||||
from langfuse.api.commons.types.usage import Usage
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.ops.base_trace_instance import BaseTraceInstance
|
||||
@@ -52,13 +54,40 @@ class LangFuseDataTrace(BaseTraceInstance):
|
||||
langfuse_config: LangfuseConfig,
|
||||
):
|
||||
super().__init__(langfuse_config)
|
||||
# Isolated TracerProvider prevents the langfuse v3 SDK from attaching its
|
||||
# SpanProcessor to the global OpenTelemetry TracerProvider, which would
|
||||
# otherwise siphon every Flask/Celery/SQLAlchemy span in the process into
|
||||
# this tenant's Langfuse project. See langfuse upgrade guide v2 -> v3.
|
||||
self._tracer_provider: TracerProvider | None = TracerProvider(
|
||||
resource=Resource.create({"service.name": "dify-langfuse-app-trace"}),
|
||||
)
|
||||
self.langfuse_client = Langfuse(
|
||||
public_key=langfuse_config.public_key,
|
||||
secret_key=langfuse_config.secret_key,
|
||||
host=langfuse_config.host,
|
||||
tracer_provider=self._tracer_provider,
|
||||
)
|
||||
self.file_base_url = os.getenv("FILES_URL", "http://127.0.0.1:5001")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Flush and shut down the isolated TracerProvider.
|
||||
|
||||
Called explicitly when the trace instance is evicted from the cache, or
|
||||
implicitly via ``__del__`` on garbage collection. Idempotent.
|
||||
"""
|
||||
provider = getattr(self, "_tracer_provider", None)
|
||||
if provider is None:
|
||||
return
|
||||
try:
|
||||
provider.shutdown()
|
||||
except Exception:
|
||||
logger.debug("Failed to shut down Langfuse TracerProvider", exc_info=True)
|
||||
finally:
|
||||
self._tracer_provider = None
|
||||
|
||||
def __del__(self) -> None:
|
||||
self.close()
|
||||
|
||||
@staticmethod
|
||||
def _get_completion_start_time(
|
||||
start_time: datetime | None, time_to_first_token: float | int | None
|
||||
|
||||
+77
-5
@@ -50,20 +50,92 @@ def trace_instance(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
|
||||
def test_init(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
mock_langfuse = MagicMock()
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", mock_langfuse)
|
||||
monkeypatch.setenv("FILES_URL", "http://test.url")
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
mock_langfuse.assert_called_once_with(
|
||||
public_key=langfuse_config.public_key,
|
||||
secret_key=langfuse_config.secret_key,
|
||||
host=langfuse_config.host,
|
||||
)
|
||||
mock_langfuse.assert_called_once()
|
||||
kwargs = mock_langfuse.call_args.kwargs
|
||||
assert kwargs["public_key"] == langfuse_config.public_key
|
||||
assert kwargs["secret_key"] == langfuse_config.secret_key
|
||||
assert kwargs["host"] == langfuse_config.host
|
||||
assert isinstance(kwargs["tracer_provider"], TracerProvider)
|
||||
assert kwargs["tracer_provider"] is instance._tracer_provider
|
||||
assert instance.file_base_url == "http://test.url"
|
||||
|
||||
|
||||
def test_init_passes_isolated_tracer_provider_to_langfuse(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Regression test for langfuse v3 SDK side effect.
|
||||
|
||||
Without an explicit ``tracer_provider=`` kwarg, the Langfuse v3 SDK
|
||||
attaches a ``LangfuseSpanProcessor`` to the *global* OpenTelemetry
|
||||
TracerProvider — siphoning every Flask / Celery / SQLAlchemy span in the
|
||||
process into the tenant's Langfuse project. See langfuse upgrade-path
|
||||
docs (v2 -> v3) and GitHub discussion #9136.
|
||||
|
||||
The fix is to construct an isolated ``TracerProvider`` and pass it via
|
||||
``tracer_provider=`` so the SDK never touches the global one.
|
||||
"""
|
||||
from opentelemetry import trace as otel_trace_api
|
||||
from opentelemetry.sdk.trace import TracerProvider
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_langfuse(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", fake_langfuse)
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
|
||||
# 1. tracer_provider kwarg must be supplied (drives the no-pollution branch
|
||||
# in langfuse.LangfuseResourceManager._init_tracer_provider).
|
||||
assert "tracer_provider" in captured, (
|
||||
"Langfuse() must receive an explicit tracer_provider=; without it the "
|
||||
"v3 SDK attaches its SpanProcessor to the global OTEL TracerProvider."
|
||||
)
|
||||
|
||||
passed_provider = captured["tracer_provider"]
|
||||
assert isinstance(passed_provider, TracerProvider)
|
||||
assert passed_provider is instance._tracer_provider
|
||||
|
||||
# 2. The instance's provider must not be the global one.
|
||||
global_provider = otel_trace_api.get_tracer_provider()
|
||||
assert passed_provider is not global_provider
|
||||
|
||||
|
||||
def test_close_shuts_down_tracer_provider(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider = instance._tracer_provider
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
assert instance._tracer_provider is None
|
||||
|
||||
|
||||
def test_close_is_idempotent(langfuse_config, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr("dify_trace_langfuse.langfuse_trace.Langfuse", lambda **kwargs: MagicMock())
|
||||
|
||||
instance = LangFuseDataTrace(langfuse_config)
|
||||
provider_shutdown = MagicMock()
|
||||
monkeypatch.setattr(instance._tracer_provider, "shutdown", provider_shutdown)
|
||||
|
||||
instance.close()
|
||||
instance.close()
|
||||
|
||||
provider_shutdown.assert_called_once()
|
||||
|
||||
|
||||
def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
methods = [
|
||||
"workflow_trace",
|
||||
|
||||
+34
-28
@@ -65,35 +65,18 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel):
|
||||
}
|
||||
file_list = values.get("file_list", [])
|
||||
if isinstance(v, str):
|
||||
if field_name == "inputs":
|
||||
return {
|
||||
"messages": {
|
||||
"role": "user",
|
||||
"content": v,
|
||||
"usage_metadata": usage_metadata,
|
||||
"file_list": file_list,
|
||||
},
|
||||
}
|
||||
elif field_name == "outputs":
|
||||
return {
|
||||
"choices": {
|
||||
"role": "ai",
|
||||
"content": v,
|
||||
"usage_metadata": usage_metadata,
|
||||
"file_list": file_list,
|
||||
},
|
||||
}
|
||||
elif isinstance(v, list):
|
||||
data = {}
|
||||
if len(v) > 0 and isinstance(v[0], dict):
|
||||
# rename text to content
|
||||
v = replace_text_with_content(data=v)
|
||||
if field_name == "inputs":
|
||||
data = {
|
||||
"messages": v,
|
||||
match field_name:
|
||||
case "inputs":
|
||||
return {
|
||||
"messages": {
|
||||
"role": "user",
|
||||
"content": v,
|
||||
"usage_metadata": usage_metadata,
|
||||
"file_list": file_list,
|
||||
},
|
||||
}
|
||||
elif field_name == "outputs":
|
||||
data = {
|
||||
case "outputs":
|
||||
return {
|
||||
"choices": {
|
||||
"role": "ai",
|
||||
"content": v,
|
||||
@@ -101,6 +84,29 @@ class LangSmithRunModel(LangSmithTokenUsage, LangSmithMultiModel):
|
||||
"file_list": file_list,
|
||||
},
|
||||
}
|
||||
case _:
|
||||
pass
|
||||
elif isinstance(v, list):
|
||||
data = {}
|
||||
if len(v) > 0 and isinstance(v[0], dict):
|
||||
# rename text to content
|
||||
v = replace_text_with_content(data=v)
|
||||
match field_name:
|
||||
case "inputs":
|
||||
data = {
|
||||
"messages": v,
|
||||
}
|
||||
case "outputs":
|
||||
data = {
|
||||
"choices": {
|
||||
"role": "ai",
|
||||
"content": v,
|
||||
"usage_metadata": usage_metadata,
|
||||
"file_list": file_list,
|
||||
},
|
||||
}
|
||||
case _:
|
||||
pass
|
||||
return data
|
||||
else:
|
||||
return {
|
||||
|
||||
@@ -64,7 +64,9 @@ class LangSmithDataTrace(BaseTraceInstance):
|
||||
self.generate_name_trace(trace_info)
|
||||
|
||||
def workflow_trace(self, trace_info: WorkflowTraceInfo):
|
||||
trace_id = trace_info.trace_id or trace_info.message_id or trace_info.workflow_run_id
|
||||
# trace_id must equal the root run's run_id (LangSmith protocol); external trace_id
|
||||
# cannot be used here as it would cause HTTP 400.
|
||||
trace_id = trace_info.message_id or trace_info.workflow_run_id
|
||||
if trace_info.start_time is None:
|
||||
trace_info.start_time = datetime.now()
|
||||
message_dotted_order = (
|
||||
@@ -77,6 +79,8 @@ class LangSmithDataTrace(BaseTraceInstance):
|
||||
)
|
||||
metadata = trace_info.metadata
|
||||
metadata["workflow_app_log_id"] = trace_info.workflow_app_log_id
|
||||
if trace_info.trace_id:
|
||||
metadata["external_trace_id"] = trace_info.trace_id
|
||||
|
||||
if trace_info.message_id:
|
||||
message_run = LangSmithRunModel(
|
||||
|
||||
+84
@@ -208,13 +208,17 @@ def test_workflow_trace(trace_instance, monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
assert call_args[0].id == "msg-1"
|
||||
assert call_args[0].name == TraceTaskName.MESSAGE_TRACE
|
||||
# trace_id must equal root run's id (message_id), not the external trace_id "trace-1"
|
||||
assert call_args[0].trace_id == "msg-1"
|
||||
|
||||
assert call_args[1].id == "run-1"
|
||||
assert call_args[1].name == TraceTaskName.WORKFLOW_TRACE
|
||||
assert call_args[1].parent_run_id == "msg-1"
|
||||
assert call_args[1].trace_id == "msg-1"
|
||||
|
||||
assert call_args[2].id == "node-llm"
|
||||
assert call_args[2].run_type == LangSmithRunType.llm
|
||||
assert call_args[2].trace_id == "msg-1"
|
||||
|
||||
assert call_args[3].id == "node-other"
|
||||
assert call_args[3].run_type == LangSmithRunType.tool
|
||||
@@ -604,3 +608,83 @@ def test_get_project_url_error(trace_instance):
|
||||
trace_instance.langsmith_client.get_run_url.side_effect = Exception("error")
|
||||
with pytest.raises(ValueError, match="LangSmith get run url failed: error"):
|
||||
trace_instance.get_project_url()
|
||||
|
||||
|
||||
def _make_workflow_trace_info(
|
||||
*, message_id: str | None, workflow_run_id: str, trace_id: str | None
|
||||
) -> WorkflowTraceInfo:
|
||||
workflow_data = MagicMock()
|
||||
workflow_data.created_at = _dt()
|
||||
workflow_data.finished_at = _dt() + timedelta(seconds=1)
|
||||
return WorkflowTraceInfo(
|
||||
tenant_id="tenant-1",
|
||||
workflow_id="wf-1",
|
||||
workflow_run_id=workflow_run_id,
|
||||
workflow_run_inputs={},
|
||||
workflow_run_outputs={},
|
||||
workflow_run_status="succeeded",
|
||||
workflow_run_version="1.0",
|
||||
workflow_run_elapsed_time=1.0,
|
||||
total_tokens=0,
|
||||
file_list=[],
|
||||
query="q",
|
||||
message_id=message_id,
|
||||
conversation_id="conv-1" if message_id else None,
|
||||
start_time=_dt(),
|
||||
end_time=_dt() + timedelta(seconds=1),
|
||||
trace_id=trace_id,
|
||||
metadata={"app_id": "app-1"},
|
||||
workflow_app_log_id=None,
|
||||
error=None,
|
||||
workflow_data=workflow_data,
|
||||
)
|
||||
|
||||
|
||||
def _patch_workflow_trace_deps(monkeypatch, trace_instance):
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.sessionmaker", lambda bind: lambda: MagicMock())
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.db", MagicMock(engine="engine"))
|
||||
repo = MagicMock()
|
||||
repo.get_by_workflow_execution.return_value = []
|
||||
factory = MagicMock()
|
||||
factory.create_workflow_node_execution_repository.return_value = repo
|
||||
monkeypatch.setattr("dify_trace_langsmith.langsmith_trace.DifyCoreRepositoryFactory", factory)
|
||||
monkeypatch.setattr(trace_instance, "get_service_account_with_tenant", lambda app_id: MagicMock())
|
||||
trace_instance.add_run = MagicMock()
|
||||
|
||||
|
||||
def test_workflow_trace_id_uses_message_id_not_external(trace_instance, monkeypatch):
|
||||
"""Chatflow with external trace_id: LangSmith trace_id must be message_id, not external."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id="msg-abc",
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
calls = [c[0][0] for c in trace_instance.add_run.call_args_list]
|
||||
# message run (root) and workflow run (child) must both use message_id as trace_id
|
||||
assert calls[0].id == "msg-abc"
|
||||
assert calls[0].trace_id == "msg-abc"
|
||||
assert calls[1].id == "run-xyz"
|
||||
assert calls[1].trace_id == "msg-abc"
|
||||
# external_trace_id preserved in metadata
|
||||
assert trace_info.metadata.get("external_trace_id") == "external-999"
|
||||
|
||||
|
||||
def test_workflow_trace_id_pure_workflow_uses_run_id(trace_instance, monkeypatch):
|
||||
"""Pure workflow (no message_id) with external trace_id: trace_id must be workflow_run_id."""
|
||||
trace_info = _make_workflow_trace_info(
|
||||
message_id=None,
|
||||
workflow_run_id="run-xyz",
|
||||
trace_id="external-999",
|
||||
)
|
||||
_patch_workflow_trace_deps(monkeypatch, trace_instance)
|
||||
|
||||
trace_instance.workflow_trace(trace_info)
|
||||
|
||||
calls = [c[0][0] for c in trace_instance.add_run.call_args_list]
|
||||
# workflow run is the root; trace_id must equal its run_id
|
||||
assert calls[0].id == "run-xyz"
|
||||
assert calls[0].trace_id == "run-xyz"
|
||||
|
||||
@@ -94,29 +94,29 @@ class PatchedCoreComponents(TypedDict):
|
||||
def _add_stub_modules(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Drop fake metric modules into sys.modules so the client imports resolve."""
|
||||
|
||||
metrics_module = types.ModuleType("opentelemetry.sdk.metrics")
|
||||
metrics_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics"))
|
||||
metrics_module.Histogram = DummyHistogram
|
||||
metrics_module.MeterProvider = DummyMeterProvider
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics", metrics_module)
|
||||
|
||||
metrics_export_module = types.ModuleType("opentelemetry.sdk.metrics.export")
|
||||
metrics_export_module = cast(Any, types.ModuleType("opentelemetry.sdk.metrics.export"))
|
||||
metrics_export_module.AggregationTemporality = AggregationTemporality
|
||||
metrics_export_module.PeriodicExportingMetricReader = DummyMetricReader
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.sdk.metrics.export", metrics_export_module)
|
||||
|
||||
grpc_module = types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter")
|
||||
grpc_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.grpc.metric_exporter"))
|
||||
grpc_module.OTLPMetricExporter = DummyGrpcMetricExporter
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.grpc.metric_exporter", grpc_module)
|
||||
|
||||
http_module = types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter")
|
||||
http_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.proto.http.metric_exporter"))
|
||||
http_module.OTLPMetricExporter = DummyHttpMetricExporter
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.proto.http.metric_exporter", http_module)
|
||||
|
||||
http_json_module = types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter")
|
||||
http_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.http.json.metric_exporter"))
|
||||
http_json_module.OTLPMetricExporter = DummyJsonMetricExporter
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.http.json.metric_exporter", http_json_module)
|
||||
|
||||
legacy_json_module = types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter")
|
||||
legacy_json_module = cast(Any, types.ModuleType("opentelemetry.exporter.otlp.json.metric_exporter"))
|
||||
legacy_json_module.OTLPMetricExporter = DummyJsonMetricExporter
|
||||
monkeypatch.setitem(sys.modules, "opentelemetry.exporter.otlp.json.metric_exporter", legacy_json_module)
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import json
|
||||
from typing import Any, TypedDict
|
||||
|
||||
import chromadb
|
||||
from chromadb import QueryResult, Settings # pyright: ignore[reportPrivateImportUsage]
|
||||
from chromadb import QueryResult, Settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
from configs import dify_config
|
||||
@@ -166,8 +166,8 @@ class ChromaVectorFactory(AbstractVectorFactory):
|
||||
config=ChromaConfig(
|
||||
host=dify_config.CHROMA_HOST or "",
|
||||
port=dify_config.CHROMA_PORT,
|
||||
tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT, # pyright: ignore[reportPrivateImportUsage]
|
||||
database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE, # pyright: ignore[reportPrivateImportUsage]
|
||||
tenant=dify_config.CHROMA_TENANT or chromadb.DEFAULT_TENANT,
|
||||
database=dify_config.CHROMA_DATABASE or chromadb.DEFAULT_DATABASE,
|
||||
auth_provider=dify_config.CHROMA_AUTH_PROVIDER,
|
||||
auth_credentials=dify_config.CHROMA_AUTH_CREDENTIALS,
|
||||
),
|
||||
|
||||
@@ -59,7 +59,7 @@ class CouchbaseVector(BaseVector):
|
||||
|
||||
auth = PasswordAuthenticator(config.user, config.password)
|
||||
options = ClusterOptions(auth)
|
||||
self._cluster = Cluster(config.connection_string, options) # pyright: ignore[reportArgumentType]
|
||||
self._cluster = Cluster(config.connection_string, options)
|
||||
self._bucket = self._cluster.bucket(config.bucket_name)
|
||||
self._scope = self._bucket.scope(config.scope_name)
|
||||
self._bucket_name = config.bucket_name
|
||||
@@ -306,7 +306,7 @@ class CouchbaseVector(BaseVector):
|
||||
def search_by_full_text(self, query: str, **kwargs: Any) -> list[Document]:
|
||||
top_k = kwargs.get("top_k", 4)
|
||||
try:
|
||||
CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyright: ignore[reportCallIssue]
|
||||
CBrequest = search.SearchRequest.create(search.QueryStringQuery("text:" + query)) # pyrefly: ignore[bad-argument-count]
|
||||
search_iter = self._scope.search(
|
||||
self._collection_name + "_search", CBrequest, SearchOptions(limit=top_k, fields=["*"])
|
||||
)
|
||||
|
||||
@@ -81,14 +81,15 @@ class OpenSearchConfig(BaseModel):
|
||||
pool_maxsize=20,
|
||||
)
|
||||
|
||||
if self.auth_method == "basic":
|
||||
logger.info("Using basic authentication for OpenSearch Vector DB")
|
||||
match self.auth_method:
|
||||
case AuthMethod.BASIC:
|
||||
logger.info("Using basic authentication for OpenSearch Vector DB")
|
||||
|
||||
params["http_auth"] = (self.user, self.password)
|
||||
elif self.auth_method == "aws_managed_iam":
|
||||
logger.info("Using AWS managed IAM role for OpenSearch Vector DB")
|
||||
params["http_auth"] = (self.user, self.password)
|
||||
case AuthMethod.AWS_MANAGED_IAM:
|
||||
logger.info("Using AWS managed IAM role for OpenSearch Vector DB")
|
||||
|
||||
params["http_auth"] = self.create_aws_managed_iam_auth()
|
||||
params["http_auth"] = self.create_aws_managed_iam_auth()
|
||||
|
||||
return params
|
||||
|
||||
|
||||
@@ -471,7 +471,7 @@ class QdrantVector(BaseVector):
|
||||
|
||||
def _reload_if_needed(self):
|
||||
if isinstance(self._client, QdrantLocal):
|
||||
self._client._load() # pyright: ignore[reportPrivateUsage]
|
||||
self._client._load()
|
||||
|
||||
@classmethod
|
||||
def _document_from_scored_point(
|
||||
|
||||
+10
-11
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.14.0"
|
||||
version = "1.14.1"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.3.0",
|
||||
"boto3>=1.43.3",
|
||||
"boto3>=1.43.6",
|
||||
"celery>=5.6.3",
|
||||
"croniter>=6.2.2",
|
||||
"flask>=3.1.3,<4.0.0",
|
||||
@@ -14,15 +14,14 @@ dependencies = [
|
||||
"gevent>=26.4.0",
|
||||
"gevent-websocket>=0.10.1",
|
||||
"gmpy2>=2.3.0",
|
||||
"google-api-python-client>=2.195.0",
|
||||
"gunicorn>=25.3.0",
|
||||
"google-api-python-client>=2.196.0",
|
||||
"gunicorn>=26.0.0",
|
||||
"psycogreen>=1.0.2",
|
||||
"psycopg2-binary>=2.9.12",
|
||||
"python-socketio>=5.13.0",
|
||||
"redis[hiredis]>=7.4.0",
|
||||
"sendgrid>=6.12.5",
|
||||
"sseclient-py>=1.8.0",
|
||||
|
||||
# Stable: production-proven, cap below the next major
|
||||
"aliyun-log-python-sdk>=0.9.44,<1.0.0",
|
||||
"azure-identity>=1.25.3,<2.0.0",
|
||||
@@ -31,7 +30,7 @@ dependencies = [
|
||||
"flask-migrate>=4.1.0,<5.0.0",
|
||||
"flask-orjson>=2.0.0,<3.0.0",
|
||||
"flask-restx>=1.3.2,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.149.0,<2.0.0",
|
||||
"google-cloud-aiplatform>=1.151.0,<2.0.0",
|
||||
"httpx[socks]>=0.28.1,<1.0.0",
|
||||
"opentelemetry-distro>=0.62b1,<1.0.0",
|
||||
"opentelemetry-instrumentation-celery>=0.62b0,<1.0.0",
|
||||
@@ -42,10 +41,10 @@ dependencies = [
|
||||
"opentelemetry-propagator-b3>=1.41.1,<2.0.0",
|
||||
"readabilipy>=0.3.0,<1.0.0",
|
||||
"resend>=2.27.0,<3.0.0",
|
||||
|
||||
# Emerging: newer and fast-moving, use compatible pins
|
||||
"dify-agent",
|
||||
"fastopenapi[flask]~=0.7.0",
|
||||
"graphon~=0.3.0",
|
||||
"graphon~=0.3.1",
|
||||
"httpx-sse~=0.4.0",
|
||||
"json-repair~=0.59.4",
|
||||
]
|
||||
@@ -60,6 +59,7 @@ members = ["providers/vdb/*", "providers/trace/*"]
|
||||
exclude = ["providers/vdb/__pycache__", "providers/trace/__pycache__"]
|
||||
|
||||
[tool.uv.sources]
|
||||
dify-agent = { path = "../dify-agent" }
|
||||
dify-vdb-alibabacloud-mysql = { workspace = true }
|
||||
dify-vdb-analyticdb = { workspace = true }
|
||||
dify-vdb-baidu = { workspace = true }
|
||||
@@ -118,7 +118,6 @@ dev = [
|
||||
"dotenv-linter>=0.7.0",
|
||||
"faker>=40.15.0",
|
||||
"lxml-stubs>=0.5.1",
|
||||
"basedpyright>=1.39.3",
|
||||
"ruff>=0.15.12",
|
||||
"pytest>=9.0.3",
|
||||
"pytest-benchmark>=5.2.3",
|
||||
@@ -175,7 +174,7 @@ dev = [
|
||||
# "locust>=2.40.4", # Temporarily removed due to compatibility issues. Uncomment when resolved.
|
||||
"pytest-timeout>=2.4.0",
|
||||
"pytest-xdist>=3.8.0",
|
||||
"pyrefly>=0.64.0",
|
||||
"pyrefly>=1.0.0",
|
||||
"xinference-client>=2.7.0",
|
||||
]
|
||||
|
||||
@@ -191,7 +190,7 @@ storage = [
|
||||
"google-cloud-storage>=3.10.1",
|
||||
"opendal>=0.46.0",
|
||||
"oss2>=2.19.1",
|
||||
"supabase>=2.29.0",
|
||||
"supabase>=2.30.0",
|
||||
"tos>=2.9.0",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
{
|
||||
"include": ["."],
|
||||
"exclude": [
|
||||
"tests/",
|
||||
".venv",
|
||||
"migrations/",
|
||||
"core/rag",
|
||||
"providers/vdb/",
|
||||
"providers/trace/*/tests",
|
||||
],
|
||||
"typeCheckingMode": "strict",
|
||||
"allowedUntypedLibraries": [
|
||||
"fastopenapi",
|
||||
"flask_restx",
|
||||
"flask_login",
|
||||
"opentelemetry.instrumentation.celery",
|
||||
"opentelemetry.instrumentation.flask",
|
||||
"opentelemetry.instrumentation.httpx",
|
||||
"opentelemetry.instrumentation.requests",
|
||||
"opentelemetry.instrumentation.sqlalchemy",
|
||||
"opentelemetry.instrumentation.redis",
|
||||
"langfuse",
|
||||
"cloudscraper",
|
||||
"readabilipy",
|
||||
"pypandoc",
|
||||
"pypdfium2",
|
||||
"webvtt",
|
||||
"flask_compress",
|
||||
"oss2",
|
||||
"baidubce.auth.bce_credentials",
|
||||
"baidubce.bce_client_configuration",
|
||||
"baidubce.services.bos.bos_client",
|
||||
"clickzetta",
|
||||
"google.cloud",
|
||||
"obs",
|
||||
"qcloud_cos",
|
||||
"tos",
|
||||
"gmpy2",
|
||||
"sendgrid",
|
||||
"sendgrid.helpers.mail",
|
||||
"holo_search_sdk.types",
|
||||
"dify_vdb_qdrant",
|
||||
"dify_vdb_tidb_on_qdrant"
|
||||
],
|
||||
"reportUnknownMemberType": "hint",
|
||||
"reportUnknownParameterType": "hint",
|
||||
"reportUnknownArgumentType": "hint",
|
||||
"reportUnknownVariableType": "hint",
|
||||
"reportUnknownLambdaType": "hint",
|
||||
"reportMissingParameterType": "hint",
|
||||
"reportMissingTypeArgument": "hint",
|
||||
"reportUnnecessaryComparison": "hint",
|
||||
"reportUnnecessaryIsInstance": "hint",
|
||||
"reportUnnecessaryTypeIgnoreComment": "hint",
|
||||
"reportAttributeAccessIssue": "hint",
|
||||
"pythonVersion": "3.12",
|
||||
"pythonPlatform": "All"
|
||||
}
|
||||
@@ -1280,8 +1280,8 @@ class TenantService:
|
||||
"""Check member permission"""
|
||||
perms = {
|
||||
"add": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"remove": [TenantAccountRole.OWNER],
|
||||
"update": [TenantAccountRole.OWNER],
|
||||
"remove": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
"update": [TenantAccountRole.OWNER, TenantAccountRole.ADMIN],
|
||||
}
|
||||
if action not in {"add", "remove", "update"}:
|
||||
raise InvalidActionError("Invalid action.")
|
||||
@@ -1299,6 +1299,15 @@ class TenantService:
|
||||
if not ta_operator or ta_operator.role not in perms[action]:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
if action == "remove" and ta_operator.role == TenantAccountRole.ADMIN and member:
|
||||
ta_member = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
.where(TenantAccountJoin.tenant_id == tenant.id, TenantAccountJoin.account_id == member.id)
|
||||
.limit(1)
|
||||
)
|
||||
if ta_member and ta_member.role == TenantAccountRole.OWNER:
|
||||
raise NoPermissionError(f"No permission to {action} member.")
|
||||
|
||||
@staticmethod
|
||||
def remove_member_from_tenant(tenant: Tenant, account: Account, operator: Account):
|
||||
"""Remove member from tenant.
|
||||
@@ -1370,6 +1379,7 @@ class TenantService:
|
||||
def update_member_role(tenant: Tenant, member: Account, new_role: str, operator: Account):
|
||||
"""Update member role"""
|
||||
TenantService.check_member_permission(tenant, operator, member, "update")
|
||||
new_tenant_role = TenantAccountRole(new_role)
|
||||
|
||||
target_member_join = db.session.scalar(
|
||||
select(TenantAccountJoin)
|
||||
@@ -1380,6 +1390,11 @@ class TenantService:
|
||||
if not target_member_join:
|
||||
raise MemberNotInTenantError("Member not in tenant.")
|
||||
|
||||
operator_role = TenantService.get_user_role(operator, tenant)
|
||||
target_role = TenantAccountRole(target_member_join.role)
|
||||
if operator_role == TenantAccountRole.ADMIN and (TenantAccountRole.OWNER in {target_role, new_tenant_role}):
|
||||
raise NoPermissionError("No permission to update member.")
|
||||
|
||||
if target_member_join.role == new_role:
|
||||
raise RoleAlreadyAssignedError("The provided role is already assigned to the member.")
|
||||
|
||||
@@ -1394,7 +1409,7 @@ class TenantService:
|
||||
current_owner_join.role = TenantAccountRole.ADMIN
|
||||
|
||||
# Update the role of the target member
|
||||
target_member_join.role = TenantAccountRole(new_role)
|
||||
target_member_join.role = new_tenant_role
|
||||
db.session.commit()
|
||||
|
||||
@staticmethod
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user