mirror of
https://github.com/langgenius/dify.git
synced 2026-05-28 04:43:33 +08:00
Compare commits
269 Commits
codex/app-
...
refactor/c
| Author | SHA1 | Date | |
|---|---|---|---|
| 6be223ac3f | |||
| 13ac79780e | |||
| 69b6a4ca5a | |||
| 5e9d9f091e | |||
| b2710b875b | |||
| 6464255d33 | |||
| 50face5760 | |||
| b034449a0c | |||
| a8d380bcaf | |||
| bee21c9f86 | |||
| cab215e209 | |||
| 7ae4ca9a60 | |||
| d342ff1a1e | |||
| 4384d8910e | |||
| fc773b9f57 | |||
| 6e1e0d9439 | |||
| 5c5a6e83e5 | |||
| dade318f00 | |||
| ebff9a3639 | |||
| 58b8fc21d4 | |||
| e0ad088657 | |||
| 323b2b82e0 | |||
| 7d45335a32 | |||
| f5d664887b | |||
| 5aa24c25d9 | |||
| eed8d659d1 | |||
| 59e99ee1ae | |||
| 533929d314 | |||
| fb07b43107 | |||
| 0dad426101 | |||
| 2a1df4de62 | |||
| 2b97f6c8c2 | |||
| 75d6511284 | |||
| fd059720e5 | |||
| 2a5f7bb1aa | |||
| 0f06aa2fdd | |||
| 884e2b864b | |||
| a728e0ac69 | |||
| 7d464d014c | |||
| 0ce0127e7e | |||
| 25da7ae0d9 | |||
| 4d6f8eba2a | |||
| 87268f0662 | |||
| 135e01930b | |||
| fe86fa31ec | |||
| b1f0a11d84 | |||
| fbfb4b3a00 | |||
| 3a467d1d63 | |||
| 23539c5bcc | |||
| 9ddd98a265 | |||
| ecfee2f072 | |||
| 345ba80942 | |||
| e617435d03 | |||
| 5f7eb7bde9 | |||
| eb41c9b769 | |||
| 8876efb419 | |||
| adb14d23de | |||
| 6f1623e02a | |||
| 67d99723ea | |||
| 639e12a306 | |||
| ed17b6161f | |||
| baf0cf8e4e | |||
| 1e9c94b788 | |||
| ffd336cfe8 | |||
| fc4178476a | |||
| 6133c2ab6a | |||
| 603532863d | |||
| a8ca0d47b9 | |||
| 7b1aa33ad4 | |||
| 5645ea0def | |||
| 6b1b1f3790 | |||
| 7c65975507 | |||
| 72ee50c74f | |||
| 8d99326fb3 | |||
| 2a0c098857 | |||
| 790ca72627 | |||
| 4d8b6c7dc0 | |||
| 473c945839 | |||
| a698c60b29 | |||
| 24bab5fb2a | |||
| 93b7a81071 | |||
| 157e6244dd | |||
| 964aaad7ed | |||
| 92181dbe09 | |||
| 30deef45d9 | |||
| ee28074390 | |||
| 1fb491337b | |||
| 82b0a03f5a | |||
| 6185016910 | |||
| b4f5f4869f | |||
| 7ecbed3b04 | |||
| 5b58defd62 | |||
| 73196de5e1 | |||
| ea5e487d3c | |||
| f19702f76c | |||
| 092c8bca81 | |||
| c50d504c44 | |||
| 1b4356b66a | |||
| 7f633622aa | |||
| 66f5ab4cfc | |||
| 0cf9597f52 | |||
| 60cd346fa6 | |||
| 56d4d54c16 | |||
| 9f9cb4d17e | |||
| 7d0d9019d8 | |||
| d646bcf257 | |||
| e3b45a48eb | |||
| 848c15a265 | |||
| be8627233d | |||
| 1fe8b7fb1d | |||
| 5a585c8618 | |||
| cc9b90a5ae | |||
| b64d4b53ca | |||
| 5cdf4e405b | |||
| 7cb14cb4cc | |||
| de38bba99b | |||
| f04d809426 | |||
| 7ed3c7c500 | |||
| 77f1aeb1ac | |||
| 7bc5c89e3c | |||
| 718ab8433e | |||
| 8f197c5a0a | |||
| 0295862d0d | |||
| 2b2a5824c1 | |||
| 468cc19e68 | |||
| 77333e57a7 | |||
| f52491e2c1 | |||
| 05408af8a1 | |||
| d3ae074456 | |||
| 0b48a7e991 | |||
| 809f513ccb | |||
| d9e90d0fa0 | |||
| d1417bbe4b | |||
| 2565637e36 | |||
| cae9923e5a | |||
| a328bbbced | |||
| 5276eb689b | |||
| 4b2badb6f2 | |||
| 34a89416f7 | |||
| a13ab76002 | |||
| b04b4449db | |||
| 674cdc3521 | |||
| 2031d31ee8 | |||
| 04d62867af | |||
| 7f392b6950 | |||
| b0a3399774 | |||
| 2d5186fb28 | |||
| 06f076e0ff | |||
| 5b79f7e99d | |||
| 1cee1a25b6 | |||
| c0f237bf35 | |||
| 75d7fc0526 | |||
| c057b5c5ff | |||
| 5468c4ec96 | |||
| f4c02e4c6b | |||
| 9dc95eeb20 | |||
| 76bba64b79 | |||
| 59e96fbb2a | |||
| 06ea0f7ac2 | |||
| 730a0bef9e | |||
| 2eb37caf2e | |||
| 7e8147295b | |||
| c07686928a | |||
| d1238180ed | |||
| 969760364d | |||
| ceabfeb3a7 | |||
| c407f40e0d | |||
| 28818f2e2a | |||
| e2c52c9b0f | |||
| 1925d58369 | |||
| b79fc5d6b4 | |||
| 6649e4025e | |||
| b96f372f45 | |||
| 127fbf2c9a | |||
| 3c70d28064 | |||
| cd4d6f8a22 | |||
| 9d0906c684 | |||
| 41b6f894c0 | |||
| e7e6fe8813 | |||
| c0bdd6792f | |||
| 27b084c4d4 | |||
| 3f7a68fc77 | |||
| a252fbddfa | |||
| ff02636a4b | |||
| 63946d829e | |||
| cdcfd2ef2c | |||
| b04a3851cc | |||
| b41338cd08 | |||
| 28153df4d3 | |||
| 3bc3386535 | |||
| 7654f14241 | |||
| 194b54bae4 | |||
| 0e16d36edb | |||
| 432a6412a3 | |||
| 55d05fe52d | |||
| 0d500e6965 | |||
| 5798610f27 | |||
| a35b28dbef | |||
| 1a4288c811 | |||
| 9dc32f2318 | |||
| 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
|
```typescript
|
||||||
// ❌ Before: Complex state logic in component
|
// ❌ Before: Complex state logic in component
|
||||||
const Configuration: FC = () => {
|
function Configuration() {
|
||||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||||
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
||||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||||
@ -85,7 +85,7 @@ export const useModelConfig = (appId: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Component becomes cleaner
|
// Component becomes cleaner
|
||||||
const Configuration: FC = () => {
|
function Configuration() {
|
||||||
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
||||||
return <div>...</div>
|
return <div>...</div>
|
||||||
}
|
}
|
||||||
@ -189,8 +189,6 @@ const Template = useMemo(() => {
|
|||||||
|
|
||||||
**Dify Convention**:
|
**Dify Convention**:
|
||||||
- This skill is for component decomposition, not query/mutation design.
|
- 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 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.
|
- 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):
|
**After** (complexity: ~3):
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
import type { ComponentType } from 'react'
|
||||||
|
|
||||||
// Define lookup table outside component
|
// 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]: {
|
[AppModeEnum.CHAT]: {
|
||||||
[LanguagesSupported[1]]: TemplateChatZh,
|
[LanguagesSupported[1]]: TemplateChatZh,
|
||||||
[LanguagesSupported[7]]: TemplateChatJa,
|
[LanguagesSupported[7]]: TemplateChatJa,
|
||||||
|
|||||||
@ -65,10 +65,10 @@ interface ConfigurationHeaderProps {
|
|||||||
onPublish: () => void
|
onPublish: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const ConfigurationHeader: FC<ConfigurationHeaderProps> = ({
|
function ConfigurationHeader({
|
||||||
isAdvancedMode,
|
isAdvancedMode,
|
||||||
onPublish,
|
onPublish,
|
||||||
}) => {
|
}: ConfigurationHeaderProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -136,7 +136,7 @@ const AppInfo = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ✅ After: Separate view components
|
// ✅ After: Separate view components
|
||||||
const AppInfoExpanded: FC<AppInfoViewProps> = ({ appDetail, onAction }) => {
|
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
|
||||||
return (
|
return (
|
||||||
<div className="expanded">
|
<div className="expanded">
|
||||||
{/* Clean, focused expanded view */}
|
{/* 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 (
|
return (
|
||||||
<div className="collapsed">
|
<div className="collapsed">
|
||||||
{/* Clean, focused collapsed view */}
|
{/* Clean, focused collapsed view */}
|
||||||
@ -203,12 +203,12 @@ interface AppInfoModalsProps {
|
|||||||
onSuccess: () => void
|
onSuccess: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const AppInfoModals: FC<AppInfoModalsProps> = ({
|
function AppInfoModals({
|
||||||
appDetail,
|
appDetail,
|
||||||
activeModal,
|
activeModal,
|
||||||
onClose,
|
onClose,
|
||||||
onSuccess,
|
onSuccess,
|
||||||
}) => {
|
}: AppInfoModalsProps) {
|
||||||
const handleEdit = async (data) => { /* logic */ }
|
const handleEdit = async (data) => { /* logic */ }
|
||||||
const handleDuplicate = async (data) => { /* logic */ }
|
const handleDuplicate = async (data) => { /* logic */ }
|
||||||
const handleDelete = async () => { /* logic */ }
|
const handleDelete = async () => { /* logic */ }
|
||||||
@ -296,7 +296,7 @@ interface OperationItemProps {
|
|||||||
onAction: (id: string) => void
|
onAction: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const OperationItem: FC<OperationItemProps> = ({ operation, onAction }) => {
|
function OperationItem({ operation, onAction }: OperationItemProps) {
|
||||||
return (
|
return (
|
||||||
<div className="operation-item">
|
<div className="operation-item">
|
||||||
<span className="icon">{operation.icon}</span>
|
<span className="icon">{operation.icon}</span>
|
||||||
@ -435,7 +435,7 @@ interface ChildProps {
|
|||||||
onSubmit: () => void
|
onSubmit: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const Child: FC<ChildProps> = ({ value, onChange, onSubmit }) => {
|
function Child({ value, onChange, onSubmit }: ChildProps) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<input value={value} onChange={e => onChange(e.target.value)} />
|
<input value={value} onChange={e => onChange(e.target.value)} />
|
||||||
|
|||||||
@ -112,13 +112,13 @@ export const useModelConfig = ({
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// Before: 50+ lines of state management
|
// Before: 50+ lines of state management
|
||||||
const Configuration: FC = () => {
|
function Configuration() {
|
||||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||||
// ... lots of related state and effects
|
// ... lots of related state and effects
|
||||||
}
|
}
|
||||||
|
|
||||||
// After: Clean component
|
// After: Clean component
|
||||||
const Configuration: FC = () => {
|
function Configuration() {
|
||||||
const {
|
const {
|
||||||
modelConfig,
|
modelConfig,
|
||||||
setModelConfig,
|
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.
|
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 introduce deprecated `useInvalid` / `useReset`.
|
||||||
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
|
- 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
|
- `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.
|
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.
|
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
|
## 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.
|
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
|
Category: Performance
|
||||||
|
|
||||||
### Description
|
### 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.
|
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||||
|
|
||||||
Wrong:
|
Risky:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
<HeavyComp
|
<HeavyComp
|
||||||
@ -31,7 +31,7 @@ Wrong:
|
|||||||
/>
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
Right:
|
Better when stable identity matters:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
const config = useMemo(() => ({
|
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
|
# 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.*`).
|
> **⚠️ 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:
|
**Do NOT apply** when:
|
||||||
|
|
||||||
- User is asking about backend/API tests (Python/pytest)
|
- 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
|
- User is only asking conceptual questions without code context
|
||||||
|
|
||||||
## Quick Reference
|
## 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
|
### Key Commands
|
||||||
|
|
||||||
|
Run these commands from `web/`. From the repository root, prefix them with `pnpm -C web`.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run all tests
|
# Run all tests
|
||||||
pnpm test
|
pnpm test
|
||||||
|
|
||||||
# Watch mode
|
# Watch mode
|
||||||
pnpm test:watch
|
pnpm test --watch
|
||||||
|
|
||||||
# Run specific file
|
# Run specific file
|
||||||
pnpm test path/to/file.spec.tsx
|
pnpm test path/to/file.spec.tsx
|
||||||
|
|
||||||
# Generate coverage report
|
# Generate coverage report
|
||||||
pnpm test:coverage
|
pnpm test --coverage
|
||||||
|
|
||||||
# Analyze component complexity
|
# Analyze component complexity
|
||||||
pnpm analyze-component <path>
|
pnpm analyze-component <path>
|
||||||
@ -228,7 +220,10 @@ Every test should clearly separate:
|
|||||||
### 2. Black-Box Testing
|
### 2. Black-Box Testing
|
||||||
|
|
||||||
- Test observable behavior, not implementation details
|
- 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
|
- Avoid testing internal state directly
|
||||||
- **Prefer pattern matching over hardcoded strings** in assertions:
|
- **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 |
|
| 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__/zustand.ts` | Zustand mock implementation (auto-resets stores after each test) |
|
||||||
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
| `web/__mocks__/` | Reusable mock factories shared across multiple test files |
|
||||||
| Test file | Test-specific mocks, inline with `vi.mock()` |
|
| 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
|
```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', () => {
|
describe('GithubComponent', () => {
|
||||||
afterEach(() => {
|
beforeEach(() => {
|
||||||
nock.cleanAll()
|
vi.clearAllMocks()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display repo info', async () => {
|
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 />)
|
render(<GithubComponent />)
|
||||||
|
|
||||||
@ -247,7 +240,12 @@ describe('GithubComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should handle API error', async () => {
|
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 />)
|
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
|
### 6. Context Providers
|
||||||
|
|
||||||
```typescript
|
```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 Zustand store modules** - Use real stores with `setState()`
|
||||||
1. Don't mock components you can import directly
|
1. Don't mock components you can import directly
|
||||||
1. Don't create overly simplified mocks that miss conditional logic
|
1. Don't create overly simplified mocks that miss conditional logic
|
||||||
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
|
1. Don't use `any` types in mocks without necessity
|
||||||
|
|
||||||
### Mock Decision Tree
|
### Mock Decision Tree
|
||||||
|
|||||||
@ -227,12 +227,12 @@ Failing tests compound:
|
|||||||
|
|
||||||
**Fix failures immediately before proceeding.**
|
**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. **Create a todo list** before starting
|
||||||
1. **Request one file at a time** or ensure Claude processes incrementally
|
1. **Process one file at a time**
|
||||||
1. **Verify each test passes** before asking for the next
|
1. **Verify each test passes** before asking for the next
|
||||||
1. **Mark todos complete** as you progress
|
1. **Mark todos complete** as you progress
|
||||||
|
|
||||||
|
|||||||
71
.agents/skills/how-to-write-component/SKILL.md
Normal file
71
.agents/skills/how-to-write-component/SKILL.md
Normal file
@ -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.
|
||||||
|
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
||||||
|
|
||||||
|
## Ownership
|
||||||
|
|
||||||
|
- 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.
|
||||||
@ -1,5 +1,6 @@
|
|||||||
[run]
|
[run]
|
||||||
omit =
|
omit =
|
||||||
|
api/conftest.py
|
||||||
api/tests/*
|
api/tests/*
|
||||||
api/migrations/*
|
api/migrations/*
|
||||||
api/core/rag/datasource/vdb/*
|
api/core/rag/datasource/vdb/*
|
||||||
|
|||||||
15
.dockerignore
Normal file
15
.dockerignore
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
**/node_modules
|
||||||
|
**/.pnpm-store
|
||||||
|
**/dist
|
||||||
|
**/.next
|
||||||
|
**/.turbo
|
||||||
|
**/.cache
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
|
**/.mypy_cache
|
||||||
|
**/.ruff_cache
|
||||||
|
.git
|
||||||
|
.github
|
||||||
|
*.md
|
||||||
|
!web/README.md
|
||||||
|
!api/README.md
|
||||||
4
.gitattributes
vendored
4
.gitattributes
vendored
@ -5,3 +5,7 @@
|
|||||||
# them.
|
# them.
|
||||||
|
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
|
|
||||||
|
# Codegen output must stay byte-identical across platforms so
|
||||||
|
# `pnpm tree:check` in CI does not trip on CRLF rewrites.
|
||||||
|
*.generated.ts text eol=lf
|
||||||
|
|||||||
64
.github/CODEOWNERS
vendored
64
.github/CODEOWNERS
vendored
@ -4,7 +4,7 @@
|
|||||||
# Owners can be @username, @org/team-name, or email addresses.
|
# Owners can be @username, @org/team-name, or email addresses.
|
||||||
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
# For more information, see: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners
|
||||||
|
|
||||||
* @crazywoola @laipz8200 @Yeuoly
|
* @crazywoola @laipz8200
|
||||||
|
|
||||||
# ESLint suppression file is maintained by autofix.ci pruning.
|
# ESLint suppression file is maintained by autofix.ci pruning.
|
||||||
/eslint-suppressions.json
|
/eslint-suppressions.json
|
||||||
@ -18,6 +18,10 @@
|
|||||||
# Docs
|
# Docs
|
||||||
/docs/ @crazywoola
|
/docs/ @crazywoola
|
||||||
|
|
||||||
|
# CLI
|
||||||
|
/cli/ @langgenius/maintainers
|
||||||
|
/.github/workflows/cli-tests.yml @langgenius/maintainers
|
||||||
|
|
||||||
# Backend (default owner, more specific rules below will override)
|
# Backend (default owner, more specific rules below will override)
|
||||||
/api/ @QuantumGhost
|
/api/ @QuantumGhost
|
||||||
|
|
||||||
@ -85,39 +89,39 @@
|
|||||||
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong
|
/api/tasks/deal_dataset_vector_index_task.py @JohnJyong
|
||||||
|
|
||||||
# Backend - Plugins
|
# Backend - Plugins
|
||||||
/api/core/plugin/ @Mairuis @Yeuoly @Stream29
|
/api/core/plugin/ @WH-2099
|
||||||
/api/services/plugin/ @Mairuis @Yeuoly @Stream29
|
/api/services/plugin/ @WH-2099
|
||||||
/api/controllers/console/workspace/plugin.py @Mairuis @Yeuoly @Stream29
|
/api/controllers/console/workspace/plugin.py @WH-2099
|
||||||
/api/controllers/inner_api/plugin/ @Mairuis @Yeuoly @Stream29
|
/api/controllers/inner_api/plugin/ @WH-2099
|
||||||
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @Mairuis @Yeuoly @Stream29
|
/api/tasks/process_tenant_plugin_autoupgrade_check_task.py @WH-2099
|
||||||
|
|
||||||
# Backend - Trigger/Schedule/Webhook
|
# Backend - Trigger/Schedule/Webhook
|
||||||
/api/controllers/trigger/ @Mairuis @Yeuoly
|
/api/controllers/trigger/ @CourTeous33
|
||||||
/api/controllers/console/app/workflow_trigger.py @Mairuis @Yeuoly
|
/api/controllers/console/app/workflow_trigger.py @CourTeous33
|
||||||
/api/controllers/console/workspace/trigger_providers.py @Mairuis @Yeuoly
|
/api/controllers/console/workspace/trigger_providers.py @CourTeous33
|
||||||
/api/core/trigger/ @Mairuis @Yeuoly
|
/api/core/trigger/ @CourTeous33
|
||||||
/api/core/app/layers/trigger_post_layer.py @Mairuis @Yeuoly
|
/api/core/app/layers/trigger_post_layer.py @CourTeous33
|
||||||
/api/services/trigger/ @Mairuis @Yeuoly
|
/api/services/trigger/ @CourTeous33
|
||||||
/api/models/trigger.py @Mairuis @Yeuoly
|
/api/models/trigger.py @CourTeous33
|
||||||
/api/fields/workflow_trigger_fields.py @Mairuis @Yeuoly
|
/api/fields/workflow_trigger_fields.py @CourTeous33
|
||||||
/api/repositories/workflow_trigger_log_repository.py @Mairuis @Yeuoly
|
/api/repositories/workflow_trigger_log_repository.py @CourTeous33
|
||||||
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @Mairuis @Yeuoly
|
/api/repositories/sqlalchemy_workflow_trigger_log_repository.py @CourTeous33
|
||||||
/api/libs/schedule_utils.py @Mairuis @Yeuoly
|
/api/libs/schedule_utils.py @CourTeous33
|
||||||
/api/services/workflow/scheduler.py @Mairuis @Yeuoly
|
/api/services/workflow/scheduler.py @CourTeous33
|
||||||
/api/schedule/trigger_provider_refresh_task.py @Mairuis @Yeuoly
|
/api/schedule/trigger_provider_refresh_task.py @CourTeous33
|
||||||
/api/schedule/workflow_schedule_task.py @Mairuis @Yeuoly
|
/api/schedule/workflow_schedule_task.py @CourTeous33
|
||||||
/api/tasks/trigger_processing_tasks.py @Mairuis @Yeuoly
|
/api/tasks/trigger_processing_tasks.py @CourTeous33
|
||||||
/api/tasks/trigger_subscription_refresh_tasks.py @Mairuis @Yeuoly
|
/api/tasks/trigger_subscription_refresh_tasks.py @CourTeous33
|
||||||
/api/tasks/workflow_schedule_tasks.py @Mairuis @Yeuoly
|
/api/tasks/workflow_schedule_tasks.py @CourTeous33
|
||||||
/api/tasks/workflow_cfs_scheduler/ @Mairuis @Yeuoly
|
/api/tasks/workflow_cfs_scheduler/ @CourTeous33
|
||||||
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @Mairuis @Yeuoly
|
/api/events/event_handlers/sync_plugin_trigger_when_app_created.py @CourTeous33
|
||||||
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @Mairuis @Yeuoly
|
/api/events/event_handlers/update_app_triggers_when_app_published_workflow_updated.py @CourTeous33
|
||||||
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @Mairuis @Yeuoly
|
/api/events/event_handlers/sync_workflow_schedule_when_app_published.py @CourTeous33
|
||||||
/api/events/event_handlers/sync_webhook_when_app_created.py @Mairuis @Yeuoly
|
/api/events/event_handlers/sync_webhook_when_app_created.py @CourTeous33
|
||||||
|
|
||||||
# Backend - Async Workflow
|
# Backend - Async Workflow
|
||||||
/api/services/async_workflow_service.py @Mairuis @Yeuoly
|
/api/services/async_workflow_service.py @Mairuis
|
||||||
/api/tasks/async_workflow_tasks.py @Mairuis @Yeuoly
|
/api/tasks/async_workflow_tasks.py @Mairuis
|
||||||
|
|
||||||
# Backend - Billing
|
# Backend - Billing
|
||||||
/api/services/billing_service.py @hj24 @zyssyz123
|
/api/services/billing_service.py @hj24 @zyssyz123
|
||||||
|
|||||||
7
.github/actions/setup-web/action.yml
vendored
7
.github/actions/setup-web/action.yml
vendored
@ -1,10 +1,15 @@
|
|||||||
name: Setup Web Environment
|
name: Setup Web Environment
|
||||||
|
description: Set up Node.js, Vite+, pnpm, and web dependencies
|
||||||
|
|
||||||
runs:
|
runs:
|
||||||
using: composite
|
using: composite
|
||||||
steps:
|
steps:
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
- name: Setup Vite+
|
- name: Setup Vite+
|
||||||
uses: voidzero-dev/setup-vp@4f5aa3e38c781f1b01e78fb9255527cee8a6efa6 # v1.8.0
|
uses: voidzero-dev/setup-vp@ca1c46663915d6c1042ae23bd39ab85718bfb0fa # v1.10.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
111
.github/dependabot.yml
vendored
111
.github/dependabot.yml
vendored
@ -110,3 +110,114 @@ updates:
|
|||||||
github-actions-dependencies:
|
github-actions-dependencies:
|
||||||
patterns:
|
patterns:
|
||||||
- "*"
|
- "*"
|
||||||
|
- package-ecosystem: "uv"
|
||||||
|
directory: "/api"
|
||||||
|
target-branch: "lts/1.13.x"
|
||||||
|
open-pull-requests-limit: 10
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
flask:
|
||||||
|
patterns:
|
||||||
|
- "flask"
|
||||||
|
- "flask-*"
|
||||||
|
- "werkzeug"
|
||||||
|
- "gunicorn"
|
||||||
|
google:
|
||||||
|
patterns:
|
||||||
|
- "google-*"
|
||||||
|
- "googleapis-*"
|
||||||
|
opentelemetry:
|
||||||
|
patterns:
|
||||||
|
- "opentelemetry-*"
|
||||||
|
pydantic:
|
||||||
|
patterns:
|
||||||
|
- "pydantic"
|
||||||
|
- "pydantic-*"
|
||||||
|
llm:
|
||||||
|
patterns:
|
||||||
|
- "langfuse"
|
||||||
|
- "langsmith"
|
||||||
|
- "litellm"
|
||||||
|
- "mlflow*"
|
||||||
|
- "opik"
|
||||||
|
- "weave*"
|
||||||
|
- "arize*"
|
||||||
|
- "tiktoken"
|
||||||
|
- "transformers"
|
||||||
|
database:
|
||||||
|
patterns:
|
||||||
|
- "sqlalchemy"
|
||||||
|
- "psycopg2*"
|
||||||
|
- "psycogreen"
|
||||||
|
- "redis*"
|
||||||
|
- "alembic*"
|
||||||
|
storage:
|
||||||
|
patterns:
|
||||||
|
- "boto3*"
|
||||||
|
- "botocore*"
|
||||||
|
- "azure-*"
|
||||||
|
- "bce-*"
|
||||||
|
- "cos-python-*"
|
||||||
|
- "esdk-obs-*"
|
||||||
|
- "google-cloud-storage"
|
||||||
|
- "opendal"
|
||||||
|
- "oss2"
|
||||||
|
- "supabase*"
|
||||||
|
- "tos*"
|
||||||
|
vdb:
|
||||||
|
patterns:
|
||||||
|
- "alibabacloud*"
|
||||||
|
- "chromadb"
|
||||||
|
- "clickhouse-*"
|
||||||
|
- "clickzetta-*"
|
||||||
|
- "couchbase"
|
||||||
|
- "elasticsearch"
|
||||||
|
- "opensearch-py"
|
||||||
|
- "oracledb"
|
||||||
|
- "pgvect*"
|
||||||
|
- "pymilvus"
|
||||||
|
- "pymochow"
|
||||||
|
- "pyobvector"
|
||||||
|
- "qdrant-client"
|
||||||
|
- "intersystems-*"
|
||||||
|
- "tablestore"
|
||||||
|
- "tcvectordb"
|
||||||
|
- "tidb-vector"
|
||||||
|
- "upstash-*"
|
||||||
|
- "volcengine-*"
|
||||||
|
- "weaviate-*"
|
||||||
|
- "xinference-*"
|
||||||
|
- "mo-vector"
|
||||||
|
- "mysql-connector-*"
|
||||||
|
dev:
|
||||||
|
patterns:
|
||||||
|
- "coverage"
|
||||||
|
- "dotenv-linter"
|
||||||
|
- "faker"
|
||||||
|
- "lxml-stubs"
|
||||||
|
- "basedpyright"
|
||||||
|
- "ruff"
|
||||||
|
- "pytest*"
|
||||||
|
- "types-*"
|
||||||
|
- "boto3-stubs"
|
||||||
|
- "hypothesis"
|
||||||
|
- "pandas-stubs"
|
||||||
|
- "scipy-stubs"
|
||||||
|
- "import-linter"
|
||||||
|
- "celery-types"
|
||||||
|
- "mypy*"
|
||||||
|
- "pyrefly"
|
||||||
|
python-packages:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
target-branch: "lts/1.13.x"
|
||||||
|
open-pull-requests-limit: 5
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
groups:
|
||||||
|
github-actions-dependencies:
|
||||||
|
patterns:
|
||||||
|
- "*"
|
||||||
|
|||||||
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
73
.github/scripts/check-hotfix-cherry-picks.sh
vendored
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BASE_SHA=${BASE_SHA:-}
|
||||||
|
HEAD_SHA=${HEAD_SHA:-}
|
||||||
|
MAIN_REF=${MAIN_REF:-origin/main}
|
||||||
|
REMEDIATION_HINT="Changes should be made from the main branch using git cherry-pick -x."
|
||||||
|
|
||||||
|
error() {
|
||||||
|
printf 'ERROR: %s\n' "$1" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -z "$BASE_SHA" || -z "$HEAD_SHA" ]]; then
|
||||||
|
error "BASE_SHA and HEAD_SHA are required. $REMEDIATION_HINT"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$BASE_SHA^{commit}" > /dev/null 2>&1; then
|
||||||
|
error "Base commit '$BASE_SHA' is not available in the local git checkout."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$HEAD_SHA^{commit}" > /dev/null 2>&1; then
|
||||||
|
error "Head commit '$HEAD_SHA' is not available in the local git checkout."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git rev-parse --verify "$MAIN_REF^{commit}" > /dev/null 2>&1; then
|
||||||
|
error "Main ref '$MAIN_REF' is not available in the local git checkout. $REMEDIATION_HINT"
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
failed=0
|
||||||
|
checked=0
|
||||||
|
|
||||||
|
while IFS= read -r commit_sha; do
|
||||||
|
[[ -n "$commit_sha" ]] || continue
|
||||||
|
|
||||||
|
checked=$((checked + 1))
|
||||||
|
subject=$(git log -1 --format=%s "$commit_sha")
|
||||||
|
source_sha=$(
|
||||||
|
git log -1 --format=%B "$commit_sha" \
|
||||||
|
| sed -nE 's/^\(cherry picked from commit ([0-9a-fA-F]{7,64})\)$/\1/p' \
|
||||||
|
| tail -n 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -z "$source_sha" ]]; then
|
||||||
|
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git cat-file -e "$source_sha^{commit}" 2> /dev/null; then
|
||||||
|
error "Commit $commit_sha ($subject) references source $source_sha, but that commit is not available locally. $REMEDIATION_HINT"
|
||||||
|
failed=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git merge-base --is-ancestor "$source_sha" "$MAIN_REF"; then
|
||||||
|
error "Commit $commit_sha ($subject) references source $source_sha, but that source is not reachable from main ($MAIN_REF). $REMEDIATION_HINT"
|
||||||
|
failed=1
|
||||||
|
fi
|
||||||
|
done < <(git rev-list --reverse "$BASE_SHA..$HEAD_SHA")
|
||||||
|
|
||||||
|
if [[ "$failed" -ne 0 ]]; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$checked" -eq 0 ]]; then
|
||||||
|
echo "No PR commits to check."
|
||||||
|
else
|
||||||
|
echo "Verified $checked PR commit(s) include cherry-pick provenance from main."
|
||||||
|
fi
|
||||||
44
.github/workflows/api-tests.yml
vendored
44
.github/workflows/api-tests.yml
vendored
@ -48,10 +48,23 @@ jobs:
|
|||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Run dify config tests
|
- name: Run dify config tests
|
||||||
run: uv run --project api dev/pytest/pytest_config_tests.py
|
run: uv run --project api pytest api/tests/unit_tests/configs/test_env_consistency.py
|
||||||
|
|
||||||
- name: Run Unit Tests
|
- name: Run Unit Tests
|
||||||
run: uv run --project api bash dev/pytest/pytest_unit_tests.sh
|
run: |
|
||||||
|
uv run --project api pytest \
|
||||||
|
-p no:benchmark \
|
||||||
|
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||||
|
-n auto \
|
||||||
|
api/tests/unit_tests \
|
||||||
|
api/providers/vdb/*/tests/unit_tests \
|
||||||
|
api/providers/trace/*/tests/unit_tests \
|
||||||
|
--ignore=api/tests/unit_tests/controllers
|
||||||
|
# Controller tests register Flask routes at import time, so keep them out of xdist.
|
||||||
|
uv run --project api pytest \
|
||||||
|
--timeout "${PYTEST_TIMEOUT:-20}" \
|
||||||
|
--cov-append \
|
||||||
|
api/tests/unit_tests/controllers
|
||||||
|
|
||||||
- name: Upload unit coverage data
|
- name: Upload unit coverage data
|
||||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||||
@ -96,32 +109,11 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Set up dotenvs
|
|
||||||
run: |
|
|
||||||
cp docker/.env.example docker/.env
|
|
||||||
cp docker/envs/middleware.env.example docker/middleware.env
|
|
||||||
|
|
||||||
- name: Expose Service Ports
|
|
||||||
run: sh .github/workflows/expose_service_ports.sh
|
|
||||||
|
|
||||||
- name: Set up Sandbox
|
|
||||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
|
||||||
with:
|
|
||||||
compose-file: |
|
|
||||||
docker/docker-compose.middleware.yaml
|
|
||||||
services: |
|
|
||||||
db_postgres
|
|
||||||
redis
|
|
||||||
sandbox
|
|
||||||
ssrf_proxy
|
|
||||||
|
|
||||||
- name: setup test config
|
|
||||||
run: |
|
|
||||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
|
||||||
|
|
||||||
- name: Run Integration Tests
|
- name: Run Integration Tests
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest \
|
uv run --project api pytest \
|
||||||
|
-p no:benchmark \
|
||||||
|
--start-middleware \
|
||||||
-n auto \
|
-n auto \
|
||||||
--timeout "${PYTEST_TIMEOUT:-180}" \
|
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
api/tests/integration_tests/workflow \
|
api/tests/integration_tests/workflow \
|
||||||
@ -203,7 +195,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||||
with:
|
with:
|
||||||
files: ./coverage.xml
|
files: ./coverage.xml
|
||||||
disable_search: true
|
disable_search: true
|
||||||
|
|||||||
6
.github/workflows/autofix.yml
vendored
6
.github/workflows/autofix.yml
vendored
@ -120,7 +120,11 @@ jobs:
|
|||||||
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||||
run: |
|
run: |
|
||||||
cd api
|
cd api
|
||||||
uv run dev/generate_swagger_markdown_docs.py --swagger-dir openapi --markdown-dir openapi/markdown
|
uv run dev/generate_swagger_markdown_docs.py --swagger-dir ../packages/contracts/openapi --markdown-dir openapi/markdown --keep-swagger-json
|
||||||
|
|
||||||
|
- name: Generate frontend contracts
|
||||||
|
if: github.event_name != 'merge_group' && steps.api-changes.outputs.any_changed == 'true'
|
||||||
|
run: pnpm --dir packages/contracts gen-api-contract-from-openapi
|
||||||
|
|
||||||
- name: ESLint autofix
|
- name: ESLint autofix
|
||||||
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
|
||||||
|
|||||||
12
.github/workflows/build-push.yml
vendored
12
.github/workflows/build-push.yml
vendored
@ -35,15 +35,15 @@ jobs:
|
|||||||
- service_name: "build-api-amd64"
|
- service_name: "build-api-amd64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
artifact_context: "api"
|
artifact_context: "api"
|
||||||
build_context: "{{defaultContext}}:api"
|
build_context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: depot-ubuntu-24.04-4
|
||||||
- service_name: "build-api-arm64"
|
- service_name: "build-api-arm64"
|
||||||
image_name_env: "DIFY_API_IMAGE_NAME"
|
image_name_env: "DIFY_API_IMAGE_NAME"
|
||||||
artifact_context: "api"
|
artifact_context: "api"
|
||||||
build_context: "{{defaultContext}}:api"
|
build_context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: depot-ubuntu-24.04-4
|
||||||
- service_name: "build-web-amd64"
|
- service_name: "build-web-amd64"
|
||||||
@ -117,8 +117,8 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "validate-api-amd64"
|
- service_name: "validate-api-amd64"
|
||||||
build_context: "{{defaultContext}}:api"
|
build_context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
- service_name: "validate-web-amd64"
|
- service_name: "validate-web-amd64"
|
||||||
build_context: "{{defaultContext}}"
|
build_context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
|
|||||||
88
.github/workflows/cli-release.yml
vendored
Normal file
88
.github/workflows/cli-release.yml
vendored
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
name: CLI Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'difyctl-v*'
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: cli-release-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: build standalone binaries (all targets)
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
if: github.repository == 'langgenius/dify'
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./cli
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Setup web environment
|
||||||
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
|
- name: Setup Bun
|
||||||
|
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||||
|
with:
|
||||||
|
bun-version: latest
|
||||||
|
|
||||||
|
- name: Read cli/package.json
|
||||||
|
id: manifest
|
||||||
|
run: |
|
||||||
|
version=$(node -p "require('./package.json').version")
|
||||||
|
channel=$(node -p "require('./package.json').difyctl.channel")
|
||||||
|
minDify=$(node -p "require('./package.json').difyctl.compat.minDify")
|
||||||
|
maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify")
|
||||||
|
{
|
||||||
|
echo "version=$version"
|
||||||
|
echo "channel=$channel"
|
||||||
|
echo "minDify=$minDify"
|
||||||
|
echo "maxDify=$maxDify"
|
||||||
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Validate manifest
|
||||||
|
run: scripts/release-validate-manifest.sh
|
||||||
|
|
||||||
|
- name: Install cross-arch native prebuilds
|
||||||
|
# Re-installs node_modules with every @napi-rs/keyring platform variant
|
||||||
|
# so `bun build --compile` can embed the right .node into each target.
|
||||||
|
working-directory: ./
|
||||||
|
run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Compile standalone binaries (all targets)
|
||||||
|
env:
|
||||||
|
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||||
|
DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }}
|
||||||
|
DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }}
|
||||||
|
DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }}
|
||||||
|
run: |
|
||||||
|
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||||
|
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||||
|
pnpm build:bin
|
||||||
|
|
||||||
|
- name: Generate sha256 checksum file
|
||||||
|
env:
|
||||||
|
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||||
|
run: scripts/release-write-checksums.sh
|
||||||
|
|
||||||
|
- name: Publish GitHub Release
|
||||||
|
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||||
|
with:
|
||||||
|
tag_name: difyctl-v${{ steps.manifest.outputs.version }}
|
||||||
|
name: difyctl ${{ steps.manifest.outputs.version }}
|
||||||
|
prerelease: ${{ steps.manifest.outputs.channel != 'stable' }}
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
files: |
|
||||||
|
cli/dist/bin/difyctl-v*
|
||||||
60
.github/workflows/cli-smoke.yml
vendored
Normal file
60
.github/workflows/cli-smoke.yml
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
name: CLI Smoke (live dify)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
dify_version:
|
||||||
|
description: "Dify image tag to test against (e.g. 1.7.0)"
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
cli_ref:
|
||||||
|
description: "Git ref to build the cli from (default: current branch)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
steps:
|
||||||
|
- name: Checkout cli ref
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.cli_ref || github.ref }}
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup web environment
|
||||||
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
|
- name: Bring up dify
|
||||||
|
env:
|
||||||
|
DIFY_VERSION: ${{ inputs.dify_version }}
|
||||||
|
run: |
|
||||||
|
cd docker
|
||||||
|
cp .env.example .env
|
||||||
|
DIFY_API_IMAGE_TAG="$DIFY_VERSION" \
|
||||||
|
DIFY_WEB_IMAGE_TAG="$DIFY_VERSION" \
|
||||||
|
docker compose up -d api worker web db redis
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
if curl -fsS http://localhost:5001/health >/dev/null 2>&1; then
|
||||||
|
echo "dify api ready after ${i}s"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
- name: Run smoke against live dify
|
||||||
|
working-directory: ./cli
|
||||||
|
run: pnpm exec tsx scripts/run-smoke.ts --base-url http://localhost:5001
|
||||||
|
|
||||||
|
- name: Dump dify logs on failure
|
||||||
|
if: failure()
|
||||||
|
run: |
|
||||||
|
cd docker
|
||||||
|
docker compose logs api worker web --tail=200
|
||||||
50
.github/workflows/cli-tests.yml
vendored
Normal file
50
.github/workflows/cli-tests.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
name: CLI Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
secrets:
|
||||||
|
CODECOV_TOKEN:
|
||||||
|
required: false
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: cli-tests-${{ github.head_ref || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: CLI Tests (${{ matrix.os }})
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [depot-ubuntu-24.04, windows-latest, macos-latest]
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
shell: bash
|
||||||
|
working-directory: ./cli
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Setup web environment
|
||||||
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
|
- name: CI pipeline (typecheck, lint, coverage, build)
|
||||||
|
run: pnpm ci
|
||||||
|
|
||||||
|
- name: Report coverage
|
||||||
|
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
||||||
|
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||||
|
with:
|
||||||
|
directory: cli/coverage
|
||||||
|
flags: cli
|
||||||
|
env:
|
||||||
|
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||||
18
.github/workflows/docker-build.yml
vendored
18
.github/workflows/docker-build.yml
vendored
@ -6,6 +6,12 @@ on:
|
|||||||
- "main"
|
- "main"
|
||||||
paths:
|
paths:
|
||||||
- api/Dockerfile
|
- api/Dockerfile
|
||||||
|
- api/Dockerfile.dockerignore
|
||||||
|
- api/pyproject.toml
|
||||||
|
- api/uv.lock
|
||||||
|
- dify-agent/pyproject.toml
|
||||||
|
- dify-agent/README.md
|
||||||
|
- dify-agent/src/**
|
||||||
- web/Dockerfile
|
- web/Dockerfile
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@ -25,13 +31,13 @@ jobs:
|
|||||||
- service_name: "api-amd64"
|
- service_name: "api-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}:api"
|
context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
- service_name: "api-arm64"
|
- service_name: "api-arm64"
|
||||||
platform: linux/arm64
|
platform: linux/arm64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: depot-ubuntu-24.04-4
|
||||||
context: "{{defaultContext}}:api"
|
context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
- service_name: "web-amd64"
|
- service_name: "web-amd64"
|
||||||
platform: linux/amd64
|
platform: linux/amd64
|
||||||
runs_on: depot-ubuntu-24.04-4
|
runs_on: depot-ubuntu-24.04-4
|
||||||
@ -64,8 +70,8 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- service_name: "api-amd64"
|
- service_name: "api-amd64"
|
||||||
context: "{{defaultContext}}:api"
|
context: "{{defaultContext}}"
|
||||||
file: "Dockerfile"
|
file: "api/Dockerfile"
|
||||||
- service_name: "web-amd64"
|
- service_name: "web-amd64"
|
||||||
context: "{{defaultContext}}"
|
context: "{{defaultContext}}"
|
||||||
file: "web/Dockerfile"
|
file: "web/Dockerfile"
|
||||||
|
|||||||
17
.github/workflows/expose_service_ports.sh
vendored
17
.github/workflows/expose_service_ports.sh
vendored
@ -1,17 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
yq eval '.services.weaviate.ports += ["8080:8080"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.weaviate.ports += ["50051:50051"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.qdrant.ports += ["6333:6333"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.chroma.ports += ["8000:8000"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services["milvus-standalone"].ports += ["19530:19530"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.pgvector.ports += ["5433:5432"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services["pgvecto-rs"].ports += ["5431:5432"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services["elasticsearch"].ports += ["9200:9200"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.couchbase-server.ports += ["8091-8096:8091-8096"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.couchbase-server.ports += ["11210:11210"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.tidb.ports += ["4000:4000"]' -i docker/tidb/docker-compose.yaml
|
|
||||||
yq eval '.services.oceanbase.ports += ["2881:2881"]' -i docker/docker-compose.yaml
|
|
||||||
yq eval '.services.opengauss.ports += ["6600:6600"]' -i docker/docker-compose.yaml
|
|
||||||
|
|
||||||
echo "Ports exposed for sandbox, weaviate (HTTP 8080, gRPC 50051), tidb, qdrant, chroma, milvus, pgvector, pgvecto-rs, elasticsearch, couchbase, opengauss"
|
|
||||||
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
49
.github/workflows/hotfix-cherry-pick.yml
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
name: Hotfix Cherry-Pick Provenance
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches:
|
||||||
|
- 'hotfix/**'
|
||||||
|
- 'lts/**'
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- edited
|
||||||
|
- reopened
|
||||||
|
- ready_for_review
|
||||||
|
- synchronize
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: hotfix-cherry-pick-${{ github.event.pull_request.number || github.run_id }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-cherry-pick-provenance:
|
||||||
|
name: Require cherry-pick provenance
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Fetch PR base, PR head, and main
|
||||||
|
env:
|
||||||
|
BASE_REF: ${{ github.base_ref }}
|
||||||
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
run: |
|
||||||
|
git fetch --no-tags --prune origin \
|
||||||
|
"+refs/heads/main:refs/remotes/origin/main" \
|
||||||
|
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}" \
|
||||||
|
"+refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head"
|
||||||
|
|
||||||
|
- name: Load checker from main
|
||||||
|
run: git show origin/main:.github/scripts/check-hotfix-cherry-picks.sh > "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||||
|
|
||||||
|
- name: Check PR commits
|
||||||
|
env:
|
||||||
|
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||||
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
|
MAIN_REF: origin/main
|
||||||
|
run: bash "$RUNNER_TEMP/check-hotfix-cherry-picks.sh"
|
||||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@ -9,6 +9,6 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
runs-on: depot-ubuntu-24.04
|
runs-on: depot-ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||||
with:
|
with:
|
||||||
sync-labels: true
|
sync-labels: true
|
||||||
|
|||||||
79
.github/workflows/main-ci.yml
vendored
79
.github/workflows/main-ci.yml
vendored
@ -42,6 +42,7 @@ jobs:
|
|||||||
runs-on: depot-ubuntu-24.04
|
runs-on: depot-ubuntu-24.04
|
||||||
outputs:
|
outputs:
|
||||||
api-changed: ${{ steps.changes.outputs.api }}
|
api-changed: ${{ steps.changes.outputs.api }}
|
||||||
|
cli-changed: ${{ steps.changes.outputs.cli }}
|
||||||
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
e2e-changed: ${{ steps.changes.outputs.e2e }}
|
||||||
web-changed: ${{ steps.changes.outputs.web }}
|
web-changed: ${{ steps.changes.outputs.web }}
|
||||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||||
@ -55,7 +56,6 @@ jobs:
|
|||||||
api:
|
api:
|
||||||
- 'api/**'
|
- 'api/**'
|
||||||
- '.github/workflows/api-tests.yml'
|
- '.github/workflows/api-tests.yml'
|
||||||
- '.github/workflows/expose_service_ports.sh'
|
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/envs/middleware.env.example'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
@ -63,6 +63,18 @@ jobs:
|
|||||||
- 'docker/generate_docker_compose'
|
- 'docker/generate_docker_compose'
|
||||||
- 'docker/ssrf_proxy/**'
|
- 'docker/ssrf_proxy/**'
|
||||||
- 'docker/volumes/sandbox/conf/**'
|
- 'docker/volumes/sandbox/conf/**'
|
||||||
|
cli:
|
||||||
|
- 'cli/**'
|
||||||
|
- 'packages/tsconfig/**'
|
||||||
|
- 'package.json'
|
||||||
|
- 'pnpm-lock.yaml'
|
||||||
|
- 'pnpm-workspace.yaml'
|
||||||
|
- 'eslint.config.mjs'
|
||||||
|
- '.npmrc'
|
||||||
|
- '.nvmrc'
|
||||||
|
- '.github/workflows/cli-tests.yml'
|
||||||
|
- '.github/workflows/cli-docker-build.yml'
|
||||||
|
- '.github/actions/setup-web/**'
|
||||||
web:
|
web:
|
||||||
- 'web/**'
|
- 'web/**'
|
||||||
- 'packages/**'
|
- 'packages/**'
|
||||||
@ -90,11 +102,13 @@ jobs:
|
|||||||
vdb:
|
vdb:
|
||||||
- 'api/core/rag/datasource/**'
|
- 'api/core/rag/datasource/**'
|
||||||
- 'api/tests/integration_tests/vdb/**'
|
- 'api/tests/integration_tests/vdb/**'
|
||||||
|
- 'api/conftest.py'
|
||||||
|
- 'api/tests/pytest_dify.py'
|
||||||
- 'api/providers/vdb/*/tests/**'
|
- 'api/providers/vdb/*/tests/**'
|
||||||
- '.github/workflows/vdb-tests.yml'
|
- '.github/workflows/vdb-tests.yml'
|
||||||
- '.github/workflows/expose_service_ports.sh'
|
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/envs/middleware.env.example'
|
||||||
|
- 'docker/docker-compose.pytest.ports.yaml'
|
||||||
- 'docker/docker-compose.yaml'
|
- 'docker/docker-compose.yaml'
|
||||||
- 'docker/docker-compose-template.yaml'
|
- 'docker/docker-compose-template.yaml'
|
||||||
- 'docker/generate_docker_compose'
|
- 'docker/generate_docker_compose'
|
||||||
@ -114,7 +128,6 @@ jobs:
|
|||||||
- 'api/migrations/**'
|
- 'api/migrations/**'
|
||||||
- 'api/.env.example'
|
- 'api/.env.example'
|
||||||
- '.github/workflows/db-migration-test.yml'
|
- '.github/workflows/db-migration-test.yml'
|
||||||
- '.github/workflows/expose_service_ports.sh'
|
|
||||||
- 'docker/.env.example'
|
- 'docker/.env.example'
|
||||||
- 'docker/envs/middleware.env.example'
|
- 'docker/envs/middleware.env.example'
|
||||||
- 'docker/docker-compose.middleware.yaml'
|
- 'docker/docker-compose.middleware.yaml'
|
||||||
@ -184,6 +197,66 @@ jobs:
|
|||||||
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
||||||
|
cli-tests-run:
|
||||||
|
name: Run CLI Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed == 'true'
|
||||||
|
uses: ./.github/workflows/cli-tests.yml
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
|
cli-tests-skip:
|
||||||
|
name: Skip CLI Tests
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Report skipped CLI tests
|
||||||
|
run: echo "No CLI-related changes detected; skipping CLI tests."
|
||||||
|
|
||||||
|
cli-tests:
|
||||||
|
name: CLI Tests
|
||||||
|
if: ${{ always() }}
|
||||||
|
needs:
|
||||||
|
- pre_job
|
||||||
|
- check-changes
|
||||||
|
- cli-tests-run
|
||||||
|
- cli-tests-skip
|
||||||
|
runs-on: depot-ubuntu-24.04
|
||||||
|
steps:
|
||||||
|
- name: Finalize CLI Tests status
|
||||||
|
env:
|
||||||
|
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
|
||||||
|
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
|
||||||
|
RUN_RESULT: ${{ needs.cli-tests-run.result }}
|
||||||
|
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
|
||||||
|
run: |
|
||||||
|
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
|
||||||
|
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$TESTS_CHANGED" == 'true' ]]; then
|
||||||
|
if [[ "$RUN_RESULT" == 'success' ]]; then
|
||||||
|
echo "CLI tests ran successfully."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CLI tests were required but finished with result: $RUN_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$SKIP_RESULT" == 'success' ]]; then
|
||||||
|
echo "CLI tests were skipped because no CLI-related files changed."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
|
||||||
|
exit 1
|
||||||
|
|
||||||
web-tests-run:
|
web-tests-run:
|
||||||
name: Run Web Tests
|
name: Run Web Tests
|
||||||
needs:
|
needs:
|
||||||
|
|||||||
@ -63,8 +63,8 @@ jobs:
|
|||||||
id: render
|
id: render
|
||||||
run: |
|
run: |
|
||||||
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
|
comment_body="$(uv run --directory api python libs/pyrefly_type_coverage.py \
|
||||||
--base base_report.json \
|
--base "$GITHUB_WORKSPACE/base_report.json" \
|
||||||
< pr_report.json)"
|
< "$GITHUB_WORKSPACE/pr_report.json")"
|
||||||
|
|
||||||
{
|
{
|
||||||
echo "### Pyrefly Type Coverage"
|
echo "### Pyrefly Type Coverage"
|
||||||
|
|||||||
4
.github/workflows/pyrefly-type-coverage.yml
vendored
4
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -65,6 +65,9 @@ jobs:
|
|||||||
# Save structured data for the fork-PR comment workflow
|
# Save structured data for the fork-PR comment workflow
|
||||||
cp /tmp/pyrefly_report_pr.json pr_report.json
|
cp /tmp/pyrefly_report_pr.json pr_report.json
|
||||||
cp /tmp/pyrefly_report_base.json base_report.json
|
cp /tmp/pyrefly_report_base.json base_report.json
|
||||||
|
# Keep fork-PR comments correct while the trusted workflow_run job is
|
||||||
|
# still using the default-branch renderer, which resolves --base from api/.
|
||||||
|
cp /tmp/pyrefly_report_base.json api/base_report.json
|
||||||
|
|
||||||
- name: Save PR number
|
- name: Save PR number
|
||||||
run: |
|
run: |
|
||||||
@ -77,6 +80,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
pr_report.json
|
pr_report.json
|
||||||
base_report.json
|
base_report.json
|
||||||
|
api/base_report.json
|
||||||
pr_number.txt
|
pr_number.txt
|
||||||
|
|
||||||
- name: Comment PR with type coverage
|
- name: Comment PR with type coverage
|
||||||
|
|||||||
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@ -47,6 +47,10 @@ jobs:
|
|||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: uv run --directory api --dev lint-imports
|
run: uv run --directory api --dev lint-imports
|
||||||
|
|
||||||
|
- name: Run Response Contract Linter
|
||||||
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
|
run: uv run --project api --dev python api/dev/lint_response_contracts.py --fail-on-mismatch
|
||||||
|
|
||||||
- name: Run Type Checks
|
- name: Run Type Checks
|
||||||
if: steps.changed-files.outputs.any_changed == 'true'
|
if: steps.changed-files.outputs.any_changed == 'true'
|
||||||
run: make type-check-core
|
run: make type-check-core
|
||||||
|
|||||||
2
.github/workflows/translate-i18n-claude.yml
vendored
2
.github/workflows/translate-i18n-claude.yml
vendored
@ -158,7 +158,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Run Claude Code for Translation Sync
|
- name: Run Claude Code for Translation Sync
|
||||||
if: steps.context.outputs.CHANGED_FILES != ''
|
if: steps.context.outputs.CHANGED_FILES != ''
|
||||||
uses: anthropics/claude-code-action@fefa07e9c665b7320f08c3b525980457f22f58aa # v1.0.111
|
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
||||||
with:
|
with:
|
||||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
39
.github/workflows/vdb-tests-full.yml
vendored
39
.github/workflows/vdb-tests-full.yml
vendored
@ -48,14 +48,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Set up dotenvs
|
|
||||||
run: |
|
|
||||||
cp docker/.env.example docker/.env
|
|
||||||
cp docker/envs/middleware.env.example docker/middleware.env
|
|
||||||
|
|
||||||
- name: Expose Service Ports
|
|
||||||
run: sh .github/workflows/expose_service_ports.sh
|
|
||||||
|
|
||||||
# - name: Set up Vector Store (TiDB)
|
# - name: Set up Vector Store (TiDB)
|
||||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||||
# with:
|
# with:
|
||||||
@ -64,32 +56,13 @@ jobs:
|
|||||||
# tidb
|
# tidb
|
||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
- name: Set up Full Vector Store Matrix
|
|
||||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
|
||||||
with:
|
|
||||||
compose-file: |
|
|
||||||
docker/docker-compose.yaml
|
|
||||||
services: |
|
|
||||||
weaviate
|
|
||||||
qdrant
|
|
||||||
couchbase-server
|
|
||||||
etcd
|
|
||||||
minio
|
|
||||||
milvus-standalone
|
|
||||||
pgvecto-rs
|
|
||||||
pgvector
|
|
||||||
chroma
|
|
||||||
elasticsearch
|
|
||||||
oceanbase
|
|
||||||
|
|
||||||
- name: setup test config
|
|
||||||
run: |
|
|
||||||
echo $(pwd)
|
|
||||||
ls -lah .
|
|
||||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: uv run --project api bash dev/pytest/pytest_vdb.sh
|
run: |
|
||||||
|
uv run --project api pytest \
|
||||||
|
--start-vdb \
|
||||||
|
--vdb-services "weaviate,qdrant,couchbase-server,etcd,minio,milvus-standalone,pgvecto-rs,pgvector,chroma,elasticsearch,oceanbase" \
|
||||||
|
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
|
api/providers/vdb/*/tests/integration_tests
|
||||||
|
|||||||
31
.github/workflows/vdb-tests.yml
vendored
31
.github/workflows/vdb-tests.yml
vendored
@ -45,14 +45,6 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: uv sync --project api --dev
|
run: uv sync --project api --dev
|
||||||
|
|
||||||
- name: Set up dotenvs
|
|
||||||
run: |
|
|
||||||
cp docker/.env.example docker/.env
|
|
||||||
cp docker/envs/middleware.env.example docker/middleware.env
|
|
||||||
|
|
||||||
- name: Expose Service Ports
|
|
||||||
run: sh .github/workflows/expose_service_ports.sh
|
|
||||||
|
|
||||||
# - name: Set up Vector Store (TiDB)
|
# - name: Set up Vector Store (TiDB)
|
||||||
# uses: hoverkraft-tech/compose-action@v2.0.2
|
# uses: hoverkraft-tech/compose-action@v2.0.2
|
||||||
# with:
|
# with:
|
||||||
@ -61,31 +53,14 @@ jobs:
|
|||||||
# tidb
|
# tidb
|
||||||
# tiflash
|
# tiflash
|
||||||
|
|
||||||
- name: Set up Vector Stores for Smoke Coverage
|
|
||||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
|
||||||
with:
|
|
||||||
compose-file: |
|
|
||||||
docker/docker-compose.yaml
|
|
||||||
services: |
|
|
||||||
db_postgres
|
|
||||||
redis
|
|
||||||
weaviate
|
|
||||||
qdrant
|
|
||||||
pgvector
|
|
||||||
chroma
|
|
||||||
|
|
||||||
- name: setup test config
|
|
||||||
run: |
|
|
||||||
echo $(pwd)
|
|
||||||
ls -lah .
|
|
||||||
cp api/tests/integration_tests/.env.example api/tests/integration_tests/.env
|
|
||||||
|
|
||||||
# - name: Check VDB Ready (TiDB)
|
# - name: Check VDB Ready (TiDB)
|
||||||
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
# run: uv run --project api python api/providers/vdb/tidb-vector/tests/integration_tests/check_tiflash_ready.py
|
||||||
|
|
||||||
- name: Test Vector Stores
|
- name: Test Vector Stores
|
||||||
run: |
|
run: |
|
||||||
uv run --project api pytest --timeout "${PYTEST_TIMEOUT:-180}" \
|
uv run --project api pytest \
|
||||||
|
--start-vdb \
|
||||||
|
--timeout "${PYTEST_TIMEOUT:-180}" \
|
||||||
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
||||||
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
||||||
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
||||||
|
|||||||
6
.github/workflows/web-tests.yml
vendored
6
.github/workflows/web-tests.yml
vendored
@ -39,7 +39,7 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-web
|
uses: ./.github/actions/setup-web
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: vp test run --reporter=blob --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
run: vp test run --reporter=blob --reporter=minimal --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --coverage
|
||||||
|
|
||||||
- name: Upload blob report
|
- name: Upload blob report
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
@ -83,7 +83,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||||
with:
|
with:
|
||||||
directory: web/coverage
|
directory: web/coverage
|
||||||
flags: web
|
flags: web
|
||||||
@ -117,7 +117,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Report coverage
|
- name: Report coverage
|
||||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||||
with:
|
with:
|
||||||
directory: packages/dify-ui/coverage
|
directory: packages/dify-ui/coverage
|
||||||
flags: dify-ui
|
flags: dify-ui
|
||||||
|
|||||||
9
.gitignore
vendored
9
.gitignore
vendored
@ -115,6 +115,12 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
|
||||||
|
# cli/ has a src/env/ module (DIFY_* registry) — don't treat it as a venv
|
||||||
|
!/cli/src/env/
|
||||||
|
!/cli/src/commands/env/
|
||||||
|
# cli/scripts/lib/ holds TS build helpers (resolve-buildinfo etc.) — don't treat as Python lib/
|
||||||
|
!/cli/scripts/lib/
|
||||||
.conda/
|
.conda/
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
@ -247,8 +253,9 @@ scripts/stress-test/reports/
|
|||||||
# settings
|
# settings
|
||||||
*.local.json
|
*.local.json
|
||||||
*.local.md
|
*.local.md
|
||||||
|
*.local.toml
|
||||||
|
|
||||||
# Code Agent Folder
|
# Code Agent Folder
|
||||||
.qoder/*
|
.qoder/*
|
||||||
|
.context/
|
||||||
.eslintcache
|
.eslintcache
|
||||||
|
|||||||
@ -9,6 +9,7 @@ The codebase is split into:
|
|||||||
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
- **Backend API** (`/api`): Python Flask application organized with Domain-Driven Design
|
||||||
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
- **Frontend Web** (`/web`): Next.js application using TypeScript and React
|
||||||
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
- **Docker deployment** (`/docker`): Containerized deployment configurations
|
||||||
|
- **Dify Agent Backend** (`/dify-agent`): Backend services for managing and executing agent
|
||||||
|
|
||||||
## Backend Workflow
|
## Backend Workflow
|
||||||
|
|
||||||
|
|||||||
93
Makefile
93
Makefile
@ -3,6 +3,10 @@ DOCKER_REGISTRY=langgenius
|
|||||||
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
|
WEB_IMAGE=$(DOCKER_REGISTRY)/dify-web
|
||||||
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
|
API_IMAGE=$(DOCKER_REGISTRY)/dify-api
|
||||||
VERSION=latest
|
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 target - show help
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@ -17,8 +21,13 @@ dev-setup: prepare-docker prepare-web prepare-api
|
|||||||
# Step 1: Prepare Docker middleware
|
# Step 1: Prepare Docker middleware
|
||||||
prepare-docker:
|
prepare-docker:
|
||||||
@echo "🐳 Setting up Docker middleware..."
|
@echo "🐳 Setting up Docker middleware..."
|
||||||
@cp -n docker/middleware.env.example docker/middleware.env 2>/dev/null || echo "Docker middleware.env already exists"
|
@if [ ! -f "$(DOCKER_MIDDLEWARE_ENV)" ]; then \
|
||||||
@cd docker && docker compose -f docker-compose.middleware.yaml --env-file middleware.env -p dify-middlewares-dev up -d
|
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"
|
@echo "✅ Docker middleware started"
|
||||||
|
|
||||||
# Step 2: Prepare web environment
|
# Step 2: Prepare web environment
|
||||||
@ -39,12 +48,18 @@ prepare-api:
|
|||||||
# Clean dev environment
|
# Clean dev environment
|
||||||
dev-clean:
|
dev-clean:
|
||||||
@echo "⚠️ Stopping Docker containers..."
|
@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..."
|
@echo "🗑️ Removing volumes..."
|
||||||
@rm -rf docker/volumes/db
|
@rm -rf docker/volumes/db
|
||||||
|
@rm -rf docker/volumes/mysql
|
||||||
@rm -rf docker/volumes/redis
|
@rm -rf docker/volumes/redis
|
||||||
@rm -rf docker/volumes/plugin_daemon
|
@rm -rf docker/volumes/plugin_daemon
|
||||||
@rm -rf docker/volumes/weaviate
|
@rm -rf docker/volumes/weaviate
|
||||||
|
@rm -rf docker/volumes/sandbox/dependencies
|
||||||
@rm -rf api/storage
|
@rm -rf api/storage
|
||||||
@echo "✅ Cleanup complete"
|
@echo "✅ Cleanup complete"
|
||||||
|
|
||||||
@ -60,24 +75,29 @@ check:
|
|||||||
@echo "✅ Code check complete"
|
@echo "✅ Code check complete"
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@echo "🔧 Running ruff format, check with fixes, import linter, and dotenv-linter..."
|
@echo "🔧 Running ruff format, check with fixes, response contract lint, import linter, and dotenv-linter..."
|
||||||
@uv run --project api --dev ruff format ./api
|
@uv run --project api --dev ruff format ./api
|
||||||
@uv run --project api --dev ruff check --fix ./api
|
@uv run --project api --dev ruff check --fix ./api
|
||||||
|
@$(MAKE) api-contract-lint
|
||||||
@uv run --directory api --dev lint-imports
|
@uv run --directory api --dev lint-imports
|
||||||
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
@uv run --project api --dev dotenv-linter ./api/.env.example ./web/.env.example
|
||||||
@echo "✅ Linting complete"
|
@echo "✅ Linting complete"
|
||||||
|
|
||||||
|
api-contract-lint:
|
||||||
|
@echo "🔎 Linting Flask response contracts..."
|
||||||
|
@uv run --project api --dev python api/dev/lint_response_contracts.py
|
||||||
|
@echo "✅ Response contract lint complete"
|
||||||
|
|
||||||
type-check:
|
type-check:
|
||||||
@echo "📝 Running type checks (basedpyright + pyrefly + mypy)..."
|
@echo "📝 Running type checks (pyrefly + mypy)..."
|
||||||
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
@./dev/pyrefly-check-local $(PATH_TO_CHECK)
|
||||||
@./dev/pyrefly-check-local
|
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --exclude 'tests/' --exclude 'migrations/' --exclude 'dev/generate_swagger_specs.py' --exclude 'dev/generate_fastopenapi_specs.py' --check-untyped-defs --disable-error-code=import-untyped .
|
||||||
@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 "✅ Type checks complete"
|
@echo "✅ Type checks complete"
|
||||||
|
|
||||||
type-check-core:
|
type-check-core:
|
||||||
@echo "📝 Running core type checks (basedpyright + mypy)..."
|
@echo "📝 Running core type checks (pyrefly + mypy)..."
|
||||||
@./dev/basedpyright-check $(PATH_TO_CHECK)
|
@./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 .
|
@uv --directory api run mypy --exclude-gitignore --exclude '(^|/)conftest\.py$$' --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"
|
@echo "✅ Core type checks complete"
|
||||||
|
|
||||||
test:
|
test:
|
||||||
@ -86,7 +106,46 @@ test:
|
|||||||
echo "Target: $(TARGET_TESTS)"; \
|
echo "Target: $(TARGET_TESTS)"; \
|
||||||
uv run --project api --dev pytest $(TARGET_TESTS); \
|
uv run --project api --dev pytest $(TARGET_TESTS); \
|
||||||
else \
|
else \
|
||||||
PYTEST_XDIST_ARGS="-n auto" uv run --project api --dev dev/pytest/pytest_unit_tests.sh; \
|
echo "Running backend unit tests"; \
|
||||||
|
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
||||||
|
api/tests/unit_tests \
|
||||||
|
api/providers/vdb/*/tests/unit_tests \
|
||||||
|
api/providers/trace/*/tests/unit_tests \
|
||||||
|
--ignore=api/tests/unit_tests/controllers; \
|
||||||
|
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
|
||||||
|
api/tests/unit_tests/controllers; \
|
||||||
|
fi
|
||||||
|
@echo "✅ Unit tests complete"
|
||||||
|
|
||||||
|
test-all:
|
||||||
|
@echo "🧪 Running full backend test suite..."
|
||||||
|
@if [ -n "$(TARGET_TESTS)" ]; then \
|
||||||
|
echo "Target: $(TARGET_TESTS)"; \
|
||||||
|
uv run --project api --dev pytest $(TARGET_TESTS); \
|
||||||
|
else \
|
||||||
|
echo "Running backend unit tests"; \
|
||||||
|
uv run --project api --dev pytest -p no:benchmark --timeout "$${PYTEST_TIMEOUT:-20}" -n auto \
|
||||||
|
api/tests/unit_tests \
|
||||||
|
api/providers/vdb/*/tests/unit_tests \
|
||||||
|
api/providers/trace/*/tests/unit_tests \
|
||||||
|
--ignore=api/tests/unit_tests/controllers; \
|
||||||
|
uv run --project api --dev pytest --timeout "$${PYTEST_TIMEOUT:-20}" --cov-append \
|
||||||
|
api/tests/unit_tests/controllers; \
|
||||||
|
echo "Running backend integration tests"; \
|
||||||
|
uv run --project api --dev pytest -p no:benchmark --start-middleware -n auto \
|
||||||
|
--timeout "$${PYTEST_TIMEOUT:-180}" \
|
||||||
|
--cov-append \
|
||||||
|
api/tests/integration_tests/workflow \
|
||||||
|
api/tests/integration_tests/tools \
|
||||||
|
api/tests/test_containers_integration_tests; \
|
||||||
|
echo "Running VDB smoke tests"; \
|
||||||
|
uv run --project api --dev pytest --start-vdb \
|
||||||
|
--timeout "$${PYTEST_TIMEOUT:-180}" \
|
||||||
|
--cov-append \
|
||||||
|
api/providers/vdb/vdb-chroma/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-pgvector/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-qdrant/tests/integration_tests \
|
||||||
|
api/providers/vdb/vdb-weaviate/tests/integration_tests; \
|
||||||
fi
|
fi
|
||||||
@echo "✅ Tests complete"
|
@echo "✅ Tests complete"
|
||||||
|
|
||||||
@ -132,15 +191,17 @@ help:
|
|||||||
@echo " make prepare-docker - Set up Docker middleware"
|
@echo " make prepare-docker - Set up Docker middleware"
|
||||||
@echo " make prepare-web - Set up web environment"
|
@echo " make prepare-web - Set up web environment"
|
||||||
@echo " make prepare-api - Set up API 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 ""
|
||||||
@echo "Backend Code Quality:"
|
@echo "Backend Code Quality:"
|
||||||
@echo " make format - Format code with ruff"
|
@echo " make format - Format code with ruff"
|
||||||
@echo " make check - Check code with ruff"
|
@echo " make check - Check code with ruff"
|
||||||
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
@echo " make lint - Format, fix, and lint code (ruff, imports, dotenv)"
|
||||||
@echo " make type-check - Run type checks (basedpyright, pyrefly, mypy)"
|
@echo " make api-contract-lint - Check Flask response docs against returned schemas"
|
||||||
@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 " make test - Run backend unit tests (or TARGET_TESTS=./api/tests/<target_tests>)"
|
||||||
|
@echo " make test-all - Run full backend tests, including Docker-backed suites"
|
||||||
@echo ""
|
@echo ""
|
||||||
@echo "Docker Build Targets:"
|
@echo "Docker Build Targets:"
|
||||||
@echo " make build-web - Build web Docker image"
|
@echo " make build-web - Build web Docker image"
|
||||||
@ -150,4 +211,4 @@ help:
|
|||||||
@echo " make build-push-all - Build and push all Docker images"
|
@echo " make build-push-all - Build and push all Docker images"
|
||||||
|
|
||||||
# Phony targets
|
# Phony targets
|
||||||
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint type-check test
|
.PHONY: build-web build-api push-web push-api build-all push-all build-push-all dev-setup prepare-docker prepare-web prepare-api dev-clean help format check lint api-contract-lint type-check test test-all
|
||||||
|
|||||||
@ -34,7 +34,7 @@ TRIGGER_URL=http://localhost:5001
|
|||||||
FILES_ACCESS_TIMEOUT=300
|
FILES_ACCESS_TIMEOUT=300
|
||||||
|
|
||||||
# Collaboration mode toggle
|
# Collaboration mode toggle
|
||||||
ENABLE_COLLABORATION_MODE=false
|
ENABLE_COLLABORATION_MODE=true
|
||||||
|
|
||||||
# Access token expiration time in minutes
|
# Access token expiration time in minutes
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
@ -88,6 +88,10 @@ REDIS_HEALTH_CHECK_INTERVAL=30
|
|||||||
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
CELERY_BROKER_URL=redis://:difyai123456@localhost:${REDIS_PORT}/1
|
||||||
CELERY_BACKEND=redis
|
CELERY_BACKEND=redis
|
||||||
|
|
||||||
|
# Ops trace retry configuration
|
||||||
|
OPS_TRACE_RETRYABLE_DISPATCH_MAX_RETRIES=60
|
||||||
|
OPS_TRACE_RETRYABLE_DISPATCH_DELAY_SECONDS=5
|
||||||
|
|
||||||
# Database configuration
|
# Database configuration
|
||||||
DB_TYPE=postgresql
|
DB_TYPE=postgresql
|
||||||
DB_USERNAME=postgres
|
DB_USERNAME=postgres
|
||||||
@ -553,7 +557,7 @@ MAX_VARIABLE_SIZE=204800
|
|||||||
|
|
||||||
# GraphEngine Worker Pool Configuration
|
# GraphEngine Worker Pool Configuration
|
||||||
# Minimum number of workers per GraphEngine instance (default: 1)
|
# Minimum number of workers per GraphEngine instance (default: 1)
|
||||||
GRAPH_ENGINE_MIN_WORKERS=1
|
GRAPH_ENGINE_MIN_WORKERS=3
|
||||||
# Maximum number of workers per GraphEngine instance (default: 10)
|
# Maximum number of workers per GraphEngine instance (default: 10)
|
||||||
GRAPH_ENGINE_MAX_WORKERS=10
|
GRAPH_ENGINE_MAX_WORKERS=10
|
||||||
# Queue depth threshold that triggers worker scale up (default: 3)
|
# Queue depth threshold that triggers worker scale up (default: 3)
|
||||||
@ -653,6 +657,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
|||||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||||
|
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||||
|
|
||||||
# Marketplace configuration
|
# Marketplace configuration
|
||||||
@ -763,6 +768,7 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
|
|||||||
# Whether to use Redis cluster mode while use redis as event bus.
|
# Whether to use Redis cluster mode while use redis as event bus.
|
||||||
# It's highly recommended to enable this for large deployments.
|
# It's highly recommended to enable this for large deployments.
|
||||||
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
EVENT_BUS_REDIS_USE_CLUSTERS=false
|
||||||
|
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||||
|
|
||||||
# Whether to Enable human input timeout check task
|
# Whether to Enable human input timeout check task
|
||||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||||
|
|||||||
@ -180,6 +180,8 @@ Quick checks while iterating:
|
|||||||
- Format: `make format`
|
- Format: `make format`
|
||||||
- Lint (includes auto-fix): `make lint`
|
- Lint (includes auto-fix): `make lint`
|
||||||
- Type check: `make type-check`
|
- Type check: `make type-check`
|
||||||
|
- Unit tests: `make test`
|
||||||
|
- Full backend tests, including Docker-backed suites: `make test-all`
|
||||||
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
|
- Targeted tests: `make test TARGET_TESTS=./api/tests/<target_tests>`
|
||||||
|
|
||||||
Before opening a PR / submitting:
|
Before opening a PR / submitting:
|
||||||
@ -193,9 +195,10 @@ Before opening a PR / submitting:
|
|||||||
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
|
- Controllers: parse input via Pydantic, invoke services, return serialised responses; no business logic.
|
||||||
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
|
- Services: coordinate repositories, providers, background tasks; keep side effects explicit.
|
||||||
- Document non-obvious behaviour with concise docstrings and comments.
|
- Document non-obvious behaviour with concise docstrings and comments.
|
||||||
|
- For `204 No Content` responses, return an empty body only; never return a dict, model, or other payload.
|
||||||
- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`.
|
- For Flask-RESTX controller request, query, and response schemas, follow `controllers/API_SCHEMA_GUIDE.md`.
|
||||||
In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response
|
In short: use Pydantic models, document GET query params with `query_params_from_model(...)`, register response
|
||||||
DTOs with `register_response_schema_models(...)`, serialize with `ResponseModel.model_validate(...).model_dump(...)`,
|
DTOs with `register_response_schema_models(...)`, serialize response DTOs with `dump_response(...)`,
|
||||||
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
|
and avoid adding new legacy `ns.model(...)`, `@marshal_with(...)`, or GET `@ns.expect(...)` patterns.
|
||||||
|
|
||||||
### Miscellaneous
|
### Miscellaneous
|
||||||
|
|||||||
@ -22,9 +22,12 @@ RUN apt-get update \
|
|||||||
libmpfr-dev libmpc-dev
|
libmpfr-dev libmpc-dev
|
||||||
|
|
||||||
# Install Python dependencies (workspace members under providers/vdb/)
|
# Install Python dependencies (workspace members under providers/vdb/)
|
||||||
COPY pyproject.toml uv.lock ./
|
COPY api/pyproject.toml api/uv.lock ./
|
||||||
COPY providers ./providers
|
COPY api/providers ./providers
|
||||||
RUN uv sync --locked --no-dev
|
COPY dify-agent/pyproject.toml dify-agent/README.md /app/dify-agent/
|
||||||
|
COPY dify-agent/src /app/dify-agent/src
|
||||||
|
# Trust the checked-in lock during image builds; local path sources are copied from the repository context.
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
# production stage
|
# production stage
|
||||||
FROM base AS production
|
FROM base AS production
|
||||||
@ -107,10 +110,10 @@ RUN python -c "import tiktoken; tiktoken.encoding_for_model('gpt2')" \
|
|||||||
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
|
&& chown -R dify:dify ${TIKTOKEN_CACHE_DIR}
|
||||||
|
|
||||||
# Copy source code
|
# Copy source code
|
||||||
COPY --chown=dify:dify . /app/api/
|
COPY --chown=dify:dify api /app/api/
|
||||||
|
|
||||||
# Prepare entrypoint script
|
# Prepare entrypoint script
|
||||||
COPY --chown=dify:dify --chmod=755 docker/entrypoint.sh /entrypoint.sh
|
COPY --chown=dify:dify --chmod=755 api/docker/entrypoint.sh /entrypoint.sh
|
||||||
|
|
||||||
|
|
||||||
ARG COMMIT_SHA
|
ARG COMMIT_SHA
|
||||||
|
|||||||
25
api/Dockerfile.dockerignore
Normal file
25
api/Dockerfile.dockerignore
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
*
|
||||||
|
|
||||||
|
!api/
|
||||||
|
!api/**
|
||||||
|
!dify-agent/
|
||||||
|
!dify-agent/pyproject.toml
|
||||||
|
!dify-agent/README.md
|
||||||
|
!dify-agent/src/
|
||||||
|
!dify-agent/src/**
|
||||||
|
|
||||||
|
api/.venv
|
||||||
|
api/.venv/**
|
||||||
|
api/.env
|
||||||
|
api/*.env.*
|
||||||
|
api/.idea
|
||||||
|
api/.mypy_cache
|
||||||
|
api/.ruff_cache
|
||||||
|
api/storage/generate_files/*
|
||||||
|
api/storage/privkeys/*
|
||||||
|
api/storage/tools/*
|
||||||
|
api/storage/upload_files/*
|
||||||
|
api/logs
|
||||||
|
api/*.log*
|
||||||
|
**/__pycache__
|
||||||
|
**/*.pyc
|
||||||
@ -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
|
./dev/reformat # Run all formatters and linters
|
||||||
uv run ruff check --fix ./ # Fix linting issues
|
uv run ruff check --fix ./ # Fix linting issues
|
||||||
uv run ruff format ./ # Format code
|
uv run ruff format ./ # Format code
|
||||||
uv run basedpyright . # Type checking
|
uv run pyrefly check # Type checking
|
||||||
```
|
```
|
||||||
|
|
||||||
## Generate TS stub
|
## Generate TS stub
|
||||||
|
|||||||
@ -117,7 +117,7 @@ def create_flask_app_with_configs() -> DifyApp:
|
|||||||
logger.warning("Failed to add trace headers to response", exc_info=True)
|
logger.warning("Failed to add trace headers to response", exc_info=True)
|
||||||
return response
|
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
|
_ = before_request
|
||||||
_ = add_trace_headers
|
_ = add_trace_headers
|
||||||
|
|
||||||
@ -159,6 +159,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_logstore,
|
ext_logstore,
|
||||||
ext_mail,
|
ext_mail,
|
||||||
ext_migrate,
|
ext_migrate,
|
||||||
|
ext_oauth_bearer,
|
||||||
ext_orjson,
|
ext_orjson,
|
||||||
ext_otel,
|
ext_otel,
|
||||||
ext_proxy_fix,
|
ext_proxy_fix,
|
||||||
@ -181,7 +182,6 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_import_modules,
|
ext_import_modules,
|
||||||
ext_orjson,
|
ext_orjson,
|
||||||
ext_forward_refs,
|
ext_forward_refs,
|
||||||
ext_set_secretkey,
|
|
||||||
ext_compress,
|
ext_compress,
|
||||||
ext_code_based_extension,
|
ext_code_based_extension,
|
||||||
ext_database,
|
ext_database,
|
||||||
@ -189,6 +189,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_migrate,
|
ext_migrate,
|
||||||
ext_redis,
|
ext_redis,
|
||||||
ext_storage,
|
ext_storage,
|
||||||
|
ext_set_secretkey,
|
||||||
ext_logstore, # Initialize logstore after storage, before celery
|
ext_logstore, # Initialize logstore after storage, before celery
|
||||||
ext_celery,
|
ext_celery,
|
||||||
ext_login,
|
ext_login,
|
||||||
@ -203,6 +204,7 @@ def initialize_extensions(app: DifyApp):
|
|||||||
ext_enterprise_telemetry,
|
ext_enterprise_telemetry,
|
||||||
ext_request_logging,
|
ext_request_logging,
|
||||||
ext_session_factory,
|
ext_session_factory,
|
||||||
|
ext_oauth_bearer,
|
||||||
]
|
]
|
||||||
for ext in extensions:
|
for ext in extensions:
|
||||||
short_name = ext.__name__.split(".")[-1]
|
short_name = ext.__name__.split(".")[-1]
|
||||||
@ -221,10 +223,11 @@ def initialize_extensions(app: DifyApp):
|
|||||||
|
|
||||||
def create_migrations_app() -> DifyApp:
|
def create_migrations_app() -> DifyApp:
|
||||||
app = create_flask_app_with_configs()
|
app = create_flask_app_with_configs()
|
||||||
from extensions import ext_database, ext_migrate
|
from extensions import ext_commands, ext_database, ext_migrate
|
||||||
|
|
||||||
# Initialize only required extensions
|
# Initialize only required extensions
|
||||||
ext_database.init_app(app)
|
ext_database.init_app(app)
|
||||||
ext_migrate.init_app(app)
|
ext_migrate.init_app(app)
|
||||||
|
ext_commands.init_app(app)
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|||||||
1
api/clients/__init__.py
Normal file
1
api/clients/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""External service client packages."""
|
||||||
76
api/clients/agent_backend/__init__.py
Normal file
76
api/clients/agent_backend/__init__.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
"""API-side integration boundary for the Dify Agent backend.
|
||||||
|
|
||||||
|
Public wire DTOs come from ``dify_agent.protocol``. This package only contains
|
||||||
|
API adapters: request building from Dify product concepts, a thin client wrapper,
|
||||||
|
event adaptation for future workflow integration, and deterministic fakes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
||||||
|
from clients.agent_backend.errors import (
|
||||||
|
AgentBackendError,
|
||||||
|
AgentBackendHTTPError,
|
||||||
|
AgentBackendRequestBuildError,
|
||||||
|
AgentBackendRunFailedError,
|
||||||
|
AgentBackendStreamError,
|
||||||
|
AgentBackendTransportError,
|
||||||
|
AgentBackendValidationError,
|
||||||
|
)
|
||||||
|
from clients.agent_backend.event_adapter import (
|
||||||
|
AgentBackendInternalEvent,
|
||||||
|
AgentBackendInternalEventType,
|
||||||
|
AgentBackendRunCancelledInternalEvent,
|
||||||
|
AgentBackendRunEventAdapter,
|
||||||
|
AgentBackendRunFailedInternalEvent,
|
||||||
|
AgentBackendRunPausedInternalEvent,
|
||||||
|
AgentBackendRunStartedInternalEvent,
|
||||||
|
AgentBackendRunSucceededInternalEvent,
|
||||||
|
AgentBackendStreamInternalEvent,
|
||||||
|
)
|
||||||
|
from clients.agent_backend.factory import create_agent_backend_run_client
|
||||||
|
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||||
|
from clients.agent_backend.request_builder import (
|
||||||
|
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||||
|
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||||
|
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||||
|
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||||
|
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||||
|
AgentBackendModelConfig,
|
||||||
|
AgentBackendOutputConfig,
|
||||||
|
AgentBackendRunRequestBuilder,
|
||||||
|
AgentBackendWorkflowNodeRunInput,
|
||||||
|
redact_for_agent_backend_log,
|
||||||
|
)
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"AGENT_SOUL_PROMPT_LAYER_ID",
|
||||||
|
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
|
||||||
|
"DIFY_PLUGIN_TOOLS_LAYER_ID",
|
||||||
|
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
|
||||||
|
"WORKFLOW_USER_PROMPT_LAYER_ID",
|
||||||
|
"AgentBackendError",
|
||||||
|
"AgentBackendHTTPError",
|
||||||
|
"AgentBackendInternalEvent",
|
||||||
|
"AgentBackendInternalEventType",
|
||||||
|
"AgentBackendModelConfig",
|
||||||
|
"AgentBackendOutputConfig",
|
||||||
|
"AgentBackendRequestBuildError",
|
||||||
|
"AgentBackendRunCancelledInternalEvent",
|
||||||
|
"AgentBackendRunClient",
|
||||||
|
"AgentBackendRunEventAdapter",
|
||||||
|
"AgentBackendRunFailedError",
|
||||||
|
"AgentBackendRunFailedInternalEvent",
|
||||||
|
"AgentBackendRunPausedInternalEvent",
|
||||||
|
"AgentBackendRunRequestBuilder",
|
||||||
|
"AgentBackendRunStartedInternalEvent",
|
||||||
|
"AgentBackendRunSucceededInternalEvent",
|
||||||
|
"AgentBackendStreamError",
|
||||||
|
"AgentBackendStreamInternalEvent",
|
||||||
|
"AgentBackendTransportError",
|
||||||
|
"AgentBackendValidationError",
|
||||||
|
"AgentBackendWorkflowNodeRunInput",
|
||||||
|
"DifyAgentBackendRunClient",
|
||||||
|
"FakeAgentBackendRunClient",
|
||||||
|
"FakeAgentBackendScenario",
|
||||||
|
"create_agent_backend_run_client",
|
||||||
|
"redact_for_agent_backend_log",
|
||||||
|
]
|
||||||
130
api/clients/agent_backend/client.py
Normal file
130
api/clients/agent_backend/client.py
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
"""Synchronous API-side wrapper around the public ``dify-agent`` client.
|
||||||
|
|
||||||
|
``dify-agent`` owns the cross-service DTOs and HTTP/SSE implementation. The API
|
||||||
|
backend keeps this thin wrapper so workflow code depends on a local protocol,
|
||||||
|
gets API-native errors, and can use a deterministic fake in tests without
|
||||||
|
creating another wire contract.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from dify_agent.client import (
|
||||||
|
DifyAgentClientError,
|
||||||
|
DifyAgentHTTPError,
|
||||||
|
DifyAgentStreamError,
|
||||||
|
DifyAgentTimeoutError,
|
||||||
|
DifyAgentValidationError,
|
||||||
|
)
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
CancelRunRequest,
|
||||||
|
CancelRunResponse,
|
||||||
|
CreateRunRequest,
|
||||||
|
CreateRunResponse,
|
||||||
|
RunEvent,
|
||||||
|
RunStatusResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
from clients.agent_backend.errors import (
|
||||||
|
AgentBackendError,
|
||||||
|
AgentBackendHTTPError,
|
||||||
|
AgentBackendStreamError,
|
||||||
|
AgentBackendTransportError,
|
||||||
|
AgentBackendValidationError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunClient(Protocol):
|
||||||
|
"""Local boundary used by API workflow integrations to run Agent backend jobs."""
|
||||||
|
|
||||||
|
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||||
|
"""Create one Agent backend run and return its accepted status."""
|
||||||
|
|
||||||
|
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||||
|
"""Request explicit cancellation for one Agent backend run."""
|
||||||
|
|
||||||
|
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||||
|
"""Yield public ``dify-agent`` run events in stream order."""
|
||||||
|
|
||||||
|
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
"""Wait for a run to reach a terminal status and return that status."""
|
||||||
|
|
||||||
|
|
||||||
|
class _DifyAgentSyncClient(Protocol):
|
||||||
|
"""Subset of ``dify_agent.client.Client`` used by the API wrapper."""
|
||||||
|
|
||||||
|
def create_run_sync(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||||
|
"""Create one run synchronously."""
|
||||||
|
|
||||||
|
def cancel_run_sync(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||||
|
"""Cancel one run synchronously."""
|
||||||
|
|
||||||
|
def stream_events_sync(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||||
|
"""Stream run events synchronously."""
|
||||||
|
|
||||||
|
def wait_run_sync(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
"""Wait for terminal run status synchronously."""
|
||||||
|
|
||||||
|
|
||||||
|
class DifyAgentBackendRunClient:
|
||||||
|
"""Adapter from API sync call sites to ``dify_agent.client.Client`` sync methods."""
|
||||||
|
|
||||||
|
client: _DifyAgentSyncClient
|
||||||
|
|
||||||
|
def __init__(self, client: _DifyAgentSyncClient) -> None:
|
||||||
|
self.client = client
|
||||||
|
|
||||||
|
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||||
|
"""Create one run through ``POST /runs`` and normalize client exceptions."""
|
||||||
|
try:
|
||||||
|
return self.client.create_run_sync(request)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_dify_agent_error(exc) from exc
|
||||||
|
|
||||||
|
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||||
|
"""Cancel one run through ``POST /runs/{run_id}/cancel`` and normalize exceptions."""
|
||||||
|
try:
|
||||||
|
return self.client.cancel_run_sync(run_id, request=request)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_dify_agent_error(exc) from exc
|
||||||
|
|
||||||
|
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||||
|
"""Stream run events from ``/events/sse`` with the wrapped client's reconnect policy."""
|
||||||
|
try:
|
||||||
|
yield from self.client.stream_events_sync(run_id, after=after)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_dify_agent_error(exc) from exc
|
||||||
|
|
||||||
|
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
"""Poll run status until terminal state and normalize client exceptions."""
|
||||||
|
try:
|
||||||
|
return self.client.wait_run_sync(run_id, timeout_seconds=timeout_seconds)
|
||||||
|
except Exception as exc:
|
||||||
|
raise _normalize_dify_agent_error(exc) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_dify_agent_error(exc: Exception) -> AgentBackendError:
|
||||||
|
"""Map public ``dify-agent`` client errors to API-side integration errors."""
|
||||||
|
match exc:
|
||||||
|
case DifyAgentValidationError() as error:
|
||||||
|
return AgentBackendValidationError(
|
||||||
|
"Agent backend request or response validation failed", detail=error.detail
|
||||||
|
)
|
||||||
|
case DifyAgentHTTPError() as error:
|
||||||
|
return AgentBackendHTTPError(
|
||||||
|
f"Agent backend HTTP {error.status_code}",
|
||||||
|
status_code=error.status_code,
|
||||||
|
detail=error.detail,
|
||||||
|
)
|
||||||
|
case DifyAgentTimeoutError() as error:
|
||||||
|
return AgentBackendTransportError(str(error))
|
||||||
|
case DifyAgentStreamError() as error:
|
||||||
|
return AgentBackendStreamError(str(error))
|
||||||
|
case DifyAgentClientError() as error:
|
||||||
|
return AgentBackendTransportError(str(error))
|
||||||
|
case AgentBackendError() as error:
|
||||||
|
return error
|
||||||
|
case _:
|
||||||
|
return AgentBackendTransportError(str(exc) or type(exc).__name__)
|
||||||
61
api/clients/agent_backend/errors.py
Normal file
61
api/clients/agent_backend/errors.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"""API-side errors for the Dify Agent backend integration.
|
||||||
|
|
||||||
|
The wire protocol and low-level HTTP behaviour are owned by ``dify-agent``.
|
||||||
|
This module only normalizes those client errors into the API backend's boundary
|
||||||
|
so workflow/node code does not depend directly on transport-specific exception
|
||||||
|
classes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendError(Exception):
|
||||||
|
"""Base error for API-side Agent backend integration failures."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRequestBuildError(AgentBackendError):
|
||||||
|
"""Raised when Dify product/workflow state cannot be mapped to a run request."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendTransportError(AgentBackendError):
|
||||||
|
"""Raised for timeout or request-level failures talking to Agent backend."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendHTTPError(AgentBackendTransportError):
|
||||||
|
"""Raised for Agent backend HTTP errors after status/detail normalization."""
|
||||||
|
|
||||||
|
status_code: int
|
||||||
|
detail: object
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, status_code: int, detail: object) -> None:
|
||||||
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendValidationError(AgentBackendError):
|
||||||
|
"""Raised for local request validation or Agent backend 422 responses."""
|
||||||
|
|
||||||
|
detail: object
|
||||||
|
|
||||||
|
def __init__(self, message: str, *, detail: object) -> None:
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(message)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendStreamError(AgentBackendError):
|
||||||
|
"""Raised when an Agent backend event stream is malformed or exhausted."""
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunFailedError(AgentBackendError):
|
||||||
|
"""Raised by callers that choose to translate a terminal failed run into an exception."""
|
||||||
|
|
||||||
|
run_id: str
|
||||||
|
detail: Any
|
||||||
|
|
||||||
|
def __init__(self, run_id: str, detail: Any) -> None:
|
||||||
|
self.run_id = run_id
|
||||||
|
self.detail = detail
|
||||||
|
super().__init__(f"Agent backend run failed: {run_id}")
|
||||||
167
api/clients/agent_backend/event_adapter.py
Normal file
167
api/clients/agent_backend/event_adapter.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""Adapt public ``dify-agent`` run events into API-internal event semantics.
|
||||||
|
|
||||||
|
The adapter does not define a new cross-service event contract. It consumes
|
||||||
|
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
|
||||||
|
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
from typing import Annotated, Literal, cast
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
PydanticAIStreamRunEvent,
|
||||||
|
RunCancelledEvent,
|
||||||
|
RunEvent,
|
||||||
|
RunFailedEvent,
|
||||||
|
RunPausedEvent,
|
||||||
|
RunStartedEvent,
|
||||||
|
RunSucceededEvent,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter
|
||||||
|
|
||||||
|
_EVENT_DATA_ADAPTER = TypeAdapter(object)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendInternalEventType(StrEnum):
|
||||||
|
"""API-only event labels used before Graphon/AppQueue integration."""
|
||||||
|
|
||||||
|
RUN_STARTED = "run_started"
|
||||||
|
STREAM_EVENT = "stream_event"
|
||||||
|
RUN_PAUSED = "run_paused"
|
||||||
|
RUN_SUCCEEDED = "run_succeeded"
|
||||||
|
RUN_FAILED = "run_failed"
|
||||||
|
RUN_CANCELLED = "run_cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendInternalEventBase(BaseModel):
|
||||||
|
"""Common fields preserved from public Dify Agent run events."""
|
||||||
|
|
||||||
|
run_id: str
|
||||||
|
source_event_id: str | None = None
|
||||||
|
|
||||||
|
model_config = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunStartedInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal marker for a started Agent backend run."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.RUN_STARTED] = AgentBackendInternalEventType.RUN_STARTED
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendStreamInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal wrapper for one pydantic-ai stream event payload."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.STREAM_EVENT] = AgentBackendInternalEventType.STREAM_EVENT
|
||||||
|
event_kind: str | None = None
|
||||||
|
data: JsonValue
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal terminal success event carrying final output and session state."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.RUN_SUCCEEDED] = AgentBackendInternalEventType.RUN_SUCCEEDED
|
||||||
|
output: JsonValue
|
||||||
|
session_snapshot: CompositorSessionSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal resumable pause event for human handoff and Babysit flows."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
|
||||||
|
reason: str
|
||||||
|
message: str | None = None
|
||||||
|
session_snapshot: CompositorSessionSnapshot | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal terminal failure event carrying the backend-safe error text."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.RUN_FAILED] = AgentBackendInternalEventType.RUN_FAILED
|
||||||
|
error: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||||
|
"""API-internal terminal cancellation event."""
|
||||||
|
|
||||||
|
type: Literal[AgentBackendInternalEventType.RUN_CANCELLED] = AgentBackendInternalEventType.RUN_CANCELLED
|
||||||
|
reason: str | None = None
|
||||||
|
message: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
type AgentBackendInternalEvent = Annotated[
|
||||||
|
AgentBackendRunStartedInternalEvent
|
||||||
|
| AgentBackendStreamInternalEvent
|
||||||
|
| AgentBackendRunPausedInternalEvent
|
||||||
|
| AgentBackendRunSucceededInternalEvent
|
||||||
|
| AgentBackendRunFailedInternalEvent
|
||||||
|
| AgentBackendRunCancelledInternalEvent,
|
||||||
|
Field(discriminator="type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunEventAdapter:
|
||||||
|
"""Maps public ``dify-agent`` event variants to API-internal event variants."""
|
||||||
|
|
||||||
|
def adapt(self, event: RunEvent) -> list[AgentBackendInternalEvent]:
|
||||||
|
"""Return zero or more API-internal events derived from one public run event."""
|
||||||
|
match event:
|
||||||
|
case RunStartedEvent():
|
||||||
|
return [
|
||||||
|
AgentBackendRunStartedInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case PydanticAIStreamRunEvent():
|
||||||
|
data = cast(JsonValue, _EVENT_DATA_ADAPTER.dump_python(event.data, mode="json"))
|
||||||
|
event_kind = data.get("event_kind") if isinstance(data, dict) else None
|
||||||
|
return [
|
||||||
|
AgentBackendStreamInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
event_kind=event_kind if isinstance(event_kind, str) else None,
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case RunSucceededEvent():
|
||||||
|
return [
|
||||||
|
AgentBackendRunSucceededInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
output=event.data.output,
|
||||||
|
session_snapshot=event.data.session_snapshot,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case RunPausedEvent():
|
||||||
|
return [
|
||||||
|
AgentBackendRunPausedInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
reason=event.data.reason,
|
||||||
|
message=event.data.message,
|
||||||
|
session_snapshot=event.data.session_snapshot,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case RunFailedEvent():
|
||||||
|
return [
|
||||||
|
AgentBackendRunFailedInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
error=event.data.error,
|
||||||
|
reason=event.data.reason,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
case RunCancelledEvent():
|
||||||
|
return [
|
||||||
|
AgentBackendRunCancelledInternalEvent(
|
||||||
|
run_id=event.run_id,
|
||||||
|
source_event_id=event.id,
|
||||||
|
reason=event.data.reason,
|
||||||
|
message=event.data.message,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
||||||
22
api/clients/agent_backend/factory.py
Normal file
22
api/clients/agent_backend/factory.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
"""Factories for API-side Agent backend clients."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dify_agent.client import Client
|
||||||
|
|
||||||
|
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
||||||
|
from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAgentBackendScenario
|
||||||
|
|
||||||
|
|
||||||
|
def create_agent_backend_run_client(
|
||||||
|
*,
|
||||||
|
base_url: str | None = None,
|
||||||
|
use_fake: bool = False,
|
||||||
|
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||||
|
) -> AgentBackendRunClient:
|
||||||
|
"""Create the API-side run client without hiding the ``dify-agent`` protocol."""
|
||||||
|
if use_fake:
|
||||||
|
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
|
||||||
|
if base_url is None:
|
||||||
|
raise ValueError("base_url is required when creating a real Agent backend client")
|
||||||
|
return DifyAgentBackendRunClient(Client(base_url=base_url))
|
||||||
117
api/clients/agent_backend/fake_client.py
Normal file
117
api/clients/agent_backend/fake_client.py
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
"""Deterministic fake Agent backend client using public ``dify-agent`` events.
|
||||||
|
|
||||||
|
Tests should exercise the same ``RunEvent`` DTOs as the real HTTP client. This
|
||||||
|
fake therefore replaces the previous custom mock protocol instead of emulating a
|
||||||
|
separate ``agent-backend.v1`` event stream.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Iterator
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
CancelRunRequest,
|
||||||
|
CancelRunResponse,
|
||||||
|
CreateRunRequest,
|
||||||
|
CreateRunResponse,
|
||||||
|
RunEvent,
|
||||||
|
RunFailedEvent,
|
||||||
|
RunFailedEventData,
|
||||||
|
RunStartedEvent,
|
||||||
|
RunStatusResponse,
|
||||||
|
RunSucceededEvent,
|
||||||
|
RunSucceededEventData,
|
||||||
|
)
|
||||||
|
|
||||||
|
_FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentBackendScenario(StrEnum):
|
||||||
|
"""Deterministic fake scenarios for API-side integration tests."""
|
||||||
|
|
||||||
|
SUCCESS = "success"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class FakeAgentBackendRunClient:
|
||||||
|
"""In-memory implementation of ``AgentBackendRunClient`` for unit tests."""
|
||||||
|
|
||||||
|
scenario: FakeAgentBackendScenario
|
||||||
|
run_id: str
|
||||||
|
request: CreateRunRequest | None
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scenario: FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||||
|
run_id: str = "fake-run-1",
|
||||||
|
) -> None:
|
||||||
|
self.scenario = scenario
|
||||||
|
self.run_id = run_id
|
||||||
|
self.request = None
|
||||||
|
|
||||||
|
def create_run(self, request: CreateRunRequest) -> CreateRunResponse:
|
||||||
|
"""Record the request and return a deterministic accepted response."""
|
||||||
|
self.request = request
|
||||||
|
return CreateRunResponse(run_id=self.run_id, status="running")
|
||||||
|
|
||||||
|
def cancel_run(self, run_id: str, request: CancelRunRequest | None = None) -> CancelRunResponse:
|
||||||
|
"""Return a deterministic cancellation response."""
|
||||||
|
del request
|
||||||
|
return CancelRunResponse(run_id=run_id, status="cancelled")
|
||||||
|
|
||||||
|
def stream_events(self, run_id: str, *, after: str | None = None) -> Iterator[RunEvent]:
|
||||||
|
"""Yield the deterministic public ``RunEvent`` sequence for ``run_id``."""
|
||||||
|
for event in self._events(run_id):
|
||||||
|
if after is not None and event.id is not None and event.id <= after:
|
||||||
|
continue
|
||||||
|
yield event
|
||||||
|
|
||||||
|
def wait_run(self, run_id: str, *, timeout_seconds: float | None = None) -> RunStatusResponse:
|
||||||
|
"""Return a deterministic terminal status; timeout is accepted for protocol parity."""
|
||||||
|
del timeout_seconds
|
||||||
|
match self.scenario:
|
||||||
|
case FakeAgentBackendScenario.SUCCESS:
|
||||||
|
return RunStatusResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
status="succeeded",
|
||||||
|
created_at=_FIXED_TIME,
|
||||||
|
updated_at=_FIXED_TIME,
|
||||||
|
)
|
||||||
|
case FakeAgentBackendScenario.FAILED:
|
||||||
|
return RunStatusResponse(
|
||||||
|
run_id=run_id,
|
||||||
|
status="failed",
|
||||||
|
created_at=_FIXED_TIME,
|
||||||
|
updated_at=_FIXED_TIME,
|
||||||
|
error="fake failure",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _events(self, run_id: str) -> tuple[RunEvent, ...]:
|
||||||
|
match self.scenario:
|
||||||
|
case FakeAgentBackendScenario.SUCCESS:
|
||||||
|
return (
|
||||||
|
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
||||||
|
RunSucceededEvent(
|
||||||
|
id="2-0",
|
||||||
|
run_id=run_id,
|
||||||
|
created_at=_FIXED_TIME,
|
||||||
|
data=RunSucceededEventData(
|
||||||
|
output={"text": "hello agent"},
|
||||||
|
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
case FakeAgentBackendScenario.FAILED:
|
||||||
|
return (
|
||||||
|
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
||||||
|
RunFailedEvent(
|
||||||
|
id="2-0",
|
||||||
|
run_id=run_id,
|
||||||
|
created_at=_FIXED_TIME,
|
||||||
|
data=RunFailedEventData(error="fake failure", reason="unit_test"),
|
||||||
|
),
|
||||||
|
)
|
||||||
209
api/clients/agent_backend/request_builder.py
Normal file
209
api/clients/agent_backend/request_builder.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""Build ``dify-agent`` run requests from API-side product concepts.
|
||||||
|
|
||||||
|
This module is intentionally an adapter, not a wire DTO package. The emitted
|
||||||
|
object is always ``dify_agent.protocol.CreateRunRequest`` so the Agent backend
|
||||||
|
protocol has a single owner. API-only context such as Agent Soul vs workflow job
|
||||||
|
prompt is preserved in layer names and metadata until the dedicated product
|
||||||
|
schemas land in later phases. Dify-owned execution identifiers are emitted as an
|
||||||
|
explicit ``dify.execution_context`` layer so the run request stays fully
|
||||||
|
composition-driven.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import ClassVar
|
||||||
|
|
||||||
|
from agenton.compositor import CompositorSessionSnapshot
|
||||||
|
from agenton.layers import ExitIntent
|
||||||
|
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||||
|
from dify_agent.layers.dify_plugin import (
|
||||||
|
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||||
|
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||||
|
DifyPluginCredentialValue,
|
||||||
|
DifyPluginLLMLayerConfig,
|
||||||
|
DifyPluginToolsLayerConfig,
|
||||||
|
)
|
||||||
|
from dify_agent.layers.execution_context import (
|
||||||
|
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||||
|
DifyExecutionContextLayerConfig,
|
||||||
|
)
|
||||||
|
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||||
|
from dify_agent.protocol import (
|
||||||
|
DIFY_AGENT_MODEL_LAYER_ID,
|
||||||
|
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||||
|
CreateRunRequest,
|
||||||
|
LayerExitSignals,
|
||||||
|
RunComposition,
|
||||||
|
RunLayerSpec,
|
||||||
|
RunPurpose,
|
||||||
|
)
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||||
|
|
||||||
|
AGENT_SOUL_PROMPT_LAYER_ID = "agent_soul_prompt"
|
||||||
|
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||||
|
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||||
|
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||||
|
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendModelConfig(BaseModel):
|
||||||
|
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
|
||||||
|
|
||||||
|
plugin_id: str
|
||||||
|
model_provider: str
|
||||||
|
model: str
|
||||||
|
credentials: dict[str, DifyPluginCredentialValue] = Field(default_factory=dict)
|
||||||
|
model_settings: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendOutputConfig(BaseModel):
|
||||||
|
"""API-side structured output declaration for the conventional output layer.
|
||||||
|
|
||||||
|
The structured-output tool name is fixed to ``final_output`` inside
|
||||||
|
``dify_agent.layers.output`` so callers only control the JSON Schema plus
|
||||||
|
optional description/strictness metadata.
|
||||||
|
"""
|
||||||
|
|
||||||
|
json_schema: dict[str, JsonValue]
|
||||||
|
description: str | None = None
|
||||||
|
strict: bool | None = None
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||||
|
"""Inputs needed to build the first workflow-node-oriented Agent backend run request."""
|
||||||
|
|
||||||
|
model: AgentBackendModelConfig
|
||||||
|
execution_context: DifyExecutionContextLayerConfig
|
||||||
|
workflow_node_job_prompt: str
|
||||||
|
user_prompt: str
|
||||||
|
agent_soul_prompt: str | None = None
|
||||||
|
purpose: RunPurpose = "workflow_node"
|
||||||
|
idempotency_key: str | None = None
|
||||||
|
output: AgentBackendOutputConfig | None = None
|
||||||
|
tools: DifyPluginToolsLayerConfig | None = None
|
||||||
|
session_snapshot: CompositorSessionSnapshot | None = None
|
||||||
|
suspend_on_exit: bool = False
|
||||||
|
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||||
|
|
||||||
|
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", arbitrary_types_allowed=True)
|
||||||
|
|
||||||
|
@field_validator("workflow_node_job_prompt", "user_prompt")
|
||||||
|
@classmethod
|
||||||
|
def _reject_blank_prompt(cls, value: str) -> str:
|
||||||
|
if not value.strip():
|
||||||
|
raise ValueError("prompt must not be blank")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendRunRequestBuilder:
|
||||||
|
"""Converts API product state into the public ``dify-agent`` run protocol."""
|
||||||
|
|
||||||
|
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
||||||
|
"""Build a workflow Agent Node run request without defining another wire schema."""
|
||||||
|
layers: list[RunLayerSpec] = []
|
||||||
|
if run_input.agent_soul_prompt:
|
||||||
|
layers.append(
|
||||||
|
RunLayerSpec(
|
||||||
|
name=AGENT_SOUL_PROMPT_LAYER_ID,
|
||||||
|
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||||
|
metadata={**run_input.metadata, "origin": "agent_soul"},
|
||||||
|
config=PromptLayerConfig(prefix=run_input.agent_soul_prompt),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
layers.extend(
|
||||||
|
[
|
||||||
|
RunLayerSpec(
|
||||||
|
name=WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||||
|
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||||
|
metadata={**run_input.metadata, "origin": "workflow_node_job"},
|
||||||
|
config=PromptLayerConfig(prefix=run_input.workflow_node_job_prompt),
|
||||||
|
),
|
||||||
|
RunLayerSpec(
|
||||||
|
name=WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||||
|
type=PLAIN_PROMPT_LAYER_TYPE_ID,
|
||||||
|
metadata={**run_input.metadata, "origin": "workflow_user_prompt"},
|
||||||
|
config=PromptLayerConfig(user=run_input.user_prompt),
|
||||||
|
),
|
||||||
|
RunLayerSpec(
|
||||||
|
name=DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||||
|
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||||
|
metadata=run_input.metadata,
|
||||||
|
config=run_input.execution_context,
|
||||||
|
),
|
||||||
|
RunLayerSpec(
|
||||||
|
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||||
|
type=DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||||
|
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||||
|
metadata=run_input.metadata,
|
||||||
|
config=DifyPluginLLMLayerConfig(
|
||||||
|
plugin_id=run_input.model.plugin_id,
|
||||||
|
model_provider=run_input.model.model_provider,
|
||||||
|
model=run_input.model.model,
|
||||||
|
credentials=run_input.model.credentials,
|
||||||
|
model_settings=run_input.model.model_settings or None,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_input.tools is not None and run_input.tools.tools:
|
||||||
|
layers.append(
|
||||||
|
RunLayerSpec(
|
||||||
|
name=DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||||
|
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||||
|
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||||
|
metadata=run_input.metadata,
|
||||||
|
config=run_input.tools,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if run_input.output is not None:
|
||||||
|
layers.append(
|
||||||
|
RunLayerSpec(
|
||||||
|
name=DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||||
|
type=DIFY_OUTPUT_LAYER_TYPE_ID,
|
||||||
|
metadata=run_input.metadata,
|
||||||
|
config=DifyOutputLayerConfig(
|
||||||
|
json_schema=run_input.output.json_schema,
|
||||||
|
description=run_input.output.description,
|
||||||
|
strict=run_input.output.strict,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CreateRunRequest(
|
||||||
|
composition=RunComposition(layers=layers),
|
||||||
|
purpose=run_input.purpose,
|
||||||
|
idempotency_key=run_input.idempotency_key,
|
||||||
|
metadata=run_input.metadata,
|
||||||
|
session_snapshot=run_input.session_snapshot,
|
||||||
|
on_exit=LayerExitSignals(
|
||||||
|
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_SENSITIVE_KEY_PARTS = ("secret", "credential", "token", "password", "api_key")
|
||||||
|
|
||||||
|
|
||||||
|
def redact_for_agent_backend_log(value: object) -> object:
|
||||||
|
"""Return a JSON-like copy with credential-bearing keys redacted for logs/tests."""
|
||||||
|
if isinstance(value, BaseModel):
|
||||||
|
return redact_for_agent_backend_log(value.model_dump(mode="json", warnings=False))
|
||||||
|
if isinstance(value, dict):
|
||||||
|
redacted: dict[object, object] = {}
|
||||||
|
for key, item in value.items():
|
||||||
|
key_text = str(key).lower()
|
||||||
|
if any(part in key_text for part in _SENSITIVE_KEY_PARTS):
|
||||||
|
redacted[key] = "[REDACTED]"
|
||||||
|
else:
|
||||||
|
redacted[key] = redact_for_agent_backend_log(item)
|
||||||
|
return redacted
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [redact_for_agent_backend_log(item) for item in value]
|
||||||
|
return value
|
||||||
@ -3,6 +3,7 @@ CLI command modules extracted from `commands.py`.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .account import create_tenant, reset_email, reset_password
|
from .account import create_tenant, reset_email, reset_password
|
||||||
|
from .data_migrate import data_migrate, legacy_model_types
|
||||||
from .plugin import (
|
from .plugin import (
|
||||||
extract_plugins,
|
extract_plugins,
|
||||||
extract_unique_plugins,
|
extract_unique_plugins,
|
||||||
@ -44,6 +45,7 @@ __all__ = [
|
|||||||
"clear_orphaned_file_records",
|
"clear_orphaned_file_records",
|
||||||
"convert_to_agent_apps",
|
"convert_to_agent_apps",
|
||||||
"create_tenant",
|
"create_tenant",
|
||||||
|
"data_migrate",
|
||||||
"delete_archived_workflow_runs",
|
"delete_archived_workflow_runs",
|
||||||
"export_app_messages",
|
"export_app_messages",
|
||||||
"extract_plugins",
|
"extract_plugins",
|
||||||
@ -52,6 +54,7 @@ __all__ = [
|
|||||||
"fix_app_site_missing",
|
"fix_app_site_missing",
|
||||||
"install_plugins",
|
"install_plugins",
|
||||||
"install_rag_pipeline_plugins",
|
"install_rag_pipeline_plugins",
|
||||||
|
"legacy_model_types",
|
||||||
"migrate_annotation_vector_database",
|
"migrate_annotation_vector_database",
|
||||||
"migrate_data_for_plugin",
|
"migrate_data_for_plugin",
|
||||||
"migrate_knowledge_vector_database",
|
"migrate_knowledge_vector_database",
|
||||||
|
|||||||
179
api/commands/data_migrate.py
Normal file
179
api/commands/data_migrate.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from contextlib import AbstractContextManager, nullcontext
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
import click
|
||||||
|
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from graphon.model_runtime.entities.model_entities import ModelType
|
||||||
|
from services.legacy_model_type_migration import (
|
||||||
|
VALID_TABLE_NAMES,
|
||||||
|
LegacyModelTypeMigrationService,
|
||||||
|
load_tenant_ids_from_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
_SUPPORTED_MODEL_TYPE_CHOICES = (
|
||||||
|
ModelType.LLM.value,
|
||||||
|
ModelType.TEXT_EMBEDDING.value,
|
||||||
|
ModelType.RERANK.value,
|
||||||
|
)
|
||||||
|
_DEFAULT_CONCURRENCY = os.cpu_count() or 1
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_multi_value_option(
|
||||||
|
values: tuple[str, ...],
|
||||||
|
*,
|
||||||
|
valid_values: tuple[str, ...],
|
||||||
|
option_name: str,
|
||||||
|
) -> tuple[str, ...]:
|
||||||
|
normalized_values: list[str] = []
|
||||||
|
seen_values: set[str] = set()
|
||||||
|
|
||||||
|
for value in values:
|
||||||
|
for item in value.split(","):
|
||||||
|
normalized_item = item.strip()
|
||||||
|
if not normalized_item:
|
||||||
|
continue
|
||||||
|
if normalized_item not in valid_values:
|
||||||
|
raise click.BadParameter(
|
||||||
|
f"invalid value '{normalized_item}'. valid values: {', '.join(valid_values)}",
|
||||||
|
param_hint=option_name,
|
||||||
|
)
|
||||||
|
if normalized_item in seen_values:
|
||||||
|
continue
|
||||||
|
seen_values.add(normalized_item)
|
||||||
|
normalized_values.append(normalized_item)
|
||||||
|
|
||||||
|
return tuple(normalized_values)
|
||||||
|
|
||||||
|
|
||||||
|
@click.group(
|
||||||
|
"data-migrate",
|
||||||
|
help="Online data migration commands.",
|
||||||
|
)
|
||||||
|
def data_migrate() -> None:
|
||||||
|
"""Namespace for production data migration commands."""
|
||||||
|
|
||||||
|
|
||||||
|
@click.command(
|
||||||
|
"legacy-model-types",
|
||||||
|
help=(
|
||||||
|
"Migrate legacy provider model_type values to canonical values. "
|
||||||
|
"Default is dry-run and emits JSON lines only. "
|
||||||
|
"If --tables includes provider_model_credentials, the command may also update "
|
||||||
|
"provider_models and load_balancing_model_configs references so merged credentials stay reachable."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--apply",
|
||||||
|
is_flag=True,
|
||||||
|
default=False,
|
||||||
|
help="Apply the migration. Default is dry-run.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tables",
|
||||||
|
"tables",
|
||||||
|
multiple=True,
|
||||||
|
type=str,
|
||||||
|
help=(
|
||||||
|
"Limit migration to specific tables. Accepts comma-separated values or repeated flags.\n"
|
||||||
|
"\n"
|
||||||
|
"Options: load_balancing_model_configs, provider_model_credentials, "
|
||||||
|
"provider_model_settings, provider_models, tenant_default_models.\n\n"
|
||||||
|
"When provider_model_credentials is selected, provider_models and "
|
||||||
|
"load_balancing_model_configs may also be updated for credential reference rewrites.\n"
|
||||||
|
"\n"
|
||||||
|
"If unspecified, all relevant tables are migrated."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--model-types",
|
||||||
|
"model_types",
|
||||||
|
multiple=True,
|
||||||
|
type=str,
|
||||||
|
help=(
|
||||||
|
"Canonical model types to migrate. Accepts comma-separated values or repeated flags.\n"
|
||||||
|
"\n"
|
||||||
|
"Options: llm,text-embedding,rerank\n"
|
||||||
|
"\n"
|
||||||
|
"If unspecified, all relevant legacy model types are migrated."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--tenant-id-file",
|
||||||
|
type=click.Path(exists=True, dir_okay=False, readable=True, resolve_path=True),
|
||||||
|
help="Optional file containing tenant ids, one per line.",
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--output",
|
||||||
|
type=click.Path(dir_okay=False, resolve_path=True, path_type=Path),
|
||||||
|
help=(
|
||||||
|
"Optional file path for JSON lines event logs. Defaults to stdout.\n"
|
||||||
|
"It's highly recommended to save the event logs to a file and preserve it for a period of time."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
@click.option(
|
||||||
|
"--concurrency",
|
||||||
|
type=click.IntRange(min=1),
|
||||||
|
default=_DEFAULT_CONCURRENCY,
|
||||||
|
show_default=True,
|
||||||
|
help="Number of tenant-level worker threads to run in parallel.",
|
||||||
|
)
|
||||||
|
def legacy_model_types(
|
||||||
|
apply: bool,
|
||||||
|
tables: tuple[str, ...],
|
||||||
|
model_types: tuple[str, ...],
|
||||||
|
tenant_id_file: str | None,
|
||||||
|
output: Path | None,
|
||||||
|
concurrency: int = _DEFAULT_CONCURRENCY,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Migrate legacy provider-related model_type values and emit JSON lines events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
normalized_tables = _normalize_multi_value_option(
|
||||||
|
tables,
|
||||||
|
valid_values=VALID_TABLE_NAMES,
|
||||||
|
option_name="--tables",
|
||||||
|
)
|
||||||
|
normalized_model_types = _normalize_multi_value_option(
|
||||||
|
model_types,
|
||||||
|
valid_values=_SUPPORTED_MODEL_TYPE_CHOICES,
|
||||||
|
option_name="--model-types",
|
||||||
|
)
|
||||||
|
selected_model_types = (
|
||||||
|
tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
|
||||||
|
if normalized_model_types
|
||||||
|
else (
|
||||||
|
ModelType.LLM,
|
||||||
|
ModelType.TEXT_EMBEDDING,
|
||||||
|
ModelType.RERANK,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
tenant_ids = load_tenant_ids_from_file(tenant_id_file) if tenant_id_file else None
|
||||||
|
|
||||||
|
output_context: AbstractContextManager[io.TextIOBase]
|
||||||
|
if output is None:
|
||||||
|
output_context = nullcontext(cast(io.TextIOBase, sys.stdout))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
output_context = output.open("w", encoding="utf-8")
|
||||||
|
except OSError as exc:
|
||||||
|
raise click.ClickException(f"failed to open output file '{output}': {exc.strerror or exc}") from exc
|
||||||
|
|
||||||
|
with output_context as output_stream:
|
||||||
|
LegacyModelTypeMigrationService(
|
||||||
|
engine=db.engine,
|
||||||
|
apply=apply,
|
||||||
|
concurrency=concurrency,
|
||||||
|
output=cast(io.TextIOBase, output_stream),
|
||||||
|
tables=normalized_tables or None,
|
||||||
|
model_types=selected_model_types,
|
||||||
|
tenant_ids=tenant_ids,
|
||||||
|
).migrate()
|
||||||
|
|
||||||
|
|
||||||
|
data_migrate.add_command(legacy_model_types)
|
||||||
@ -11,6 +11,7 @@ from configs import dify_config
|
|||||||
from core.helper import encrypter
|
from core.helper import encrypter
|
||||||
from core.plugin.entities.plugin_daemon import CredentialType
|
from core.plugin.entities.plugin_daemon import CredentialType
|
||||||
from core.plugin.impl.plugin import PluginInstaller
|
from core.plugin.impl.plugin import PluginInstaller
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
from core.tools.utils.system_encryption import encrypt_system_params
|
from core.tools.utils.system_encryption import encrypt_system_params
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from models import Tenant
|
from models import Tenant
|
||||||
@ -20,7 +21,6 @@ from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
|||||||
from models.tools import ToolOAuthSystemClient
|
from models.tools import ToolOAuthSystemClient
|
||||||
from services.plugin.data_migration import PluginDataMigration
|
from services.plugin.data_migration import PluginDataMigration
|
||||||
from services.plugin.plugin_migration import PluginMigration
|
from services.plugin.plugin_migration import PluginMigration
|
||||||
from services.plugin.plugin_service import PluginService
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -185,9 +185,9 @@ def transform_datasource_credentials(environment: str):
|
|||||||
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
|
||||||
jina_plugin_id = "langgenius/jina_datasource"
|
jina_plugin_id = "langgenius/jina_datasource"
|
||||||
if environment == "online":
|
if environment == "online":
|
||||||
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_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) # pyright: ignore[reportPrivateUsage]
|
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) # pyright: ignore[reportPrivateUsage]
|
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
|
||||||
else:
|
else:
|
||||||
notion_plugin_unique_identifier = None
|
notion_plugin_unique_identifier = None
|
||||||
firecrawl_plugin_unique_identifier = None
|
firecrawl_plugin_unique_identifier = None
|
||||||
|
|||||||
@ -14,6 +14,7 @@ from libs.rsa import generate_key_pair
|
|||||||
from models import Tenant
|
from models import Tenant
|
||||||
from models.model import App, AppMode, Conversation
|
from models.model import App, AppMode, Conversation
|
||||||
from models.provider import Provider, ProviderModel
|
from models.provider import Provider, ProviderModel
|
||||||
|
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -23,13 +24,16 @@ DB_UPGRADE_LOCK_TTL_SECONDS = 60
|
|||||||
@click.command(
|
@click.command(
|
||||||
"reset-encrypt-key-pair",
|
"reset-encrypt-key-pair",
|
||||||
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
help="Reset the asymmetric key pair of workspace for encrypt LLM credentials. "
|
||||||
"After the reset, all LLM credentials will become invalid, "
|
"After the reset, all LLM credentials and tool provider credentials "
|
||||||
"requiring re-entry."
|
"(builtin / API / MCP) will be purged, requiring re-entry. "
|
||||||
"Only support SELF_HOSTED mode.",
|
"Only support SELF_HOSTED mode.",
|
||||||
)
|
)
|
||||||
@click.confirmation_option(
|
@click.confirmation_option(
|
||||||
prompt=click.style(
|
prompt=click.style(
|
||||||
"Are you sure you want to reset encrypt key pair? This operation cannot be rolled back!", fg="red"
|
"Are you sure you want to reset encrypt key pair? "
|
||||||
|
"This will also purge builtin / API / MCP tool provider records for every tenant. "
|
||||||
|
"This operation cannot be rolled back!",
|
||||||
|
fg="red",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
def reset_encrypt_key_pair():
|
def reset_encrypt_key_pair():
|
||||||
@ -53,6 +57,13 @@ def reset_encrypt_key_pair():
|
|||||||
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
session.execute(delete(Provider).where(Provider.provider_type == "custom", Provider.tenant_id == tenant.id))
|
||||||
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
session.execute(delete(ProviderModel).where(ProviderModel.tenant_id == tenant.id))
|
||||||
|
|
||||||
|
# Purge tool provider records that hold credentials encrypted under the
|
||||||
|
# tenant key. Leaving them in place causes /console/api/workspaces/current/
|
||||||
|
# tool-providers to 500 because decryption fails on stale ciphertext (#35396).
|
||||||
|
session.execute(delete(BuiltinToolProvider).where(BuiltinToolProvider.tenant_id == tenant.id))
|
||||||
|
session.execute(delete(ApiToolProvider).where(ApiToolProvider.tenant_id == tenant.id))
|
||||||
|
session.execute(delete(MCPToolProvider).where(MCPToolProvider.tenant_id == tenant.id))
|
||||||
|
|
||||||
click.echo(
|
click.echo(
|
||||||
click.style(
|
click.style(
|
||||||
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
f"Congratulations! The asymmetric key pair of workspace {tenant.id} has been reset.",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict, TomlConfigSettingsSource
|
||||||
@ -25,6 +25,7 @@ class RemoteSettingsSourceFactory(PydanticBaseSettingsSource):
|
|||||||
def __init__(self, settings_cls: type[BaseSettings]):
|
def __init__(self, settings_cls: type[BaseSettings]):
|
||||||
super().__init__(settings_cls)
|
super().__init__(settings_cls)
|
||||||
|
|
||||||
|
@override
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@ -90,6 +91,7 @@ class DifyConfig(
|
|||||||
# Thanks for your concentration and consideration.
|
# Thanks for your concentration and consideration.
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@override
|
||||||
def settings_customise_sources(
|
def settings_customise_sources(
|
||||||
cls,
|
cls,
|
||||||
settings_cls: type[BaseSettings],
|
settings_cls: type[BaseSettings],
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
from typing import Literal
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
@ -23,7 +25,7 @@ class DeploymentConfig(BaseSettings):
|
|||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
EDITION: str = Field(
|
EDITION: Literal["SELF_HOSTED", "CLOUD"] = Field(
|
||||||
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
description="Deployment edition of the application (e.g., 'SELF_HOSTED', 'CLOUD')",
|
||||||
default="SELF_HOSTED",
|
default="SELF_HOSTED",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -23,6 +23,12 @@ class EnterpriseFeatureConfig(BaseSettings):
|
|||||||
ge=1, description="Maximum timeout in seconds for enterprise requests", default=5
|
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):
|
class EnterpriseTelemetryConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
from configs.extra.agent_backend_config import AgentBackendConfig
|
||||||
from configs.extra.archive_config import ArchiveStorageConfig
|
from configs.extra.archive_config import ArchiveStorageConfig
|
||||||
from configs.extra.notion_config import NotionConfig
|
from configs.extra.notion_config import NotionConfig
|
||||||
from configs.extra.sentry_config import SentryConfig
|
from configs.extra.sentry_config import SentryConfig
|
||||||
@ -5,6 +6,7 @@ from configs.extra.sentry_config import SentryConfig
|
|||||||
|
|
||||||
class ExtraServiceConfig(
|
class ExtraServiceConfig(
|
||||||
# place the configs in alphabet order
|
# place the configs in alphabet order
|
||||||
|
AgentBackendConfig,
|
||||||
ArchiveStorageConfig,
|
ArchiveStorageConfig,
|
||||||
NotionConfig,
|
NotionConfig,
|
||||||
SentryConfig,
|
SentryConfig,
|
||||||
|
|||||||
23
api/configs/extra/agent_backend_config.py
Normal file
23
api/configs/extra/agent_backend_config.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
|
class AgentBackendConfig(BaseSettings):
|
||||||
|
"""
|
||||||
|
Configuration settings for the Agent backend runtime integration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
AGENT_BACKEND_BASE_URL: str | None = Field(
|
||||||
|
description="Base URL for the Dify Agent backend service.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
AGENT_BACKEND_USE_FAKE: bool = Field(
|
||||||
|
description="Use the deterministic in-process fake Agent backend client.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
AGENT_BACKEND_FAKE_SCENARIO: str = Field(
|
||||||
|
description="Scenario used by the fake Agent backend client.",
|
||||||
|
default="success",
|
||||||
|
)
|
||||||
@ -23,9 +23,9 @@ class SecurityConfig(BaseSettings):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
SECRET_KEY: str = Field(
|
SECRET_KEY: str = Field(
|
||||||
description="Secret key for secure session cookie signing."
|
description="Secret key for secure session cookie signing. "
|
||||||
"Make sure you are changing this key for your deployment with a strong key."
|
"Leave empty to let Dify generate a persistent key in the storage directory, "
|
||||||
"Generate a strong key using `openssl rand -base64 42` or set via the `SECRET_KEY` environment variable.",
|
"or set a strong value via the `SECRET_KEY` environment variable.",
|
||||||
default="",
|
default="",
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -265,6 +265,11 @@ class PluginConfig(BaseSettings):
|
|||||||
default=60 * 60,
|
default=60 * 60,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||||
|
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||||
|
default=60 * 60 * 24,
|
||||||
|
)
|
||||||
|
|
||||||
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
||||||
description="Maximum allowed size (bytes) for plugin-generated files",
|
description="Maximum allowed size (bytes) for plugin-generated files",
|
||||||
default=50 * 1024 * 1024,
|
default=50 * 1024 * 1024,
|
||||||
@ -520,6 +525,44 @@ class HttpConfig(BaseSettings):
|
|||||||
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
def WEB_API_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
||||||
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
|
return self.inner_WEB_API_CORS_ALLOW_ORIGINS.split(",")
|
||||||
|
|
||||||
|
OPENAPI_ENABLED: bool = Field(
|
||||||
|
description=(
|
||||||
|
"Enable the /openapi/v1/* endpoint group used by difyctl and other "
|
||||||
|
"programmatic clients. Set to true to activate; disabled by default."
|
||||||
|
),
|
||||||
|
validation_alias=AliasChoices("OPENAPI_ENABLED"),
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
inner_OPENAPI_CORS_ALLOW_ORIGINS: str = Field(
|
||||||
|
description=(
|
||||||
|
"Comma-separated allowlist for /openapi/v1/* CORS. "
|
||||||
|
"Default empty = same-origin only. Browser-cookie routes within "
|
||||||
|
"the group reject cross-origin OPTIONS regardless of this list."
|
||||||
|
),
|
||||||
|
validation_alias=AliasChoices("OPENAPI_CORS_ALLOW_ORIGINS"),
|
||||||
|
default="",
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field
|
||||||
|
def OPENAPI_CORS_ALLOW_ORIGINS(self) -> list[str]:
|
||||||
|
return [o for o in self.inner_OPENAPI_CORS_ALLOW_ORIGINS.split(",") if o]
|
||||||
|
|
||||||
|
inner_OPENAPI_KNOWN_CLIENT_IDS: str = Field(
|
||||||
|
description=(
|
||||||
|
"Comma-separated client_id values accepted at "
|
||||||
|
"POST /openapi/v1/oauth/device/code. New CLIs / SDKs added here "
|
||||||
|
"without code changes. Unknown client_id returns 400 unsupported_client."
|
||||||
|
),
|
||||||
|
validation_alias=AliasChoices("OPENAPI_KNOWN_CLIENT_IDS"),
|
||||||
|
default="difyctl",
|
||||||
|
)
|
||||||
|
|
||||||
|
@computed_field # type: ignore[misc]
|
||||||
|
@property
|
||||||
|
def OPENAPI_KNOWN_CLIENT_IDS(self) -> frozenset[str]:
|
||||||
|
return frozenset(c for c in self.inner_OPENAPI_KNOWN_CLIENT_IDS.split(",") if c)
|
||||||
|
|
||||||
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
|
HTTP_REQUEST_MAX_CONNECT_TIMEOUT: int = Field(
|
||||||
ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
|
ge=1, description="Maximum connection timeout in seconds for HTTP requests", default=10
|
||||||
)
|
)
|
||||||
@ -761,7 +804,7 @@ class WorkflowConfig(BaseSettings):
|
|||||||
# GraphEngine Worker Pool Configuration
|
# GraphEngine Worker Pool Configuration
|
||||||
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
|
GRAPH_ENGINE_MIN_WORKERS: PositiveInt = Field(
|
||||||
description="Minimum number of workers per GraphEngine instance",
|
description="Minimum number of workers per GraphEngine instance",
|
||||||
default=1,
|
default=3,
|
||||||
)
|
)
|
||||||
|
|
||||||
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
|
GRAPH_ENGINE_MAX_WORKERS: PositiveInt = Field(
|
||||||
@ -895,6 +938,17 @@ class AuthConfig(BaseSettings):
|
|||||||
default=86400,
|
default=86400,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
ENABLE_OAUTH_BEARER: bool = Field(
|
||||||
|
description="Enable OAuth bearer authentication (device-flow + Service API /v1/* bearer middleware).",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field(
|
||||||
|
description="Per-token rate limit on /openapi/v1/* (requests per minute). "
|
||||||
|
"Bucket keyed on sha256(token), shared across api replicas via Redis.",
|
||||||
|
default=60,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ModerationConfig(BaseSettings):
|
class ModerationConfig(BaseSettings):
|
||||||
"""
|
"""
|
||||||
@ -1137,6 +1191,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):
|
class CeleryBeatConfig(BaseSettings):
|
||||||
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
CELERY_BEAT_SCHEDULER_TIME: int = Field(
|
||||||
description="Interval in days for Celery Beat scheduler execution, default to 1 day",
|
description="Interval in days for Celery Beat scheduler execution, default to 1 day",
|
||||||
@ -1169,6 +1235,14 @@ class CeleryScheduleTasksConfig(BaseSettings):
|
|||||||
description="Enable scheduled workflow run cleanup task",
|
description="Enable scheduled workflow run cleanup task",
|
||||||
default=False,
|
default=False,
|
||||||
)
|
)
|
||||||
|
ENABLE_CLEAN_OAUTH_ACCESS_TOKENS_TASK: bool = Field(
|
||||||
|
description="Enable scheduled cleanup of revoked/expired OAuth access-token rows past retention.",
|
||||||
|
default=True,
|
||||||
|
)
|
||||||
|
OAUTH_ACCESS_TOKEN_RETENTION_DAYS: PositiveInt = Field(
|
||||||
|
description="Days to retain revoked OAuth access-token rows before deletion.",
|
||||||
|
default=30,
|
||||||
|
)
|
||||||
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
|
ENABLE_MAIL_CLEAN_DOCUMENT_NOTIFY_TASK: bool = Field(
|
||||||
description="Enable mail clean document notify task",
|
description="Enable mail clean document notify task",
|
||||||
default=False,
|
default=False,
|
||||||
@ -1298,7 +1372,7 @@ class PositionConfig(BaseSettings):
|
|||||||
class CollaborationConfig(BaseSettings):
|
class CollaborationConfig(BaseSettings):
|
||||||
ENABLE_COLLABORATION_MODE: bool = Field(
|
ENABLE_COLLABORATION_MODE: bool = Field(
|
||||||
description="Whether to enable collaboration mode features across the workspace",
|
description="Whether to enable collaboration mode features across the workspace",
|
||||||
default=False,
|
default=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1417,6 +1491,7 @@ class FeatureConfig(
|
|||||||
ModelLoadBalanceConfig,
|
ModelLoadBalanceConfig,
|
||||||
ModerationConfig,
|
ModerationConfig,
|
||||||
MultiModalTransferConfig,
|
MultiModalTransferConfig,
|
||||||
|
OpsTraceConfig,
|
||||||
PositionConfig,
|
PositionConfig,
|
||||||
RagEtlConfig,
|
RagEtlConfig,
|
||||||
RepositoryConfig,
|
RepositoryConfig,
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
from typing import Any, Literal, TypedDict
|
from typing import Any, Literal, TypedDict, cast
|
||||||
from urllib.parse import parse_qsl, quote_plus
|
from urllib.parse import parse_qsl, quote_plus
|
||||||
|
|
||||||
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
from pydantic import Field, NonNegativeFloat, NonNegativeInt, PositiveFloat, PositiveInt, computed_field
|
||||||
@ -50,28 +50,30 @@ from .vdb.vastbase_vector_config import VastbaseVectorConfig
|
|||||||
from .vdb.vikingdb_config import VikingDBConfig
|
from .vdb.vikingdb_config import VikingDBConfig
|
||||||
from .vdb.weaviate_config import WeaviateConfig
|
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):
|
class StorageConfig(BaseSettings):
|
||||||
STORAGE_TYPE: Literal[
|
STORAGE_TYPE: _VALID_STORAGE_TYPE = Field(
|
||||||
"opendal",
|
|
||||||
"s3",
|
|
||||||
"aliyun-oss",
|
|
||||||
"azure-blob",
|
|
||||||
"baidu-obs",
|
|
||||||
"clickzetta-volume",
|
|
||||||
"google-storage",
|
|
||||||
"huawei-obs",
|
|
||||||
"oci-storage",
|
|
||||||
"tencent-cos",
|
|
||||||
"volcengine-tos",
|
|
||||||
"supabase",
|
|
||||||
"local",
|
|
||||||
] = Field(
|
|
||||||
description="Type of storage to use."
|
description="Type of storage to use."
|
||||||
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
|
" Options: 'opendal', '(deprecated) local', 's3', 'aliyun-oss', 'azure-blob', 'baidu-obs', "
|
||||||
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
|
"'clickzetta-volume', 'google-storage', 'huawei-obs', 'oci-storage', 'tencent-cos', "
|
||||||
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
|
"'volcengine-tos', 'supabase'. Default is 'opendal'.",
|
||||||
default="opendal",
|
default=cast(_VALID_STORAGE_TYPE, "opendal"),
|
||||||
)
|
)
|
||||||
|
|
||||||
STORAGE_LOCAL_PATH: str = Field(
|
STORAGE_LOCAL_PATH: str = Field(
|
||||||
|
|||||||
@ -2,6 +2,7 @@ from typing import Literal, Protocol, cast
|
|||||||
from urllib.parse import quote_plus, urlunparse
|
from urllib.parse import quote_plus, urlunparse
|
||||||
|
|
||||||
from pydantic import AliasChoices, Field
|
from pydantic import AliasChoices, Field
|
||||||
|
from pydantic.types import NonNegativeInt
|
||||||
from pydantic_settings import BaseSettings
|
from pydantic_settings import BaseSettings
|
||||||
|
|
||||||
|
|
||||||
@ -70,6 +71,24 @@ class RedisPubSubConfig(BaseSettings):
|
|||||||
default=600,
|
default=600,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
|
||||||
|
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
|
||||||
|
description=(
|
||||||
|
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
|
||||||
|
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
|
||||||
|
"an SSE client and the response stream actually closing.\n\n"
|
||||||
|
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
|
||||||
|
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
|
||||||
|
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
|
||||||
|
"return promptly while the daemon listener thread cleans itself up on the next poll "
|
||||||
|
"boundary - safe because the listener holds no critical state and exits within one poll "
|
||||||
|
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
|
||||||
|
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
|
||||||
|
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
|
||||||
|
),
|
||||||
|
default=2000,
|
||||||
|
)
|
||||||
|
|
||||||
def _build_default_pubsub_url(self) -> str:
|
def _build_default_pubsub_url(self) -> str:
|
||||||
defaults = _redis_defaults(self)
|
defaults = _redis_defaults(self)
|
||||||
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
|
||||||
|
|||||||
@ -41,3 +41,21 @@ class MilvusConfig(BaseSettings):
|
|||||||
description='Milvus text analyzer parameters, e.g., {"type": "chinese"} for Chinese segmentation support.',
|
description='Milvus text analyzer parameters, e.g., {"type": "chinese"} for Chinese segmentation support.',
|
||||||
default=None,
|
default=None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MILVUS_SECURE: bool = Field(
|
||||||
|
description="Enable TLS for the Milvus connection (one-way TLS). When True, the client uses gRPC over TLS "
|
||||||
|
"and verifies the server certificate. Equivalent to passing secure=True to pymilvus.",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
MILVUS_SERVER_PEM_PATH: str | None = Field(
|
||||||
|
description="Filesystem path inside the container to the Milvus server certificate (PEM). Mount this via "
|
||||||
|
"a Kubernetes secret. Used as pymilvus's server_pem_path when MILVUS_SECURE is True.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
MILVUS_SERVER_NAME: str | None = Field(
|
||||||
|
description="Server name (TLS SNI / certificate CN or SAN) to verify against the Milvus server certificate. "
|
||||||
|
"Required when MILVUS_SERVER_PEM_PATH is set.",
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from pydantic import Field
|
from pydantic import Field
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
@ -48,6 +48,7 @@ class ApolloSettingsSource(RemoteSettingsSource):
|
|||||||
self.namespace = configs["APOLLO_NAMESPACE"]
|
self.namespace = configs["APOLLO_NAMESPACE"]
|
||||||
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
||||||
|
|
||||||
|
@override
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
if not isinstance(self.remote_configs, dict):
|
if not isinstance(self.remote_configs, dict):
|
||||||
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from typing import Any
|
from typing import Any, override
|
||||||
|
|
||||||
from pydantic.fields import FieldInfo
|
from pydantic.fields import FieldInfo
|
||||||
|
|
||||||
@ -41,6 +41,7 @@ class NacosSettingsSource(RemoteSettingsSource):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise RuntimeError(f"Failed to parse config: {e}")
|
raise RuntimeError(f"Failed to parse config: {e}")
|
||||||
|
|
||||||
|
@override
|
||||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||||
field_value = self.remote_configs.get(field_name)
|
field_value = self.remote_configs.get(field_name)
|
||||||
if field_value is None:
|
if field_value is None:
|
||||||
|
|||||||
38
api/configs/secret_key.py
Normal file
38
api/configs/secret_key.py
Normal file
@ -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
|
||||||
91
api/conftest.py
Normal file
91
api/conftest.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
"""Global pytest hooks for Dify backend tests.
|
||||||
|
|
||||||
|
This root conftest is loaded before package-specific conftests, which lets tests opt
|
||||||
|
into Docker-backed middleware before application modules read environment config.
|
||||||
|
It intentionally lives at the API root because pytest applies conftest.py files to
|
||||||
|
tests below their directory, and this setup is shared by api/tests and api/providers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.pytest_dify import (
|
||||||
|
DEFAULT_MIDDLEWARE_SERVICES,
|
||||||
|
DEFAULT_VDB_SERVICES,
|
||||||
|
DockerComposeStack,
|
||||||
|
build_middleware_stack,
|
||||||
|
build_vdb_stack,
|
||||||
|
ensure_backend_test_environment,
|
||||||
|
ensure_compose_env_files,
|
||||||
|
parse_services,
|
||||||
|
)
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_DIFY_COMPOSE_STACKS_KEY = pytest.StashKey[list[DockerComposeStack]]()
|
||||||
|
|
||||||
|
# This must run at import time because package-specific conftests can import the
|
||||||
|
# Flask app before pytest_configure hooks from this file are called.
|
||||||
|
ensure_backend_test_environment(_REPO_ROOT)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_addoption(parser: pytest.Parser) -> None:
|
||||||
|
group = parser.getgroup("dify")
|
||||||
|
group.addoption(
|
||||||
|
"--start-middleware",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Start the Docker middleware services needed by API integration tests.",
|
||||||
|
)
|
||||||
|
group.addoption(
|
||||||
|
"--middleware-services",
|
||||||
|
default=",".join(DEFAULT_MIDDLEWARE_SERVICES),
|
||||||
|
help="Comma-separated services from docker/docker-compose.middleware.yaml to start.",
|
||||||
|
)
|
||||||
|
group.addoption(
|
||||||
|
"--start-vdb",
|
||||||
|
action="store_true",
|
||||||
|
default=False,
|
||||||
|
help="Start vector-store Docker services for VDB integration tests.",
|
||||||
|
)
|
||||||
|
group.addoption(
|
||||||
|
"--vdb-services",
|
||||||
|
default=",".join(DEFAULT_VDB_SERVICES),
|
||||||
|
help="Comma-separated services from docker/docker-compose.yaml to start for VDB tests.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config: pytest.Config) -> None:
|
||||||
|
config.stash[_DIFY_COMPOSE_STACKS_KEY] = []
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_sessionstart(session: pytest.Session) -> None:
|
||||||
|
config = session.config
|
||||||
|
if hasattr(config, "workerinput"):
|
||||||
|
return
|
||||||
|
|
||||||
|
stacks: list[DockerComposeStack] = []
|
||||||
|
if config.getoption("start_middleware"):
|
||||||
|
ensure_compose_env_files(_REPO_ROOT)
|
||||||
|
stack = build_middleware_stack(_REPO_ROOT, parse_services(config.getoption("middleware_services")))
|
||||||
|
stack.up()
|
||||||
|
stacks.append(stack)
|
||||||
|
|
||||||
|
if config.getoption("start_vdb"):
|
||||||
|
ensure_compose_env_files(_REPO_ROOT)
|
||||||
|
stack = build_vdb_stack(_REPO_ROOT, parse_services(config.getoption("vdb_services")))
|
||||||
|
stack.up()
|
||||||
|
stacks.append(stack)
|
||||||
|
|
||||||
|
config.stash[_DIFY_COMPOSE_STACKS_KEY] = stacks
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_unconfigure(config: pytest.Config) -> None:
|
||||||
|
if hasattr(config, "workerinput"):
|
||||||
|
return
|
||||||
|
|
||||||
|
stacks = config.stash.get(_DIFY_COMPOSE_STACKS_KEY, [])
|
||||||
|
for stack in reversed(stacks):
|
||||||
|
stack.down()
|
||||||
@ -10,7 +10,7 @@ import threading
|
|||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
from collections.abc import Callable, Generator
|
from collections.abc import Callable, Generator
|
||||||
from contextlib import AbstractContextManager, contextmanager
|
from contextlib import AbstractContextManager, contextmanager
|
||||||
from typing import Any, Protocol, final, runtime_checkable
|
from typing import Any, Protocol, final, override, runtime_checkable
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
@ -133,10 +133,12 @@ class NullAppContext(AppContext):
|
|||||||
self._config = config or {}
|
self._config = config or {}
|
||||||
self._extensions: dict[str, Any] = {}
|
self._extensions: dict[str, Any] = {}
|
||||||
|
|
||||||
|
@override
|
||||||
def get_config(self, key: str, default: Any = None) -> Any:
|
def get_config(self, key: str, default: Any = None) -> Any:
|
||||||
"""Get configuration value by key."""
|
"""Get configuration value by key."""
|
||||||
return self._config.get(key, default)
|
return self._config.get(key, default)
|
||||||
|
|
||||||
|
@override
|
||||||
def get_extension(self, name: str) -> Any:
|
def get_extension(self, name: str) -> Any:
|
||||||
"""Get extension by name."""
|
"""Get extension by name."""
|
||||||
return self._extensions.get(name)
|
return self._extensions.get(name)
|
||||||
@ -146,6 +148,7 @@ class NullAppContext(AppContext):
|
|||||||
self._extensions[name] = extension
|
self._extensions[name] = extension
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
@override
|
||||||
def enter(self) -> Generator[None, None, None]:
|
def enter(self) -> Generator[None, None, None]:
|
||||||
"""Enter null context (no-op)."""
|
"""Enter null context (no-op)."""
|
||||||
yield
|
yield
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import contextvars
|
|||||||
import threading
|
import threading
|
||||||
from collections.abc import Generator
|
from collections.abc import Generator
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import Any, final
|
from typing import Any, final, override
|
||||||
|
|
||||||
from flask import Flask, current_app, g
|
from flask import Flask, current_app, g
|
||||||
|
|
||||||
@ -30,15 +30,18 @@ class FlaskAppContext(AppContext):
|
|||||||
"""
|
"""
|
||||||
self._flask_app = flask_app
|
self._flask_app = flask_app
|
||||||
|
|
||||||
|
@override
|
||||||
def get_config(self, key: str, default: Any = None) -> Any:
|
def get_config(self, key: str, default: Any = None) -> Any:
|
||||||
"""Get configuration value from Flask app config."""
|
"""Get configuration value from Flask app config."""
|
||||||
return self._flask_app.config.get(key, default)
|
return self._flask_app.config.get(key, default)
|
||||||
|
|
||||||
|
@override
|
||||||
def get_extension(self, name: str) -> Any:
|
def get_extension(self, name: str) -> Any:
|
||||||
"""Get Flask extension by name."""
|
"""Get Flask extension by name."""
|
||||||
return self._flask_app.extensions.get(name)
|
return self._flask_app.extensions.get(name)
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
|
@override
|
||||||
def enter(self) -> Generator[None, None, None]:
|
def enter(self) -> Generator[None, None, None]:
|
||||||
"""Enter Flask app context."""
|
"""Enter Flask app context."""
|
||||||
with self._flask_app.app_context():
|
with self._flask_app.app_context():
|
||||||
|
|||||||
@ -34,6 +34,7 @@ from controllers.common.schema import (
|
|||||||
register_response_schema_models,
|
register_response_schema_models,
|
||||||
register_schema_models,
|
register_schema_models,
|
||||||
)
|
)
|
||||||
|
from libs.helper import dump_response
|
||||||
```
|
```
|
||||||
|
|
||||||
Register request payload and query models with `register_schema_models(...)`:
|
Register request payload and query models with `register_schema_models(...)`:
|
||||||
@ -82,7 +83,7 @@ register_schema_models(console_ns, DraftWorkflowNodeRunPayload)
|
|||||||
def post(self, app_model: App, node_id: str):
|
def post(self, app_model: App, node_id: str):
|
||||||
payload = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
|
payload = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
|
||||||
result = service.run(..., inputs=payload.inputs, query=payload.query)
|
result = service.run(..., inputs=payload.inputs, query=payload.query)
|
||||||
return WorkflowRunNodeExecutionResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
|
return dump_response(WorkflowRunNodeExecutionResponse, result)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Query Parameters
|
## Query Parameters
|
||||||
@ -105,7 +106,7 @@ class WorkflowRunListQuery(BaseModel):
|
|||||||
def get(self, app_model: App):
|
def get(self, app_model: App):
|
||||||
query = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True))
|
query = WorkflowRunListQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
result = service.list(..., limit=query.limit, last_id=query.last_id)
|
result = service.list(..., limit=query.limit, last_id=query.last_id)
|
||||||
return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json")
|
return dump_response(WorkflowRunPaginationResponse, result)
|
||||||
```
|
```
|
||||||
|
|
||||||
Do not do this for GET query parameters:
|
Do not do this for GET query parameters:
|
||||||
@ -145,10 +146,25 @@ def post(...):
|
|||||||
Serialize explicitly:
|
Serialize explicitly:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
return WorkflowRunNodeExecutionResponse.model_validate(
|
return dump_response(WorkflowRunNodeExecutionResponse, workflow_node_execution)
|
||||||
workflow_node_execution,
|
```
|
||||||
from_attributes=True,
|
|
||||||
).model_dump(mode="json")
|
`dump_response(...)` is the preferred response serialization helper for a single Pydantic response DTO. It validates
|
||||||
|
with `from_attributes=True` and returns `model_dump(mode="json")`, so SQLAlchemy models, plain objects, dictionaries,
|
||||||
|
Pydantic aliases, computed fields, and `datetime` values are serialized consistently.
|
||||||
|
|
||||||
|
For wrapper responses, pass a dictionary with the public wrapper fields:
|
||||||
|
|
||||||
|
```python
|
||||||
|
return dump_response(
|
||||||
|
WorkflowRunPaginationResponse,
|
||||||
|
{
|
||||||
|
"data": workflow_runs,
|
||||||
|
"page": page,
|
||||||
|
"limit": limit,
|
||||||
|
"has_more": has_more,
|
||||||
|
},
|
||||||
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
If the service can return `None`, translate that into the expected HTTP error before validation:
|
If the service can return `None`, translate that into the expected HTTP error before validation:
|
||||||
@ -158,9 +174,12 @@ workflow_run = service.get_workflow_run(...)
|
|||||||
if workflow_run is None:
|
if workflow_run is None:
|
||||||
raise NotFound("Workflow run not found")
|
raise NotFound("Workflow run not found")
|
||||||
|
|
||||||
return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json")
|
return dump_response(WorkflowRunDetailResponse, workflow_run)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Use manual `model_validate(...).model_dump(...)` only when the endpoint needs behavior that `dump_response(...)` does
|
||||||
|
not provide, such as returning a non-dict payload, intentionally excluding fields, or composing a `(body, status)` tuple.
|
||||||
|
|
||||||
## Legacy Flask-RESTX Patterns
|
## Legacy Flask-RESTX Patterns
|
||||||
|
|
||||||
Avoid adding these patterns to new or migrated endpoints:
|
Avoid adding these patterns to new or migrated endpoints:
|
||||||
@ -190,4 +209,3 @@ Inspect affected endpoints with `jq`. Check that:
|
|||||||
- Request bodies appear only where the endpoint has a body.
|
- Request bodies appear only where the endpoint has a body.
|
||||||
- Responses reference the expected `*Response` schema.
|
- Responses reference the expected `*Response` schema.
|
||||||
- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`.
|
- Response schemas use public serialized names, not internal validation aliases like `inputs_dict`.
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, computed_field
|
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||||
|
|
||||||
|
from fields.base import ResponseModel
|
||||||
from graphon.file import helpers as file_helpers
|
from graphon.file import helpers as file_helpers
|
||||||
from models.model import IconType
|
from models.model import IconType
|
||||||
|
|
||||||
@ -19,6 +20,113 @@ class SystemParameters(BaseModel):
|
|||||||
workflow_file_upload_limit: int
|
workflow_file_upload_limit: int
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResultResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResultMessageResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleMessageResponse(ResponseModel):
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleDataResponse(ResponseModel):
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResultDataResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
data: str
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResultStringListResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
data: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleResultOptionalDataResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
data: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTokenData(ResponseModel):
|
||||||
|
access_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class AccessTokenResultResponse(ResponseModel):
|
||||||
|
result: str
|
||||||
|
data: AccessTokenData
|
||||||
|
|
||||||
|
|
||||||
|
class VerificationTokenResponse(ResponseModel):
|
||||||
|
is_valid: bool
|
||||||
|
email: str
|
||||||
|
token: str
|
||||||
|
|
||||||
|
|
||||||
|
class LoginStatusResponse(ResponseModel):
|
||||||
|
logged_in: bool
|
||||||
|
app_logged_in: bool
|
||||||
|
|
||||||
|
|
||||||
|
class AccessModeResponse(ResponseModel):
|
||||||
|
access_mode: str = Field(serialization_alias="accessMode", validation_alias="accessMode")
|
||||||
|
|
||||||
|
|
||||||
|
class BooleanResultResponse(ResponseModel):
|
||||||
|
result: bool
|
||||||
|
|
||||||
|
|
||||||
|
class SuccessResponse(ResponseModel):
|
||||||
|
success: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UsageCheckResponse(ResponseModel):
|
||||||
|
is_using: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UsageCountResponse(ResponseModel):
|
||||||
|
is_using: bool
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class IndexInfoResponse(ResponseModel):
|
||||||
|
welcome: str
|
||||||
|
api_version: str
|
||||||
|
server_version: str
|
||||||
|
|
||||||
|
|
||||||
|
class AvatarUrlResponse(ResponseModel):
|
||||||
|
avatar_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class TextContentResponse(ResponseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
|
class AllowedExtensionsResponse(ResponseModel):
|
||||||
|
allowed_extensions: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class UrlResponse(ResponseModel):
|
||||||
|
url: str
|
||||||
|
|
||||||
|
|
||||||
|
class RedirectUrlResponse(ResponseModel):
|
||||||
|
redirect_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApiBaseUrlResponse(ResponseModel):
|
||||||
|
api_base_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class NewAppResponse(ResponseModel):
|
||||||
|
new_app_id: str
|
||||||
|
|
||||||
|
|
||||||
class Parameters(BaseModel):
|
class Parameters(BaseModel):
|
||||||
opening_statement: str | None = None
|
opening_statement: str | None = None
|
||||||
suggested_questions: list[str]
|
suggested_questions: list[str]
|
||||||
|
|||||||
@ -36,6 +36,24 @@ class FileInfo(BaseModel):
|
|||||||
size: int
|
size: int
|
||||||
|
|
||||||
|
|
||||||
|
def decode_remote_url(url: str, query_string: bytes | str = b"") -> str:
|
||||||
|
decoded_url = urllib.parse.unquote(url)
|
||||||
|
if isinstance(query_string, bytes):
|
||||||
|
raw_query = query_string.decode()
|
||||||
|
else:
|
||||||
|
raw_query = query_string
|
||||||
|
if not raw_query:
|
||||||
|
return decoded_url
|
||||||
|
|
||||||
|
if decoded_url.endswith(("?", "&")):
|
||||||
|
separator = ""
|
||||||
|
elif urllib.parse.urlsplit(decoded_url).query:
|
||||||
|
separator = "&"
|
||||||
|
else:
|
||||||
|
separator = "?"
|
||||||
|
return f"{decoded_url}{separator}{raw_query}"
|
||||||
|
|
||||||
|
|
||||||
def guess_file_info_from_response(response: httpx.Response):
|
def guess_file_info_from_response(response: httpx.Response):
|
||||||
url = str(response.url)
|
url = str(response.url)
|
||||||
# Try to extract filename from URL
|
# Try to extract filename from URL
|
||||||
|
|||||||
@ -1,6 +1,21 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
from pydantic import BaseModel, JsonValue
|
from pydantic import BaseModel, JsonValue
|
||||||
|
|
||||||
|
|
||||||
class HumanInputFormSubmitPayload(BaseModel):
|
class HumanInputFormSubmitPayload(BaseModel):
|
||||||
inputs: dict[str, JsonValue]
|
inputs: dict[str, JsonValue]
|
||||||
action: str
|
action: str
|
||||||
|
|
||||||
|
|
||||||
|
def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]:
|
||||||
|
"""Serialize default values into strings expected by human-input form clients."""
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for key, value in values.items():
|
||||||
|
if value is None:
|
||||||
|
result[key] = ""
|
||||||
|
elif isinstance(value, (dict, list)):
|
||||||
|
result[key] = json.dumps(value, ensure_ascii=False)
|
||||||
|
else:
|
||||||
|
result[key] = str(value)
|
||||||
|
return result
|
||||||
|
|||||||
@ -39,6 +39,7 @@ QueryParamDoc = TypedDict(
|
|||||||
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
|
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
|
||||||
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
|
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
|
||||||
|
|
||||||
|
schema = _swagger_2_compatible_schema(schema)
|
||||||
nested_definitions = schema.get("$defs")
|
nested_definitions = schema.get("$defs")
|
||||||
schema_to_register = dict(schema)
|
schema_to_register = dict(schema)
|
||||||
if isinstance(nested_definitions, dict):
|
if isinstance(nested_definitions, dict):
|
||||||
@ -65,6 +66,35 @@ def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _swagger_2_compatible_schema(value: Any) -> Any:
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [_swagger_2_compatible_schema(item) for item in value]
|
||||||
|
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
return value
|
||||||
|
|
||||||
|
converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()}
|
||||||
|
any_of = value.get("anyOf")
|
||||||
|
if not isinstance(any_of, list):
|
||||||
|
return converted
|
||||||
|
|
||||||
|
non_null_candidates = [
|
||||||
|
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
|
||||||
|
]
|
||||||
|
has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of)
|
||||||
|
if not has_null_candidate or len(non_null_candidates) != 1:
|
||||||
|
return converted
|
||||||
|
|
||||||
|
non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0]))
|
||||||
|
if not isinstance(non_null_schema, dict):
|
||||||
|
return converted
|
||||||
|
|
||||||
|
converted.pop("anyOf", None)
|
||||||
|
converted.update(non_null_schema)
|
||||||
|
converted["x-nullable"] = True
|
||||||
|
return converted
|
||||||
|
|
||||||
|
|
||||||
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
|
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
|
||||||
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
|
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
|
||||||
|
|
||||||
|
|||||||
@ -33,7 +33,6 @@ for module_name in RESOURCE_MODULES:
|
|||||||
# Ensure resource modules are imported so route decorators are evaluated.
|
# Ensure resource modules are imported so route decorators are evaluated.
|
||||||
# Import other controllers
|
# Import other controllers
|
||||||
from . import (
|
from . import (
|
||||||
admin,
|
|
||||||
apikey,
|
apikey,
|
||||||
extension,
|
extension,
|
||||||
feature,
|
feature,
|
||||||
@ -45,6 +44,8 @@ from . import (
|
|||||||
spec,
|
spec,
|
||||||
version,
|
version,
|
||||||
)
|
)
|
||||||
|
from .agent import composer as agent_composer
|
||||||
|
from .agent import roster as agent_roster
|
||||||
|
|
||||||
# Import app controllers
|
# Import app controllers
|
||||||
from .app import (
|
from .app import (
|
||||||
@ -67,6 +68,7 @@ from .app import (
|
|||||||
workflow_app_log,
|
workflow_app_log,
|
||||||
workflow_comment,
|
workflow_comment,
|
||||||
workflow_draft_variable,
|
workflow_draft_variable,
|
||||||
|
workflow_node_output_inspector,
|
||||||
workflow_run,
|
workflow_run,
|
||||||
workflow_statistic,
|
workflow_statistic,
|
||||||
workflow_trigger,
|
workflow_trigger,
|
||||||
@ -117,7 +119,7 @@ from .explore import (
|
|||||||
saved_message,
|
saved_message,
|
||||||
trial,
|
trial,
|
||||||
)
|
)
|
||||||
from .socketio import workflow as socketio_workflow # pyright: ignore[reportUnusedImport]
|
from .socketio import workflow as socketio_workflow
|
||||||
|
|
||||||
# Import tag controllers
|
# Import tag controllers
|
||||||
from .tag import tags
|
from .tag import tags
|
||||||
@ -142,10 +144,11 @@ api.add_namespace(console_ns)
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"account",
|
"account",
|
||||||
"activate",
|
"activate",
|
||||||
"admin",
|
|
||||||
"advanced_prompt_template",
|
"advanced_prompt_template",
|
||||||
"agent",
|
"agent",
|
||||||
|
"agent_composer",
|
||||||
"agent_providers",
|
"agent_providers",
|
||||||
|
"agent_roster",
|
||||||
"annotation",
|
"annotation",
|
||||||
"api",
|
"api",
|
||||||
"apikey",
|
"apikey",
|
||||||
@ -216,6 +219,7 @@ __all__ = [
|
|||||||
"workflow_app_log",
|
"workflow_app_log",
|
||||||
"workflow_comment",
|
"workflow_comment",
|
||||||
"workflow_draft_variable",
|
"workflow_draft_variable",
|
||||||
|
"workflow_node_output_inspector",
|
||||||
"workflow_run",
|
"workflow_run",
|
||||||
"workflow_statistic",
|
"workflow_statistic",
|
||||||
"workflow_trigger",
|
"workflow_trigger",
|
||||||
|
|||||||
@ -1,64 +1,11 @@
|
|||||||
import csv
|
|
||||||
import io
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import cast
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from werkzeug.exceptions import Unauthorized
|
||||||
from pydantic import BaseModel, Field, field_validator
|
|
||||||
from sqlalchemy import select
|
|
||||||
from werkzeug.exceptions import BadRequest, NotFound, Unauthorized
|
|
||||||
|
|
||||||
from configs import dify_config
|
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 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]:
|
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 view(*args, **kwargs)
|
||||||
|
|
||||||
return decorated
|
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
|
|
||||||
|
|||||||
3
api/controllers/console/agent/__init__.py
Normal file
3
api/controllers/console/agent/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from . import composer, roster
|
||||||
|
|
||||||
|
__all__ = ["composer", "roster"]
|
||||||
153
api/controllers/console/agent/composer.py
Normal file
153
api/controllers/console/agent/composer.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
from flask_restx import Resource
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
|
from controllers.console import console_ns
|
||||||
|
from controllers.console.app.wraps import get_app_model
|
||||||
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
|
from libs.login import current_account_with_tenant, login_required
|
||||||
|
from models.model import App, AppMode
|
||||||
|
from services.agent.composer_service import AgentComposerService
|
||||||
|
from services.agent.composer_validator import ComposerConfigValidator
|
||||||
|
from services.entities.agent_entities import ComposerSavePayload
|
||||||
|
|
||||||
|
register_schema_models(console_ns, ComposerSavePayload)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||||
|
class WorkflowAgentComposerApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def get(self, app_model: App, node_id: str):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
return AgentComposerService.load_workflow_composer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
node_id=node_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def put(self, app_model: App, node_id: str):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
return AgentComposerService.save_workflow_composer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
node_id=node_id,
|
||||||
|
account_id=account.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
|
||||||
|
class WorkflowAgentComposerValidateApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def post(self, app_model: App, node_id: str):
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
ComposerConfigValidator.validate_save_payload(payload)
|
||||||
|
return {"result": "success", "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
|
||||||
|
class WorkflowAgentComposerCandidatesApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def get(self, app_model: App, node_id: str):
|
||||||
|
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/impact")
|
||||||
|
class WorkflowAgentComposerImpactApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def post(self, app_model: App, node_id: str):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
|
||||||
|
if not current_snapshot_id:
|
||||||
|
return {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
|
||||||
|
return AgentComposerService.calculate_impact(tenant_id=tenant_id, current_snapshot_id=current_snapshot_id)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/save-to-roster")
|
||||||
|
class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||||
|
def post(self, app_model: App, node_id: str):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
return AgentComposerService.save_workflow_composer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
node_id=node_id,
|
||||||
|
account_id=account.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
|
||||||
|
class AgentAppComposerApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
def get(self, app_model: App):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
@get_app_model()
|
||||||
|
def put(self, app_model: App):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
return AgentComposerService.save_agent_app_composer(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
app_id=app_model.id,
|
||||||
|
account_id=account.id,
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
|
||||||
|
class AgentAppComposerValidateApi(Resource):
|
||||||
|
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
def post(self, app_model: App):
|
||||||
|
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||||
|
ComposerConfigValidator.validate_save_payload(payload)
|
||||||
|
return {"result": "success", "errors": []}
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
|
||||||
|
class AgentAppComposerCandidatesApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@get_app_model()
|
||||||
|
def get(self, app_model: App):
|
||||||
|
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)
|
||||||
132
api/controllers/console/agent/roster.py
Normal file
132
api/controllers/console/agent/roster.py
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from flask import request
|
||||||
|
from flask_restx import Resource
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from controllers.common.schema import register_schema_models
|
||||||
|
from controllers.console import console_ns
|
||||||
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
|
from extensions.ext_database import db
|
||||||
|
from libs.login import current_account_with_tenant, login_required
|
||||||
|
from services.agent.roster_service import AgentRosterService
|
||||||
|
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
|
||||||
|
|
||||||
|
|
||||||
|
class AgentInviteOptionsQuery(RosterListQuery):
|
||||||
|
app_id: str | None = Field(default=None, description="Workflow app id for in-current-workflow markers")
|
||||||
|
|
||||||
|
|
||||||
|
class AgentIdPath(BaseModel):
|
||||||
|
agent_id: str
|
||||||
|
|
||||||
|
|
||||||
|
register_schema_models(
|
||||||
|
console_ns,
|
||||||
|
AgentInviteOptionsQuery,
|
||||||
|
AgentIdPath,
|
||||||
|
RosterAgentCreatePayload,
|
||||||
|
RosterAgentUpdatePayload,
|
||||||
|
RosterListQuery,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _agent_roster_service() -> AgentRosterService:
|
||||||
|
return AgentRosterService(db.session)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/agents")
|
||||||
|
class AgentRosterListApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
def get(self):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
return _agent_roster_service().list_roster_agents(
|
||||||
|
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
|
||||||
|
)
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
|
||||||
|
service = _agent_roster_service()
|
||||||
|
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account.id, payload=payload)
|
||||||
|
return service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id), 201
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/agents/invite-options")
|
||||||
|
class AgentInviteOptionsApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
def get(self):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
query = AgentInviteOptionsQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
return _agent_roster_service().list_invite_options(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
page=query.page,
|
||||||
|
limit=query.limit,
|
||||||
|
keyword=query.keyword,
|
||||||
|
app_id=query.app_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/agents/<uuid:agent_id>")
|
||||||
|
class AgentRosterDetailApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
def get(self, agent_id: UUID):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
return _agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||||
|
|
||||||
|
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
def patch(self, agent_id: UUID):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
|
return _agent_roster_service().update_roster_agent(
|
||||||
|
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id, payload=payload
|
||||||
|
)
|
||||||
|
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
@edit_permission_required
|
||||||
|
def delete(self, agent_id: UUID):
|
||||||
|
account, tenant_id = current_account_with_tenant()
|
||||||
|
_agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account.id)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/agents/<uuid:agent_id>/versions")
|
||||||
|
class AgentRosterVersionsApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
def get(self, agent_id: UUID):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
return {"data": _agent_roster_service().list_agent_versions(tenant_id=tenant_id, agent_id=str(agent_id))}
|
||||||
|
|
||||||
|
|
||||||
|
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
|
||||||
|
class AgentRosterVersionDetailApi(Resource):
|
||||||
|
@setup_required
|
||||||
|
@login_required
|
||||||
|
@account_initialization_required
|
||||||
|
def get(self, agent_id: UUID, version_id: UUID):
|
||||||
|
_, tenant_id = current_account_with_tenant()
|
||||||
|
return _agent_roster_service().get_agent_version_detail(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
agent_id=str(agent_id),
|
||||||
|
version_id=str(version_id),
|
||||||
|
)
|
||||||
@ -1,4 +1,5 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import flask_restx
|
import flask_restx
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
@ -8,23 +9,25 @@ from sqlalchemy import delete, func, select
|
|||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
from werkzeug.exceptions import Forbidden
|
from werkzeug.exceptions import Forbidden
|
||||||
|
|
||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.schema import register_response_schema_models
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.base import ResponseModel
|
from fields.base import ResponseModel
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.helper import dump_response, to_timestamp
|
||||||
|
from libs.login import login_required
|
||||||
|
from models import Account
|
||||||
from models.dataset import Dataset
|
from models.dataset import Dataset
|
||||||
from models.enums import ApiTokenType
|
from models.enums import ApiTokenType
|
||||||
from models.model import ApiToken, App
|
from models.model import ApiToken, App
|
||||||
from services.api_token_service import ApiTokenCache
|
from services.api_token_service import ApiTokenCache
|
||||||
|
|
||||||
from . import console_ns
|
from . import console_ns
|
||||||
from .wraps import account_initialization_required, edit_permission_required, setup_required
|
from .wraps import (
|
||||||
|
account_initialization_required,
|
||||||
|
edit_permission_required,
|
||||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
setup_required,
|
||||||
if isinstance(value, datetime):
|
with_current_tenant_id,
|
||||||
return int(value.timestamp())
|
with_current_user,
|
||||||
return value
|
)
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyItem(ResponseModel):
|
class ApiKeyItem(ResponseModel):
|
||||||
@ -37,14 +40,14 @@ class ApiKeyItem(ResponseModel):
|
|||||||
@field_validator("last_used_at", "created_at", mode="before")
|
@field_validator("last_used_at", "created_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class ApiKeyList(ResponseModel):
|
class ApiKeyList(ResponseModel):
|
||||||
data: list[ApiKeyItem]
|
data: list[ApiKeyItem]
|
||||||
|
|
||||||
|
|
||||||
register_schema_models(console_ns, ApiKeyItem, ApiKeyList)
|
register_response_schema_models(console_ns, ApiKeyItem, ApiKeyList)
|
||||||
|
|
||||||
|
|
||||||
def _get_resource(resource_id, tenant_id, resource_model):
|
def _get_resource(resource_id, tenant_id, resource_model):
|
||||||
@ -68,10 +71,11 @@ class BaseApiKeyListResource(Resource):
|
|||||||
token_prefix: str | None = None
|
token_prefix: str | None = None
|
||||||
max_keys = 10
|
max_keys = 10
|
||||||
|
|
||||||
def get(self, resource_id):
|
def get(self, resource_id: str, current_tenant_id: str) -> dict[str, object]:
|
||||||
|
return dump_response(ApiKeyList, self._get_api_key_list(resource_id, current_tenant_id))
|
||||||
|
|
||||||
|
def _get_api_key_list(self, resource_id: str, current_tenant_id: str) -> ApiKeyList:
|
||||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||||
resource_id = str(resource_id)
|
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
|
||||||
|
|
||||||
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
||||||
keys = db.session.scalars(
|
keys = db.session.scalars(
|
||||||
@ -79,13 +83,14 @@ class BaseApiKeyListResource(Resource):
|
|||||||
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
ApiToken.type == self.resource_type, getattr(ApiToken, self.resource_id_field) == resource_id
|
||||||
)
|
)
|
||||||
).all()
|
).all()
|
||||||
return ApiKeyList.model_validate({"data": keys}, from_attributes=True).model_dump(mode="json")
|
return ApiKeyList.model_validate({"data": keys}, from_attributes=True)
|
||||||
|
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, resource_id):
|
def post(self, resource_id: str, current_tenant_id: str) -> tuple[dict[str, object], int]:
|
||||||
|
return dump_response(ApiKeyItem, self._create_api_key(resource_id, current_tenant_id)), 201
|
||||||
|
|
||||||
|
def _create_api_key(self, resource_id: str, current_tenant_id: str) -> ApiToken:
|
||||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||||
resource_id = str(resource_id)
|
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
|
||||||
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
||||||
current_key_count: int = (
|
current_key_count: int = (
|
||||||
db.session.scalar(
|
db.session.scalar(
|
||||||
@ -112,7 +117,7 @@ class BaseApiKeyListResource(Resource):
|
|||||||
api_token.type = self.resource_type
|
api_token.type = self.resource_type
|
||||||
db.session.add(api_token)
|
db.session.add(api_token)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ApiKeyItem.model_validate(api_token, from_attributes=True).model_dump(mode="json"), 201
|
return api_token
|
||||||
|
|
||||||
|
|
||||||
class BaseApiKeyResource(Resource):
|
class BaseApiKeyResource(Resource):
|
||||||
@ -122,9 +127,20 @@ class BaseApiKeyResource(Resource):
|
|||||||
resource_model: type | None = None
|
resource_model: type | None = None
|
||||||
resource_id_field: str | None = None
|
resource_id_field: str | None = None
|
||||||
|
|
||||||
def delete(self, resource_id: str, api_key_id: str):
|
def delete(
|
||||||
|
self, resource_id: str, api_key_id: str, current_tenant_id: str, current_user: Account
|
||||||
|
) -> tuple[str, int]:
|
||||||
|
self._delete_api_key(resource_id, api_key_id, current_tenant_id, current_user)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
|
def _delete_api_key(
|
||||||
|
self,
|
||||||
|
resource_id: str,
|
||||||
|
api_key_id: str,
|
||||||
|
current_tenant_id: str,
|
||||||
|
current_user: Account,
|
||||||
|
) -> None:
|
||||||
assert self.resource_id_field is not None, "resource_id_field must be set"
|
assert self.resource_id_field is not None, "resource_id_field must be set"
|
||||||
current_user, current_tenant_id = current_account_with_tenant()
|
|
||||||
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
_get_resource(resource_id, current_tenant_id, self.resource_model)
|
||||||
|
|
||||||
if not current_user.is_admin_or_owner:
|
if not current_user.is_admin_or_owner:
|
||||||
@ -151,8 +167,6 @@ class BaseApiKeyResource(Resource):
|
|||||||
db.session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
|
db.session.execute(delete(ApiToken).where(ApiToken.id == api_key_id))
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {"result": "success"}, 204
|
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:resource_id>/api-keys")
|
@console_ns.route("/apps/<uuid:resource_id>/api-keys")
|
||||||
class AppApiKeyListResource(BaseApiKeyListResource):
|
class AppApiKeyListResource(BaseApiKeyListResource):
|
||||||
@ -160,18 +174,21 @@ class AppApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc(description="Get all API keys for an app")
|
@console_ns.doc(description="Get all API keys for an app")
|
||||||
@console_ns.doc(params={"resource_id": "App ID"})
|
@console_ns.doc(params={"resource_id": "App ID"})
|
||||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||||
def get(self, resource_id): # type: ignore
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||||
"""Get all API keys for an app"""
|
"""Get all API keys for an app"""
|
||||||
return super().get(resource_id)
|
return dump_response(ApiKeyList, self._get_api_key_list(str(resource_id), current_tenant_id))
|
||||||
|
|
||||||
@console_ns.doc("create_app_api_key")
|
@console_ns.doc("create_app_api_key")
|
||||||
@console_ns.doc(description="Create a new API key for an app")
|
@console_ns.doc(description="Create a new API key for an app")
|
||||||
@console_ns.doc(params={"resource_id": "App ID"})
|
@console_ns.doc(params={"resource_id": "App ID"})
|
||||||
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||||
@console_ns.response(400, "Maximum keys exceeded")
|
@console_ns.response(400, "Maximum keys exceeded")
|
||||||
def post(self, resource_id): # type: ignore
|
@with_current_tenant_id
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||||
"""Create a new API key for an app"""
|
"""Create a new API key for an app"""
|
||||||
return super().post(resource_id)
|
return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201
|
||||||
|
|
||||||
resource_type = ApiTokenType.APP
|
resource_type = ApiTokenType.APP
|
||||||
resource_model = App
|
resource_model = App
|
||||||
@ -185,9 +202,14 @@ class AppApiKeyResource(BaseApiKeyResource):
|
|||||||
@console_ns.doc(description="Delete an API key for an app")
|
@console_ns.doc(description="Delete an API key for an app")
|
||||||
@console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"})
|
@console_ns.doc(params={"resource_id": "App ID", "api_key_id": "API key ID"})
|
||||||
@console_ns.response(204, "API key deleted successfully")
|
@console_ns.response(204, "API key deleted successfully")
|
||||||
def delete(self, resource_id, api_key_id):
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def delete(
|
||||||
|
self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID
|
||||||
|
) -> tuple[str, int]:
|
||||||
"""Delete an API key for an app"""
|
"""Delete an API key for an app"""
|
||||||
return super().delete(resource_id, api_key_id)
|
self._delete_api_key(str(resource_id), str(api_key_id), current_tenant_id, current_user)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
resource_type = ApiTokenType.APP
|
resource_type = ApiTokenType.APP
|
||||||
resource_model = App
|
resource_model = App
|
||||||
@ -200,18 +222,21 @@ class DatasetApiKeyListResource(BaseApiKeyListResource):
|
|||||||
@console_ns.doc(description="Get all API keys for a dataset")
|
@console_ns.doc(description="Get all API keys for a dataset")
|
||||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||||
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
@console_ns.response(200, "API keys retrieved successfully", console_ns.models[ApiKeyList.__name__])
|
||||||
def get(self, resource_id): # type: ignore
|
@with_current_tenant_id
|
||||||
|
def get(self, current_tenant_id: str, resource_id: UUID) -> dict[str, object]:
|
||||||
"""Get all API keys for a dataset"""
|
"""Get all API keys for a dataset"""
|
||||||
return super().get(resource_id)
|
return dump_response(ApiKeyList, self._get_api_key_list(str(resource_id), current_tenant_id))
|
||||||
|
|
||||||
@console_ns.doc("create_dataset_api_key")
|
@console_ns.doc("create_dataset_api_key")
|
||||||
@console_ns.doc(description="Create a new API key for a dataset")
|
@console_ns.doc(description="Create a new API key for a dataset")
|
||||||
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
@console_ns.doc(params={"resource_id": "Dataset ID"})
|
||||||
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
@console_ns.response(201, "API key created successfully", console_ns.models[ApiKeyItem.__name__])
|
||||||
@console_ns.response(400, "Maximum keys exceeded")
|
@console_ns.response(400, "Maximum keys exceeded")
|
||||||
def post(self, resource_id): # type: ignore
|
@with_current_tenant_id
|
||||||
|
@edit_permission_required
|
||||||
|
def post(self, current_tenant_id: str, resource_id: UUID) -> tuple[dict[str, object], int]:
|
||||||
"""Create a new API key for a dataset"""
|
"""Create a new API key for a dataset"""
|
||||||
return super().post(resource_id)
|
return dump_response(ApiKeyItem, self._create_api_key(str(resource_id), current_tenant_id)), 201
|
||||||
|
|
||||||
resource_type = ApiTokenType.DATASET
|
resource_type = ApiTokenType.DATASET
|
||||||
resource_model = Dataset
|
resource_model = Dataset
|
||||||
@ -225,9 +250,14 @@ class DatasetApiKeyResource(BaseApiKeyResource):
|
|||||||
@console_ns.doc(description="Delete an API key for a dataset")
|
@console_ns.doc(description="Delete an API key for a dataset")
|
||||||
@console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"})
|
@console_ns.doc(params={"resource_id": "Dataset ID", "api_key_id": "API key ID"})
|
||||||
@console_ns.response(204, "API key deleted successfully")
|
@console_ns.response(204, "API key deleted successfully")
|
||||||
def delete(self, resource_id, api_key_id):
|
@with_current_user
|
||||||
|
@with_current_tenant_id
|
||||||
|
def delete(
|
||||||
|
self, current_tenant_id: str, current_user: Account, resource_id: UUID, api_key_id: UUID
|
||||||
|
) -> tuple[str, int]:
|
||||||
"""Delete an API key for a dataset"""
|
"""Delete an API key for a dataset"""
|
||||||
return super().delete(resource_id, api_key_id)
|
self._delete_api_key(str(resource_id), str(api_key_id), current_tenant_id, current_user)
|
||||||
|
return "", 204
|
||||||
|
|
||||||
resource_type = ApiTokenType.DATASET
|
resource_type = ApiTokenType.DATASET
|
||||||
resource_model = Dataset
|
resource_model = Dataset
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from controllers.console.app.wraps import get_app_model
|
|||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from libs.helper import uuid_value
|
from libs.helper import uuid_value
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models.model import AppMode
|
from models.model import App, AppMode
|
||||||
from services.agent_service import AgentService
|
from services.agent_service import AgentService
|
||||||
|
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ class AgentLogApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
"""Get agent logs"""
|
"""Get agent logs"""
|
||||||
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
|
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
|
|||||||
@ -159,13 +159,15 @@ class AppAnnotationSettingUpdateApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_id: UUID, annotation_setting_id):
|
def post(self, app_id: UUID, annotation_setting_id: UUID):
|
||||||
annotation_setting_id = str(annotation_setting_id)
|
annotation_setting_id_str = str(annotation_setting_id)
|
||||||
|
|
||||||
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
|
args = AnnotationSettingUpdatePayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
|
setting_args: UpdateAnnotationSettingArgs = {"score_threshold": args.score_threshold}
|
||||||
result = AppAnnotationService.update_app_annotation_setting(str(app_id), annotation_setting_id, setting_args)
|
result = AppAnnotationService.update_app_annotation_setting(
|
||||||
|
str(app_id), annotation_setting_id_str, setting_args
|
||||||
|
)
|
||||||
return result, 200
|
return result, 200
|
||||||
|
|
||||||
|
|
||||||
@ -181,9 +183,9 @@ class AnnotationReplyActionStatusApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@cloud_edition_billing_resource_check("annotation")
|
@cloud_edition_billing_resource_check("annotation")
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_id: UUID, job_id, action):
|
def get(self, app_id: UUID, job_id: UUID, action: str):
|
||||||
job_id = str(job_id)
|
job_id_str = str(job_id)
|
||||||
app_annotation_job_key = f"{action}_app_annotation_job_{str(job_id)}"
|
app_annotation_job_key = f"{action}_app_annotation_job_{job_id_str}"
|
||||||
cache_result = redis_client.get(app_annotation_job_key)
|
cache_result = redis_client.get(app_annotation_job_key)
|
||||||
if cache_result is None:
|
if cache_result is None:
|
||||||
raise ValueError("The job does not exist.")
|
raise ValueError("The job does not exist.")
|
||||||
@ -191,10 +193,10 @@ class AnnotationReplyActionStatusApi(Resource):
|
|||||||
job_status = cache_result.decode()
|
job_status = cache_result.decode()
|
||||||
error_msg = ""
|
error_msg = ""
|
||||||
if job_status == "error":
|
if job_status == "error":
|
||||||
app_annotation_error_key = f"{action}_app_annotation_error_{str(job_id)}"
|
app_annotation_error_key = f"{action}_app_annotation_error_{job_id_str}"
|
||||||
error_msg = redis_client.get(app_annotation_error_key).decode()
|
error_msg = redis_client.get(app_annotation_error_key).decode()
|
||||||
|
|
||||||
return {"job_id": job_id, "job_status": job_status, "error_msg": error_msg}, 200
|
return {"job_id": job_id_str, "job_status": job_status, "error_msg": error_msg}, 200
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/annotations")
|
@console_ns.route("/apps/<uuid:app_id>/annotations")
|
||||||
@ -269,12 +271,12 @@ class AnnotationApi(Resource):
|
|||||||
"message": "annotation_ids are required if the parameter is provided.",
|
"message": "annotation_ids are required if the parameter is provided.",
|
||||||
}, 400
|
}, 400
|
||||||
|
|
||||||
result = AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids)
|
AppAnnotationService.delete_app_annotations_in_batch(str(app_id), annotation_ids)
|
||||||
return result, 204
|
return "", 204
|
||||||
# If no annotation_ids are provided, handle clearing all annotations
|
# If no annotation_ids are provided, handle clearing all annotations
|
||||||
else:
|
else:
|
||||||
AppAnnotationService.clear_all_annotations(str(app_id))
|
AppAnnotationService.clear_all_annotations(str(app_id))
|
||||||
return {"result": "success"}, 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/annotations/export")
|
@console_ns.route("/apps/<uuid:app_id>/annotations/export")
|
||||||
@ -335,7 +337,7 @@ class AnnotationUpdateDeleteApi(Resource):
|
|||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def delete(self, app_id: UUID, annotation_id: UUID):
|
def delete(self, app_id: UUID, annotation_id: UUID):
|
||||||
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
|
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
|
||||||
return {"result": "success"}, 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import")
|
@console_ns.route("/apps/<uuid:app_id>/annotations/batch-import")
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import re
|
|||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Literal
|
from typing import Any, Literal
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
@ -13,10 +12,11 @@ from sqlalchemy.orm import Session
|
|||||||
from werkzeug.datastructures import MultiDict
|
from werkzeug.datastructures import MultiDict
|
||||||
from werkzeug.exceptions import BadRequest
|
from werkzeug.exceptions import BadRequest
|
||||||
|
|
||||||
|
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
|
||||||
from controllers.common.helpers import FileInfo
|
from controllers.common.helpers import FileInfo
|
||||||
from controllers.common.schema import register_enum_models, register_schema_models
|
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.wraps import get_app_model
|
from controllers.console.app.wraps import get_app_model, with_session
|
||||||
from controllers.console.workspace.models import LoadBalancingPayload
|
from controllers.console.workspace.models import LoadBalancingPayload
|
||||||
from controllers.console.wraps import (
|
from controllers.console.wraps import (
|
||||||
account_initialization_required,
|
account_initialization_required,
|
||||||
@ -26,7 +26,6 @@ from controllers.console.wraps import (
|
|||||||
is_admin_or_owner_required,
|
is_admin_or_owner_required,
|
||||||
setup_required,
|
setup_required,
|
||||||
)
|
)
|
||||||
from core.db.session_factory import session_factory
|
|
||||||
from core.ops.ops_trace_manager import OpsTraceManager
|
from core.ops.ops_trace_manager import OpsTraceManager
|
||||||
from core.rag.entities import PreProcessingRule, Rule, Segmentation
|
from core.rag.entities import PreProcessingRule, Rule, Segmentation
|
||||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||||
@ -34,12 +33,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
|
|||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.base import ResponseModel
|
from fields.base import ResponseModel
|
||||||
from graphon.enums import WorkflowExecutionStatus
|
from graphon.enums import WorkflowExecutionStatus
|
||||||
from libs.helper import build_icon_url
|
from libs.helper import build_icon_url, to_timestamp
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import App, DatasetPermissionEnum, Workflow
|
from models import App, DatasetPermissionEnum, Workflow
|
||||||
from models.model import IconType
|
from models.model import IconType
|
||||||
from services.app_dsl_service import AppDslService
|
from services.app_dsl_service import AppDslService
|
||||||
from services.app_service import AppService
|
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||||
from services.enterprise.enterprise_service import EnterpriseService
|
from services.enterprise.enterprise_service import EnterpriseService
|
||||||
from services.entities.dsl_entities import ImportMode, ImportStatus
|
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||||
from services.entities.knowledge_entities.knowledge_entities import (
|
from services.entities.knowledge_entities.knowledge_entities import (
|
||||||
@ -178,12 +177,6 @@ class AppTracePayload(BaseModel):
|
|||||||
type JSONValue = Any
|
type JSONValue = Any
|
||||||
|
|
||||||
|
|
||||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return int(value.timestamp())
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class Tag(ResponseModel):
|
class Tag(ResponseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@ -200,7 +193,7 @@ class WorkflowPartial(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class ModelConfigPartial(ResponseModel):
|
class ModelConfigPartial(ResponseModel):
|
||||||
@ -214,7 +207,7 @@ class ModelConfigPartial(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class ModelConfig(ResponseModel):
|
class ModelConfig(ResponseModel):
|
||||||
@ -275,7 +268,7 @@ class ModelConfig(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class Site(ResponseModel):
|
class Site(ResponseModel):
|
||||||
@ -318,7 +311,7 @@ class Site(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class DeletedTool(ResponseModel):
|
class DeletedTool(ResponseModel):
|
||||||
@ -361,7 +354,7 @@ class AppPartial(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class AppDetail(ResponseModel):
|
class AppDetail(ResponseModel):
|
||||||
@ -391,7 +384,7 @@ class AppDetail(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class AppDetailWithSite(AppDetail):
|
class AppDetailWithSite(AppDetail):
|
||||||
@ -420,6 +413,7 @@ class AppExportResponse(ResponseModel):
|
|||||||
|
|
||||||
|
|
||||||
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
|
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
|
||||||
|
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
|
||||||
|
|
||||||
register_schema_models(
|
register_schema_models(
|
||||||
console_ns,
|
console_ns,
|
||||||
@ -478,11 +472,18 @@ class AppListApi(Resource):
|
|||||||
current_user, current_tenant_id = current_account_with_tenant()
|
current_user, current_tenant_id = current_account_with_tenant()
|
||||||
|
|
||||||
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
|
args = AppListQuery.model_validate(_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
|
# get app list
|
||||||
app_service = AppService()
|
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:
|
if not app_pagination:
|
||||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||||
return empty.model_dump(mode="json"), 200
|
return empty.model_dump(mode="json"), 200
|
||||||
@ -546,9 +547,17 @@ class AppListApi(Resource):
|
|||||||
"""Create app"""
|
"""Create app"""
|
||||||
current_user, current_tenant_id = current_account_with_tenant()
|
current_user, current_tenant_id = current_account_with_tenant()
|
||||||
args = CreateAppPayload.model_validate(console_ns.payload)
|
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_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)
|
app_detail = AppDetail.model_validate(app, from_attributes=True)
|
||||||
return app_detail.model_dump(mode="json"), 201
|
return app_detail.model_dump(mode="json"), 201
|
||||||
|
|
||||||
@ -564,7 +573,7 @@ class AppApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@enterprise_license_required
|
@enterprise_license_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
"""Get app detail"""
|
"""Get app detail"""
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
|
|
||||||
@ -572,7 +581,7 @@ class AppApi(Resource):
|
|||||||
|
|
||||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||||
app_model.access_mode = app_setting.access_mode
|
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
|
||||||
|
|
||||||
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
|
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
|
||||||
return response_model.model_dump(mode="json")
|
return response_model.model_dump(mode="json")
|
||||||
@ -589,7 +598,7 @@ class AppApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def put(self, app_model):
|
def put(self, app_model: App):
|
||||||
"""Update app"""
|
"""Update app"""
|
||||||
args = UpdateAppPayload.model_validate(console_ns.payload)
|
args = UpdateAppPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
@ -618,12 +627,12 @@ class AppApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def delete(self, app_model):
|
def delete(self, app_model: App):
|
||||||
"""Delete app"""
|
"""Delete app"""
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
app_service.delete_app(app_model)
|
app_service.delete_app(app_model)
|
||||||
|
|
||||||
return {"result": "success"}, 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/copy")
|
@console_ns.route("/apps/<uuid:app_id>/copy")
|
||||||
@ -639,7 +648,7 @@ class AppCopyApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
"""Copy app"""
|
"""Copy app"""
|
||||||
# The role of the current user in the ta table must be admin, owner, or editor
|
# The role of the current user in the ta table must be admin, owner, or editor
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
@ -700,7 +709,7 @@ class AppExportApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
"""Export app"""
|
"""Export app"""
|
||||||
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
|
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
@ -716,12 +725,13 @@ class AppExportApi(Resource):
|
|||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
|
@console_ns.route("/apps/<uuid:app_id>/publish-to-creators-platform")
|
||||||
class AppPublishToCreatorsPlatformApi(Resource):
|
class AppPublishToCreatorsPlatformApi(Resource):
|
||||||
|
@console_ns.response(200, "Success", console_ns.models[RedirectUrlResponse.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
"""Publish app to Creators Platform"""
|
"""Publish app to Creators Platform"""
|
||||||
from configs import dify_config
|
from configs import dify_config
|
||||||
from core.helper.creators import get_redirect_url, upload_dsl
|
from core.helper.creators import get_redirect_url, upload_dsl
|
||||||
@ -752,7 +762,7 @@ class AppNameApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args = AppNamePayload.model_validate(console_ns.payload)
|
args = AppNamePayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
@ -774,7 +784,7 @@ class AppIconApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
@ -801,7 +811,7 @@ class AppSiteStatus(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args = AppSiteStatusPayload.model_validate(console_ns.payload)
|
args = AppSiteStatusPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
@ -823,7 +833,7 @@ class AppApiStatus(Resource):
|
|||||||
@is_admin_or_owner_required
|
@is_admin_or_owner_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=None)
|
@get_app_model(mode=None)
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args = AppApiStatusPayload.model_validate(console_ns.payload)
|
args = AppApiStatusPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
app_service = AppService()
|
app_service = AppService()
|
||||||
@ -841,10 +851,11 @@ class AppTraceApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_id: UUID):
|
@with_session
|
||||||
|
@get_app_model
|
||||||
|
def get(self, session: Session, app_model: App):
|
||||||
"""Get app trace"""
|
"""Get app trace"""
|
||||||
with session_factory.create_session() as session:
|
app_trace_config = OpsTraceManager.get_app_tracing_config(app_model.id, session)
|
||||||
app_trace_config = OpsTraceManager.get_app_tracing_config(str(app_id), session)
|
|
||||||
|
|
||||||
return app_trace_config
|
return app_trace_config
|
||||||
|
|
||||||
@ -852,18 +863,23 @@ class AppTraceApi(Resource):
|
|||||||
@console_ns.doc(description="Update app tracing configuration")
|
@console_ns.doc(description="Update app tracing configuration")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[AppTracePayload.__name__])
|
@console_ns.expect(console_ns.models[AppTracePayload.__name__])
|
||||||
@console_ns.response(200, "Trace configuration updated successfully")
|
@console_ns.response(
|
||||||
|
200,
|
||||||
|
"Trace configuration updated successfully",
|
||||||
|
console_ns.models[SimpleResultResponse.__name__],
|
||||||
|
)
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_id: UUID):
|
@get_app_model
|
||||||
|
def post(self, app_model: App):
|
||||||
# add app trace
|
# add app trace
|
||||||
args = AppTracePayload.model_validate(console_ns.payload)
|
args = AppTracePayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
OpsTraceManager.update_app_tracing_config(
|
OpsTraceManager.update_app_tracing_config(
|
||||||
app_id=str(app_id),
|
app_id=app_model.id,
|
||||||
enabled=args.enabled,
|
enabled=args.enabled,
|
||||||
tracing_provider=args.tracing_provider,
|
tracing_provider=args.tracing_provider,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -97,7 +97,7 @@ class AppImportConfirmApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, import_id):
|
def post(self, import_id: str):
|
||||||
# Check user role first
|
# Check user role first
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
|
|
||||||
|
|||||||
@ -70,7 +70,7 @@ class ChatMessageAudioApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
file = request.files["file"]
|
file = request.files["file"]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@ -171,7 +171,7 @@ class TextModesApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
try:
|
try:
|
||||||
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
|
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
|
|||||||
@ -7,7 +7,8 @@ from pydantic import BaseModel, Field, field_validator
|
|||||||
from werkzeug.exceptions import InternalServerError, NotFound
|
from werkzeug.exceptions import InternalServerError, NotFound
|
||||||
|
|
||||||
import services
|
import services
|
||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.fields import SimpleResultResponse
|
||||||
|
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import (
|
from controllers.console.app.error import (
|
||||||
AppUnavailableError,
|
AppUnavailableError,
|
||||||
@ -32,7 +33,7 @@ from libs import helper
|
|||||||
from libs.helper import uuid_value
|
from libs.helper import uuid_value
|
||||||
from libs.login import current_user, login_required
|
from libs.login import current_user, login_required
|
||||||
from models import Account
|
from models import Account
|
||||||
from models.model import AppMode
|
from models.model import App, AppMode
|
||||||
from services.app_generate_service import AppGenerateService
|
from services.app_generate_service import AppGenerateService
|
||||||
from services.app_task_service import AppTaskService
|
from services.app_task_service import AppTaskService
|
||||||
from services.errors.llm import InvokeRateLimitError
|
from services.errors.llm import InvokeRateLimitError
|
||||||
@ -66,6 +67,7 @@ class ChatMessagePayload(BaseMessagePayload):
|
|||||||
|
|
||||||
|
|
||||||
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
|
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
|
||||||
|
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||||
|
|
||||||
|
|
||||||
# define completion message api for user
|
# define completion message api for user
|
||||||
@ -82,7 +84,7 @@ class CompletionMessageApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
|
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
|
||||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||||
|
|
||||||
@ -124,12 +126,12 @@ class CompletionMessageStopApi(Resource):
|
|||||||
@console_ns.doc("stop_completion_message")
|
@console_ns.doc("stop_completion_message")
|
||||||
@console_ns.doc(description="Stop a running completion message generation")
|
@console_ns.doc(description="Stop a running completion message generation")
|
||||||
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
|
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
|
||||||
@console_ns.response(200, "Task stopped successfully")
|
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
def post(self, app_model, task_id):
|
def post(self, app_model: App, task_id: str):
|
||||||
if not isinstance(current_user, Account):
|
if not isinstance(current_user, Account):
|
||||||
raise ValueError("current_user must be an Account instance")
|
raise ValueError("current_user must be an Account instance")
|
||||||
|
|
||||||
@ -157,7 +159,7 @@ class ChatMessageApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args_model = ChatMessagePayload.model_validate(console_ns.payload)
|
args_model = ChatMessagePayload.model_validate(console_ns.payload)
|
||||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||||
|
|
||||||
@ -205,12 +207,12 @@ class ChatMessageStopApi(Resource):
|
|||||||
@console_ns.doc("stop_chat_message")
|
@console_ns.doc("stop_chat_message")
|
||||||
@console_ns.doc(description="Stop a running chat message generation")
|
@console_ns.doc(description="Stop a running chat message generation")
|
||||||
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
|
@console_ns.doc(params={"app_id": "Application ID", "task_id": "Task ID to stop"})
|
||||||
@console_ns.response(200, "Task stopped successfully")
|
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
def post(self, app_model, task_id):
|
def post(self, app_model: App, task_id: str):
|
||||||
if not isinstance(current_user, Account):
|
if not isinstance(current_user, Account):
|
||||||
raise ValueError("current_user must be an Account instance")
|
raise ValueError("current_user must be an Account instance")
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from flask import abort, request
|
from flask import abort, request
|
||||||
@ -29,13 +30,10 @@ from fields.conversation_fields import (
|
|||||||
from fields.conversation_fields import (
|
from fields.conversation_fields import (
|
||||||
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
|
ConversationWithSummaryPagination as ConversationWithSummaryPaginationResponse,
|
||||||
)
|
)
|
||||||
from fields.conversation_fields import (
|
|
||||||
ResultResponse,
|
|
||||||
)
|
|
||||||
from libs.datetime_utils import naive_utc_now, parse_time_range
|
from libs.datetime_utils import naive_utc_now, parse_time_range
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import Conversation, EndUser, Message, MessageAnnotation
|
from models import Conversation, EndUser, Message, MessageAnnotation
|
||||||
from models.model import AppMode
|
from models.model import App, AppMode
|
||||||
from services.conversation_service import ConversationService
|
from services.conversation_service import ConversationService
|
||||||
from services.errors.conversation import ConversationNotExistsError
|
from services.errors.conversation import ConversationNotExistsError
|
||||||
|
|
||||||
@ -77,7 +75,6 @@ register_schema_models(
|
|||||||
ConversationMessageDetailResponse,
|
ConversationMessageDetailResponse,
|
||||||
ConversationWithSummaryPaginationResponse,
|
ConversationWithSummaryPaginationResponse,
|
||||||
ConversationDetailResponse,
|
ConversationDetailResponse,
|
||||||
ResultResponse,
|
|
||||||
CompletionConversationQuery,
|
CompletionConversationQuery,
|
||||||
ChatConversationQuery,
|
ChatConversationQuery,
|
||||||
)
|
)
|
||||||
@ -96,7 +93,7 @@ class CompletionConversationApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
|
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
@ -137,7 +134,7 @@ class CompletionConversationApi(Resource):
|
|||||||
.join( # type: ignore
|
.join( # type: ignore
|
||||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||||
)
|
)
|
||||||
.distinct()
|
.group_by(Conversation.id)
|
||||||
)
|
)
|
||||||
elif args.annotation_status == "not_annotated":
|
elif args.annotation_status == "not_annotated":
|
||||||
query = (
|
query = (
|
||||||
@ -168,10 +165,10 @@ class CompletionConversationDetailApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model, conversation_id):
|
def get(self, app_model: App, conversation_id: UUID):
|
||||||
conversation_id = str(conversation_id)
|
conversation_id_str = str(conversation_id)
|
||||||
return ConversationMessageDetailResponse.model_validate(
|
return ConversationMessageDetailResponse.model_validate(
|
||||||
_get_conversation(app_model, conversation_id), from_attributes=True
|
_get_conversation(app_model, conversation_id_str), from_attributes=True
|
||||||
).model_dump(mode="json")
|
).model_dump(mode="json")
|
||||||
|
|
||||||
@console_ns.doc("delete_completion_conversation")
|
@console_ns.doc("delete_completion_conversation")
|
||||||
@ -185,16 +182,16 @@ class CompletionConversationDetailApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.COMPLETION)
|
@get_app_model(mode=AppMode.COMPLETION)
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def delete(self, app_model, conversation_id):
|
def delete(self, app_model: App, conversation_id: UUID):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
conversation_id = str(conversation_id)
|
conversation_id_str = str(conversation_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ConversationService.delete(app_model, conversation_id, current_user)
|
ConversationService.delete(app_model, conversation_id_str, current_user)
|
||||||
except ConversationNotExistsError:
|
except ConversationNotExistsError:
|
||||||
raise NotFound("Conversation Not Exists.")
|
raise NotFound("Conversation Not Exists.")
|
||||||
|
|
||||||
return ResultResponse(result="success").model_dump(mode="json"), 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
|
@console_ns.route("/apps/<uuid:app_id>/chat-conversations")
|
||||||
@ -210,7 +207,7 @@ class ChatConversationApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
|
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
@ -275,7 +272,7 @@ class ChatConversationApi(Resource):
|
|||||||
.join( # type: ignore
|
.join( # type: ignore
|
||||||
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
MessageAnnotation, MessageAnnotation.conversation_id == Conversation.id
|
||||||
)
|
)
|
||||||
.distinct()
|
.group_by(Conversation.id)
|
||||||
)
|
)
|
||||||
case "not_annotated":
|
case "not_annotated":
|
||||||
query = (
|
query = (
|
||||||
@ -321,10 +318,10 @@ class ChatConversationDetailApi(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model, conversation_id):
|
def get(self, app_model: App, conversation_id: UUID):
|
||||||
conversation_id = str(conversation_id)
|
conversation_id_str = str(conversation_id)
|
||||||
return ConversationDetailResponse.model_validate(
|
return ConversationDetailResponse.model_validate(
|
||||||
_get_conversation(app_model, conversation_id), from_attributes=True
|
_get_conversation(app_model, conversation_id_str), from_attributes=True
|
||||||
).model_dump(mode="json")
|
).model_dump(mode="json")
|
||||||
|
|
||||||
@console_ns.doc("delete_chat_conversation")
|
@console_ns.doc("delete_chat_conversation")
|
||||||
@ -338,16 +335,16 @@ class ChatConversationDetailApi(Resource):
|
|||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def delete(self, app_model, conversation_id):
|
def delete(self, app_model: App, conversation_id: UUID):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
conversation_id = str(conversation_id)
|
conversation_id_str = str(conversation_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ConversationService.delete(app_model, conversation_id, current_user)
|
ConversationService.delete(app_model, conversation_id_str, current_user)
|
||||||
except ConversationNotExistsError:
|
except ConversationNotExistsError:
|
||||||
raise NotFound("Conversation Not Exists.")
|
raise NotFound("Conversation Not Exists.")
|
||||||
|
|
||||||
return ResultResponse(result="success").model_dump(mode="json"), 204
|
return "", 204
|
||||||
|
|
||||||
|
|
||||||
def _get_conversation(app_model, conversation_id):
|
def _get_conversation(app_model, conversation_id):
|
||||||
|
|||||||
@ -16,21 +16,16 @@ from controllers.console.wraps import account_initialization_required, setup_req
|
|||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields._value_type_serializer import serialize_value_type
|
from fields._value_type_serializer import serialize_value_type
|
||||||
from fields.base import ResponseModel
|
from fields.base import ResponseModel
|
||||||
|
from libs.helper import to_timestamp
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
from models import ConversationVariable
|
from models import ConversationVariable
|
||||||
from models.model import AppMode
|
from models.model import App, AppMode
|
||||||
|
|
||||||
|
|
||||||
class ConversationVariablesQuery(BaseModel):
|
class ConversationVariablesQuery(BaseModel):
|
||||||
conversation_id: str = Field(..., description="Conversation ID to filter variables")
|
conversation_id: str = Field(..., description="Conversation ID to filter variables")
|
||||||
|
|
||||||
|
|
||||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return int(value.timestamp())
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class ConversationVariableResponse(ResponseModel):
|
class ConversationVariableResponse(ResponseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@ -65,7 +60,7 @@ class ConversationVariableResponse(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
class PaginatedConversationVariableResponse(ResponseModel):
|
class PaginatedConversationVariableResponse(ResponseModel):
|
||||||
@ -99,7 +94,7 @@ class ConversationVariablesApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
|
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
stmt = (
|
stmt = (
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator
|
||||||
@ -13,9 +14,10 @@ from controllers.console.app.wraps import get_app_model
|
|||||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from fields.base import ResponseModel
|
from fields.base import ResponseModel
|
||||||
|
from libs.helper import to_timestamp
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.enums import AppMCPServerStatus
|
from models.enums import AppMCPServerStatus
|
||||||
from models.model import AppMCPServer
|
from models.model import App, AppMCPServer
|
||||||
|
|
||||||
|
|
||||||
class MCPServerCreatePayload(BaseModel):
|
class MCPServerCreatePayload(BaseModel):
|
||||||
@ -30,12 +32,6 @@ class MCPServerUpdatePayload(BaseModel):
|
|||||||
status: str | None = Field(default=None, description="Server status")
|
status: str | None = Field(default=None, description="Server status")
|
||||||
|
|
||||||
|
|
||||||
def _to_timestamp(value: datetime | int | None) -> int | None:
|
|
||||||
if isinstance(value, datetime):
|
|
||||||
return int(value.timestamp())
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class AppMCPServerResponse(ResponseModel):
|
class AppMCPServerResponse(ResponseModel):
|
||||||
id: str
|
id: str
|
||||||
name: str
|
name: str
|
||||||
@ -59,7 +55,7 @@ class AppMCPServerResponse(ResponseModel):
|
|||||||
@field_validator("created_at", "updated_at", mode="before")
|
@field_validator("created_at", "updated_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
|
||||||
return _to_timestamp(value)
|
return to_timestamp(value)
|
||||||
|
|
||||||
|
|
||||||
register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse)
|
register_schema_models(console_ns, MCPServerCreatePayload, MCPServerUpdatePayload, AppMCPServerResponse)
|
||||||
@ -77,7 +73,7 @@ class AppMCPServerController(Resource):
|
|||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||||
if server is None:
|
if server is None:
|
||||||
return {}
|
return {}
|
||||||
@ -96,7 +92,7 @@ class AppMCPServerController(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@setup_required
|
@setup_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
|
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
|
||||||
|
|
||||||
@ -131,7 +127,7 @@ class AppMCPServerController(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def put(self, app_model):
|
def put(self, app_model: App):
|
||||||
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
server = db.session.get(AppMCPServer, payload.id)
|
server = db.session.get(AppMCPServer, payload.id)
|
||||||
if not server:
|
if not server:
|
||||||
@ -167,7 +163,7 @@ class AppMCPServerRefreshController(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, server_id):
|
def get(self, server_id: UUID):
|
||||||
_, current_tenant_id = current_account_with_tenant()
|
_, current_tenant_id = current_account_with_tenant()
|
||||||
server = db.session.scalar(
|
server = db.session.scalar(
|
||||||
select(AppMCPServer)
|
select(AppMCPServer)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource
|
from flask_restx import Resource
|
||||||
@ -9,7 +10,8 @@ from sqlalchemy import exists, func, select
|
|||||||
from werkzeug.exceptions import InternalServerError, NotFound
|
from werkzeug.exceptions import InternalServerError, NotFound
|
||||||
|
|
||||||
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.fields import SimpleResultResponse
|
||||||
|
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import (
|
from controllers.console.app.error import (
|
||||||
CompletionRequestError,
|
CompletionRequestError,
|
||||||
@ -37,14 +39,13 @@ from fields.conversation_fields import (
|
|||||||
JSONValue,
|
JSONValue,
|
||||||
MessageFile,
|
MessageFile,
|
||||||
format_files_contained,
|
format_files_contained,
|
||||||
to_timestamp,
|
|
||||||
)
|
)
|
||||||
from graphon.model_runtime.errors.invoke import InvokeError
|
from graphon.model_runtime.errors.invoke import InvokeError
|
||||||
from libs.helper import uuid_value
|
from libs.helper import to_timestamp, uuid_value
|
||||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.enums import FeedbackFromSource, FeedbackRating
|
from models.enums import FeedbackFromSource, FeedbackRating
|
||||||
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||||
from services.errors.conversation import ConversationNotExistsError
|
from services.errors.conversation import ConversationNotExistsError
|
||||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||||
from services.message_service import MessageService, attach_message_extra_contents
|
from services.message_service import MessageService, attach_message_extra_contents
|
||||||
@ -144,9 +145,7 @@ class MessageDetailResponse(ResponseModel):
|
|||||||
@field_validator("created_at", mode="before")
|
@field_validator("created_at", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
|
def _normalize_created_at(cls, value: datetime | int | None) -> int | None:
|
||||||
if isinstance(value, datetime):
|
return to_timestamp(value)
|
||||||
return to_timestamp(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class MessageInfiniteScrollPaginationResponse(ResponseModel):
|
class MessageInfiniteScrollPaginationResponse(ResponseModel):
|
||||||
@ -165,6 +164,7 @@ register_schema_models(
|
|||||||
MessageDetailResponse,
|
MessageDetailResponse,
|
||||||
MessageInfiniteScrollPaginationResponse,
|
MessageInfiniteScrollPaginationResponse,
|
||||||
)
|
)
|
||||||
|
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||||
|
|
||||||
|
|
||||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
|
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
|
||||||
@ -180,7 +180,7 @@ class ChatMessageListApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||||
|
|
||||||
conversation = db.session.scalar(
|
conversation = db.session.scalar(
|
||||||
@ -250,14 +250,14 @@ class MessageFeedbackApi(Resource):
|
|||||||
@console_ns.doc(description="Create or update message feedback (like/dislike)")
|
@console_ns.doc(description="Create or update message feedback (like/dislike)")
|
||||||
@console_ns.doc(params={"app_id": "Application ID"})
|
@console_ns.doc(params={"app_id": "Application ID"})
|
||||||
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
||||||
@console_ns.response(200, "Feedback updated successfully")
|
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||||
@console_ns.response(404, "Message not found")
|
@console_ns.response(404, "Message not found")
|
||||||
@console_ns.response(403, "Insufficient permissions")
|
@console_ns.response(403, "Insufficient permissions")
|
||||||
@get_app_model
|
@get_app_model
|
||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
|
|
||||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||||
@ -314,7 +314,7 @@ class MessageAnnotationCountApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
count = db.session.scalar(
|
count = db.session.scalar(
|
||||||
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
|
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
|
||||||
)
|
)
|
||||||
@ -337,13 +337,13 @@ class MessageSuggestedQuestionApi(Resource):
|
|||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||||
def get(self, app_model, message_id):
|
def get(self, app_model: App, message_id: UUID):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
message_id = str(message_id)
|
message_id_str = str(message_id)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
questions = MessageService.get_suggested_questions_after_answer(
|
questions = MessageService.get_suggested_questions_after_answer(
|
||||||
app_model=app_model, message_id=message_id, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
||||||
)
|
)
|
||||||
except MessageNotExistsError:
|
except MessageNotExistsError:
|
||||||
raise NotFound("Message not found")
|
raise NotFound("Message not found")
|
||||||
@ -379,7 +379,7 @@ class MessageFeedbackExportApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_model):
|
def get(self, app_model: App):
|
||||||
args = FeedbackExportQuery.model_validate(request.args.to_dict())
|
args = FeedbackExportQuery.model_validate(request.args.to_dict())
|
||||||
|
|
||||||
# Import the service function
|
# Import the service function
|
||||||
@ -417,11 +417,11 @@ class MessageApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_model, message_id: str):
|
def get(self, app_model: App, message_id: UUID):
|
||||||
message_id = str(message_id)
|
message_id_str = str(message_id)
|
||||||
|
|
||||||
message = db.session.scalar(
|
message = db.session.scalar(
|
||||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
|
||||||
)
|
)
|
||||||
|
|
||||||
if not message:
|
if not message:
|
||||||
|
|||||||
@ -16,7 +16,7 @@ from events.app_event import app_model_config_was_updated
|
|||||||
from extensions.ext_database import db
|
from extensions.ext_database import db
|
||||||
from libs.datetime_utils import naive_utc_now
|
from libs.datetime_utils import naive_utc_now
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models.model import AppMode, AppModelConfig
|
from models.model import App, AppMode, AppModelConfig
|
||||||
from services.app_model_config_service import AppModelConfigService
|
from services.app_model_config_service import AppModelConfigService
|
||||||
|
|
||||||
|
|
||||||
@ -52,7 +52,7 @@ class ModelConfigResource(Resource):
|
|||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
|
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
"""Modify app model config"""
|
"""Modify app model config"""
|
||||||
current_user, current_tenant_id = current_account_with_tenant()
|
current_user, current_tenant_id = current_account_with_tenant()
|
||||||
# validate config
|
# validate config
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from flask import request
|
from flask import request
|
||||||
from flask_restx import Resource, fields
|
from flask_restx import Resource, fields
|
||||||
@ -9,8 +8,10 @@ from werkzeug.exceptions import BadRequest
|
|||||||
from controllers.common.schema import register_schema_models
|
from controllers.common.schema import register_schema_models
|
||||||
from controllers.console import console_ns
|
from controllers.console import console_ns
|
||||||
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
|
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
|
||||||
|
from controllers.console.app.wraps import get_app_model
|
||||||
from controllers.console.wraps import account_initialization_required, setup_required
|
from controllers.console.wraps import account_initialization_required, setup_required
|
||||||
from libs.login import login_required
|
from libs.login import login_required
|
||||||
|
from models import App
|
||||||
from services.ops_service import OpsService
|
from services.ops_service import OpsService
|
||||||
|
|
||||||
|
|
||||||
@ -43,11 +44,14 @@ class TraceAppConfigApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def get(self, app_id: UUID):
|
@get_app_model
|
||||||
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
|
def get(self, app_model: App):
|
||||||
|
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
|
||||||
|
|
||||||
try:
|
try:
|
||||||
trace_config = OpsService.get_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
|
trace_config = OpsService.get_tracing_app_config(
|
||||||
|
app_id=app_model.id, tracing_provider=args.tracing_provider
|
||||||
|
)
|
||||||
if not trace_config:
|
if not trace_config:
|
||||||
return {"has_not_configured": True}
|
return {"has_not_configured": True}
|
||||||
return trace_config
|
return trace_config
|
||||||
@ -65,13 +69,14 @@ class TraceAppConfigApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def post(self, app_id: UUID):
|
@get_app_model
|
||||||
|
def post(self, app_model: App):
|
||||||
"""Create a new trace app configuration"""
|
"""Create a new trace app configuration"""
|
||||||
args = TraceConfigPayload.model_validate(console_ns.payload)
|
args = TraceConfigPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = OpsService.create_tracing_app_config(
|
result = OpsService.create_tracing_app_config(
|
||||||
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
|
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise TracingConfigIsExist()
|
raise TracingConfigIsExist()
|
||||||
@ -90,13 +95,14 @@ class TraceAppConfigApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def patch(self, app_id: UUID):
|
@get_app_model
|
||||||
|
def patch(self, app_model: App):
|
||||||
"""Update an existing trace app configuration"""
|
"""Update an existing trace app configuration"""
|
||||||
args = TraceConfigPayload.model_validate(console_ns.payload)
|
args = TraceConfigPayload.model_validate(console_ns.payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = OpsService.update_tracing_app_config(
|
result = OpsService.update_tracing_app_config(
|
||||||
app_id=str(app_id), tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
|
app_id=app_model.id, tracing_provider=args.tracing_provider, tracing_config=args.tracing_config
|
||||||
)
|
)
|
||||||
if not result:
|
if not result:
|
||||||
raise TracingConfigNotExist()
|
raise TracingConfigNotExist()
|
||||||
@ -113,14 +119,15 @@ class TraceAppConfigApi(Resource):
|
|||||||
@setup_required
|
@setup_required
|
||||||
@login_required
|
@login_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
def delete(self, app_id: UUID):
|
@get_app_model
|
||||||
|
def delete(self, app_model: App):
|
||||||
"""Delete an existing trace app configuration"""
|
"""Delete an existing trace app configuration"""
|
||||||
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
|
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
result = OpsService.delete_tracing_app_config(app_id=str(app_id), tracing_provider=args.tracing_provider)
|
result = OpsService.delete_tracing_app_config(app_id=app_model.id, tracing_provider=args.tracing_provider)
|
||||||
if not result:
|
if not result:
|
||||||
raise TracingConfigNotExist()
|
raise TracingConfigNotExist()
|
||||||
return {"result": "success"}, 204
|
return "", 204
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise BadRequest(str(e))
|
raise BadRequest(str(e))
|
||||||
|
|||||||
@ -20,6 +20,7 @@ from fields.base import ResponseModel
|
|||||||
from libs.datetime_utils import naive_utc_now
|
from libs.datetime_utils import naive_utc_now
|
||||||
from libs.login import current_account_with_tenant, login_required
|
from libs.login import current_account_with_tenant, login_required
|
||||||
from models import Site
|
from models import Site
|
||||||
|
from models.model import App
|
||||||
|
|
||||||
|
|
||||||
class AppSiteUpdatePayload(BaseModel):
|
class AppSiteUpdatePayload(BaseModel):
|
||||||
@ -84,7 +85,7 @@ class AppSite(Resource):
|
|||||||
@edit_permission_required
|
@edit_permission_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||||
@ -133,7 +134,7 @@ class AppSiteAccessTokenReset(Resource):
|
|||||||
@is_admin_or_owner_required
|
@is_admin_or_owner_required
|
||||||
@account_initialization_required
|
@account_initialization_required
|
||||||
@get_app_model
|
@get_app_model
|
||||||
def post(self, app_model):
|
def post(self, app_model: App):
|
||||||
current_user, _ = current_account_with_tenant()
|
current_user, _ = current_account_with_tenant()
|
||||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user