Compare commits

..

9 Commits

2068 changed files with 27013 additions and 151865 deletions

View File

@ -0,0 +1,440 @@
---
name: component-refactoring
description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
---
# Dify Component Refactoring Skill
Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below.
> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing.
## Quick Reference
### Commands (run from `web/`)
Use paths relative to `web/` (e.g., `app/components/...`).
Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics.
```bash
cd web
# Generate refactoring prompt
pnpm refactor-component <path>
# Output refactoring analysis as JSON
pnpm refactor-component <path> --json
# Generate testing prompt (after refactoring)
pnpm analyze-component <path>
# Output testing analysis as JSON
pnpm analyze-component <path> --json
```
### Complexity Analysis
```bash
# Analyze component complexity
pnpm analyze-component <path> --json
# Key metrics to check:
# - complexity: normalized score 0-100 (target < 50)
# - maxComplexity: highest single function complexity
# - lineCount: total lines (target < 300)
```
### Complexity Score Interpretation
| Score | Level | Action |
|-------|-------|--------|
| 0-25 | 🟢 Simple | Ready for testing |
| 26-50 | 🟡 Medium | Consider minor refactoring |
| 51-75 | 🟠 Complex | **Refactor before testing** |
| 76-100 | 🔴 Very Complex | **Must refactor** |
## Core Refactoring Patterns
### Pattern 1: Extract Custom Hooks
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`.
```typescript
// ❌ Before: Complex state logic in component
function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// 50+ lines of state management logic...
return <div>...</div>
}
// ✅ After: Extract to custom hook
// hooks/use-model-config.ts
export const useModelConfig = (appId: string) => {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
// Related state management logic here
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
}
// Component becomes cleaner
function Configuration() {
const { modelConfig, setModelConfig } = useModelConfig(appId)
return <div>...</div>
}
```
**Dify Examples**:
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
- `web/app/components/app/configuration/debug/hooks.tsx`
- `web/app/components/workflow/hooks/use-workflow.ts`
### Pattern 2: Extract Sub-Components
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory.
```typescript
// ❌ Before: Monolithic JSX with multiple sections
const AppInfo = () => {
return (
<div>
{/* 100 lines of header UI */}
{/* 100 lines of operations UI */}
{/* 100 lines of modals */}
</div>
)
}
// ✅ After: Split into focused components
// app-info/
// ├── index.tsx (orchestration only)
// ├── app-header.tsx (header UI)
// ├── app-operations.tsx (operations UI)
// └── app-modals.tsx (modal management)
const AppInfo = () => {
const { showModal, setShowModal } = useAppInfoModals()
return (
<div>
<AppHeader appDetail={appDetail} />
<AppOperations onAction={handleAction} />
<AppModals show={showModal} onClose={() => setShowModal(null)} />
</div>
)
}
```
**Dify Examples**:
- `web/app/components/app/configuration/` directory structure
- `web/app/components/workflow/nodes/` per-node organization
### Pattern 3: Simplify Conditional Logic
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
```typescript
// ❌ Before: Deeply nested conditionals
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh />
case LanguagesSupported[7]:
return <TemplateChatJa />
default:
return <TemplateChatEn />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
// Another 15 lines...
}
// More conditions...
}, [appDetail, locale])
// ✅ After: Use lookup tables + early returns
const TEMPLATE_MAP = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
// ...
},
}
const Template = useMemo(() => {
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
if (!modeTemplates) return null
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
### Pattern 4: Extract API/Data Logic
**When**: Component directly handles API calls, data transformation, or complex async operations.
**Dify Convention**:
- This skill is for component decomposition, not query/mutation design.
- 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.
**Dify Examples**:
- `web/service/use-workflow.ts`
- `web/service/use-common.ts`
- `web/service/knowledge/use-dataset.ts`
- `web/service/knowledge/use-document.ts`
### Pattern 5: Extract Modal/Dialog Management
**When**: Component manages multiple modals with complex open/close states.
**Dify Convention**: Modals should be extracted with their state management.
```typescript
// ❌ Before: Multiple modal states in component
const AppInfo = () => {
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState(false)
const [showImportDSLModal, setShowImportDSLModal] = useState(false)
// 5+ more modal states...
}
// ✅ After: Extract to modal management hook
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
const useAppInfoModals = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
const closeModal = useCallback(() => setActiveModal(null), [])
return {
activeModal,
openModal,
closeModal,
isOpen: (type: ModalType) => activeModal === type,
}
}
```
### Pattern 6: Extract Form Logic
**When**: Complex form validation, submission handling, or field transformation.
**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`.
```typescript
// ✅ Use existing form infrastructure
import { useAppForm } from '@/app/components/base/form'
const ConfigForm = () => {
const form = useAppForm({
defaultValues: { name: '', description: '' },
onSubmit: handleSubmit,
})
return <form.Provider>...</form.Provider>
}
```
## Dify-Specific Refactoring Guidelines
### 1. Context Provider Extraction
**When**: Component provides complex context values with multiple states.
```typescript
// ❌ Before: Large context value object
const value = {
appId, isAPIKeySet, isTrailFinished, mode, modelModeType,
promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall,
// 50+ more properties...
}
return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
// ✅ After: Split into domain-specific contexts
<ModelConfigProvider value={modelConfigValue}>
<DatasetConfigProvider value={datasetConfigValue}>
<UIConfigProvider value={uiConfigValue}>
{children}
</UIConfigProvider>
</DatasetConfigProvider>
</ModelConfigProvider>
```
**Dify Reference**: `web/context/` directory structure
### 2. Workflow Node Components
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
**Conventions**:
- Keep node logic in `use-interactions.ts`
- Extract panel UI to separate files
- Use `_base` components for common patterns
```
nodes/<node-type>/
├── index.tsx # Node registration
├── node.tsx # Node visual component
├── panel.tsx # Configuration panel
├── use-interactions.ts # Node-specific hooks
└── types.ts # Type definitions
```
### 3. Configuration Components
**When**: Refactoring app configuration components.
**Conventions**:
- Separate config sections into subdirectories
- Use existing patterns from `web/app/components/app/configuration/`
- Keep feature toggles in dedicated components
### 4. Tool/Plugin Components
**When**: Refactoring tool-related components (`web/app/components/tools/`).
**Conventions**:
- Follow existing modal patterns
- Use service hooks from `web/service/use-tools.ts`
- Keep provider-specific logic isolated
## Refactoring Workflow
### Step 1: Generate Refactoring Prompt
```bash
pnpm refactor-component <path>
```
This command will:
- Analyze component complexity and features
- Identify specific refactoring actions needed
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
- Provide detailed requirements based on detected patterns
### Step 2: Analyze Details
```bash
pnpm analyze-component <path> --json
```
Identify:
- Total complexity score
- Max function complexity
- Line count
- Features detected (state, effects, API, etc.)
### Step 3: Plan
Create a refactoring plan based on detected features:
| Detected Feature | Refactoring Action |
|------------------|-------------------|
| `hasState: true` + `hasEffects: true` | Extract custom hook |
| `hasAPI: true` | Extract data/service hook |
| `hasEvents: true` (many) | Extract event handlers |
| `lineCount > 300` | Split into sub-components |
| `maxComplexity > 50` | Simplify conditional logic |
### Step 4: Execute Incrementally
1. **Extract one piece at a time**
2. **Run lint, type-check, and tests after each extraction**
3. **Verify functionality before next step**
```
For each extraction:
┌────────────────────────────────────────┐
│ 1. Extract code │
│ 2. Run: pnpm lint:fix │
│ 3. Run: pnpm type-check │
│ 4. Run: pnpm test │
│ 5. Test functionality manually │
│ 6. PASS? → Next extraction │
│ FAIL? → Fix before continuing │
└────────────────────────────────────────┘
```
### Step 5: Verify
After refactoring:
```bash
# Re-run refactor command to verify improvements
pnpm refactor-component <path>
# If complexity < 25 and lines < 200, you'll see:
# ✅ COMPONENT IS WELL-STRUCTURED
# For detailed metrics:
pnpm analyze-component <path> --json
# Target metrics:
# - complexity < 50
# - lineCount < 300
# - maxComplexity < 30
```
## Common Mistakes to Avoid
### ❌ Over-Engineering
```typescript
// ❌ Too many tiny hooks
const useButtonText = () => useState('Click')
const useButtonDisabled = () => useState(false)
const useButtonLoading = () => useState(false)
// ✅ Cohesive hook with related state
const useButtonState = () => {
const [text, setText] = useState('Click')
const [disabled, setDisabled] = useState(false)
const [loading, setLoading] = useState(false)
return { text, setText, disabled, setDisabled, loading, setLoading }
}
```
### ❌ Breaking Existing Patterns
- Follow existing directory structures
- Maintain naming conventions
- Preserve export patterns for compatibility
### ❌ Premature Abstraction
- Only extract when there's clear complexity benefit
- Don't create abstractions for single-use code
- Keep refactored code in the same domain area
## References
### Dify Codebase Examples
- **Hook extraction**: `web/app/components/app/configuration/hooks/`
- **Component splitting**: `web/app/components/app/configuration/`
- **Service hooks**: `web/service/use-*.ts`
- **Workflow patterns**: `web/app/components/workflow/hooks/`
- **Form patterns**: `web/app/components/base/form/`
### Related Skills
- `frontend-testing` - For testing refactored components
- `web/docs/test.md` - Testing specification

View File

@ -0,0 +1,495 @@
# Complexity Reduction Patterns
This document provides patterns for reducing cognitive complexity in Dify React components.
## Understanding Complexity
### SonarJS Cognitive Complexity
The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
- **Total Complexity**: Sum of all functions' complexity in the file
- **Max Complexity**: Highest single function complexity
### What Increases Complexity
| Pattern | Complexity Impact |
|---------|-------------------|
| `if/else` | +1 per branch |
| Nested conditions | +1 per nesting level |
| `switch/case` | +1 per case |
| `for/while/do` | +1 per loop |
| `&&`/`||` chains | +1 per operator |
| Nested callbacks | +1 per nesting level |
| `try/catch` | +1 per catch |
| Ternary expressions | +1 per nesting |
## Pattern 1: Replace Conditionals with Lookup Tables
**Before** (complexity: ~15):
```typescript
const Template = useMemo(() => {
if (appDetail?.mode === AppModeEnum.CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateChatJa appDetail={appDetail} />
default:
return <TemplateChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
switch (locale) {
case LanguagesSupported[1]:
return <TemplateAdvancedChatZh appDetail={appDetail} />
case LanguagesSupported[7]:
return <TemplateAdvancedChatJa appDetail={appDetail} />
default:
return <TemplateAdvancedChatEn appDetail={appDetail} />
}
}
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
// Similar pattern...
}
return null
}, [appDetail, locale])
```
**After** (complexity: ~3):
```typescript
import type { ComponentType } from 'react'
// Define lookup table outside component
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
[AppModeEnum.CHAT]: {
[LanguagesSupported[1]]: TemplateChatZh,
[LanguagesSupported[7]]: TemplateChatJa,
default: TemplateChatEn,
},
[AppModeEnum.ADVANCED_CHAT]: {
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
[LanguagesSupported[7]]: TemplateAdvancedChatJa,
default: TemplateAdvancedChatEn,
},
[AppModeEnum.WORKFLOW]: {
[LanguagesSupported[1]]: TemplateWorkflowZh,
[LanguagesSupported[7]]: TemplateWorkflowJa,
default: TemplateWorkflowEn,
},
// ...
}
// Clean component logic
const Template = useMemo(() => {
if (!appDetail?.mode) return null
const templates = TEMPLATE_MAP[appDetail.mode]
if (!templates) return null
const TemplateComponent = templates[locale] ?? templates.default
return <TemplateComponent appDetail={appDetail} />
}, [appDetail, locale])
```
## Pattern 2: Use Early Returns
**Before** (complexity: ~10):
```typescript
const handleSubmit = () => {
if (isValid) {
if (hasChanges) {
if (isConnected) {
submitData()
} else {
showConnectionError()
}
} else {
showNoChangesMessage()
}
} else {
showValidationError()
}
}
```
**After** (complexity: ~4):
```typescript
const handleSubmit = () => {
if (!isValid) {
showValidationError()
return
}
if (!hasChanges) {
showNoChangesMessage()
return
}
if (!isConnected) {
showConnectionError()
return
}
submitData()
}
```
## Pattern 3: Extract Complex Conditions
**Before** (complexity: high):
```typescript
const canPublish = (() => {
if (mode !== AppModeEnum.COMPLETION) {
if (!isAdvancedMode)
return true
if (modelModeType === ModelModeType.completion) {
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
return false
return true
}
return true
}
return !promptEmpty
})()
```
**After** (complexity: lower):
```typescript
// Extract to named functions
const canPublishInCompletionMode = () => !promptEmpty
const canPublishInChatMode = () => {
if (!isAdvancedMode) return true
if (modelModeType !== ModelModeType.completion) return true
return hasSetBlockStatus.history && hasSetBlockStatus.query
}
// Clean main logic
const canPublish = mode === AppModeEnum.COMPLETION
? canPublishInCompletionMode()
: canPublishInChatMode()
```
## Pattern 4: Replace Chained Ternaries
**Before** (complexity: ~5):
```typescript
const statusText = serverActivated
? t('status.running')
: serverPublished
? t('status.inactive')
: appUnpublished
? t('status.unpublished')
: t('status.notConfigured')
```
**After** (complexity: ~2):
```typescript
const getStatusText = () => {
if (serverActivated) return t('status.running')
if (serverPublished) return t('status.inactive')
if (appUnpublished) return t('status.unpublished')
return t('status.notConfigured')
}
const statusText = getStatusText()
```
Or use lookup:
```typescript
const STATUS_TEXT_MAP = {
running: 'status.running',
inactive: 'status.inactive',
unpublished: 'status.unpublished',
notConfigured: 'status.notConfigured',
} as const
const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => {
if (serverActivated) return 'running'
if (serverPublished) return 'inactive'
if (appUnpublished) return 'unpublished'
return 'notConfigured'
}
const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
```
## Pattern 5: Flatten Nested Loops
**Before** (complexity: high):
```typescript
const processData = (items: Item[]) => {
const results: ProcessedItem[] = []
for (const item of items) {
if (item.isValid) {
for (const child of item.children) {
if (child.isActive) {
for (const prop of child.properties) {
if (prop.value !== null) {
results.push({
itemId: item.id,
childId: child.id,
propValue: prop.value,
})
}
}
}
}
}
}
return results
}
```
**After** (complexity: lower):
```typescript
// Use functional approach
const processData = (items: Item[]) => {
return items
.filter(item => item.isValid)
.flatMap(item =>
item.children
.filter(child => child.isActive)
.flatMap(child =>
child.properties
.filter(prop => prop.value !== null)
.map(prop => ({
itemId: item.id,
childId: child.id,
propValue: prop.value,
}))
)
)
}
```
## Pattern 6: Extract Event Handler Logic
**Before** (complexity: high in component):
```typescript
const Component = () => {
const handleSelect = (data: DataSet[]) => {
if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
let newDatasets = data
if (data.find(item => !item.name)) {
const newSelected = produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const newItem = dataSets.find(i => i.id === item.id)
if (newItem)
draft[index] = newItem
}
})
})
setDataSets(newSelected)
newDatasets = newSelected
}
else {
setDataSets(data)
}
hideSelectDataSet()
// 40 more lines of logic...
}
return <div>...</div>
}
```
**After** (complexity: lower):
```typescript
// Extract to hook or utility
const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[]>) => {
const normalizeSelection = (data: DataSet[]) => {
const hasUnloadedItem = data.some(item => !item.name)
if (!hasUnloadedItem) return data
return produce(data, (draft) => {
data.forEach((item, index) => {
if (!item.name) {
const existing = dataSets.find(i => i.id === item.id)
if (existing) draft[index] = existing
}
})
})
}
const hasSelectionChanged = (newData: DataSet[]) => {
return !isEqual(
newData.map(item => item.id),
dataSets.map(item => item.id)
)
}
return { normalizeSelection, hasSelectionChanged }
}
// Component becomes cleaner
const Component = () => {
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
const handleSelect = (data: DataSet[]) => {
if (!hasSelectionChanged(data)) {
hideSelectDataSet()
return
}
formattingChangedDispatcher()
const normalized = normalizeSelection(data)
setDataSets(normalized)
hideSelectDataSet()
}
return <div>...</div>
}
```
## Pattern 7: Reduce Boolean Logic Complexity
**Before** (complexity: ~8):
```typescript
const toggleDisabled = hasInsufficientPermissions
|| appUnpublished
|| missingStartNode
|| triggerModeDisabled
|| (isAdvancedApp && !currentWorkflow?.graph)
|| (isBasicApp && !basicAppConfig.updated_at)
```
**After** (complexity: ~3):
```typescript
// Extract meaningful boolean functions
const isAppReady = () => {
if (isAdvancedApp) return !!currentWorkflow?.graph
return !!basicAppConfig.updated_at
}
const hasRequiredPermissions = () => {
return isCurrentWorkspaceEditor && !hasInsufficientPermissions
}
const canToggle = () => {
if (!hasRequiredPermissions()) return false
if (!isAppReady()) return false
if (missingStartNode) return false
if (triggerModeDisabled) return false
return true
}
const toggleDisabled = !canToggle()
```
## Pattern 8: Simplify useMemo/useCallback Dependencies
**Before** (complexity: multiple recalculations):
```typescript
const payload = useMemo(() => {
let parameters: Parameter[] = []
let outputParameters: OutputParameter[] = []
if (!published) {
parameters = (inputs || []).map((item) => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
outputParameters = (outputs || []).map((item) => ({
name: item.variable,
description: '',
type: item.value_type,
}))
}
else if (detail && detail.tool) {
parameters = (inputs || []).map((item) => ({
// Complex transformation...
}))
outputParameters = (outputs || []).map((item) => ({
// Complex transformation...
}))
}
return {
icon: detail?.icon || icon,
label: detail?.label || name,
// ...more fields
}
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
```
**After** (complexity: separated concerns):
```typescript
// Separate transformations
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
return useMemo(() => {
if (!published) {
return inputs.map(item => ({
name: item.variable,
description: '',
form: 'llm',
required: item.required,
type: item.type,
}))
}
if (!detail?.tool) return []
return inputs.map(item => ({
name: item.variable,
required: item.required,
type: item.type === 'paragraph' ? 'string' : item.type,
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
}))
}, [inputs, detail, published])
}
// Component uses hook
const parameters = useParameterTransform(inputs, detail, published)
const outputParameters = useOutputTransform(outputs, detail, published)
const payload = useMemo(() => ({
icon: detail?.icon || icon,
label: detail?.label || name,
parameters,
outputParameters,
// ...
}), [detail, icon, name, parameters, outputParameters])
```
## Target Metrics After Refactoring
| Metric | Target |
|--------|--------|
| Total Complexity | < 50 |
| Max Function Complexity | < 30 |
| Function Length | < 30 lines |
| Nesting Depth | 3 levels |
| Conditional Chains | 3 conditions |

View File

@ -0,0 +1,477 @@
# Component Splitting Patterns
This document provides detailed guidance on splitting large components into smaller, focused components in Dify.
## When to Split Components
Split a component when you identify:
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
1. **Repeated patterns** - Similar UI structures used multiple times
1. **300+ lines** - Component exceeds manageable size
1. **Modal clusters** - Multiple modals rendered in one component
## Splitting Strategies
### Strategy 1: Section-Based Splitting
Identify visual sections and extract each as a component.
```typescript
// ❌ Before: Monolithic component (500+ lines)
const ConfigurationPage = () => {
return (
<div>
{/* Header Section - 50 lines */}
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher ... />
</div>
</div>
{/* Config Section - 200 lines */}
<div className="config">
<Config />
</div>
{/* Debug Section - 150 lines */}
<div className="debug">
<Debug ... />
</div>
{/* Modals Section - 100 lines */}
{showSelectDataSet && <SelectDataSet ... />}
{showHistoryModal && <EditHistoryModal ... />}
{showUseGPT4Confirm && <Confirm ... />}
</div>
)
}
// ✅ After: Split into focused components
// configuration/
// ├── index.tsx (orchestration)
// ├── configuration-header.tsx
// ├── configuration-content.tsx
// ├── configuration-debug.tsx
// └── configuration-modals.tsx
// configuration-header.tsx
interface ConfigurationHeaderProps {
isAdvancedMode: boolean
onPublish: () => void
}
function ConfigurationHeader({
isAdvancedMode,
onPublish,
}: ConfigurationHeaderProps) {
const { t } = useTranslation()
return (
<div className="header">
<h1>{t('configuration.title')}</h1>
<div className="actions">
{isAdvancedMode && <Badge>Advanced</Badge>}
<ModelParameterModal ... />
<AppPublisher onPublish={onPublish} />
</div>
</div>
)
}
// index.tsx (orchestration only)
const ConfigurationPage = () => {
const { modelConfig, setModelConfig } = useModelConfig()
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
<ConfigurationHeader
isAdvancedMode={isAdvancedMode}
onPublish={handlePublish}
/>
<ConfigurationContent
modelConfig={modelConfig}
onConfigChange={setModelConfig}
/>
{!isMobile && (
<ConfigurationDebug
inputs={inputs}
onSetting={handleSetting}
/>
)}
<ConfigurationModals
activeModal={activeModal}
onClose={closeModal}
/>
</div>
)
}
```
### Strategy 2: Conditional Block Extraction
Extract large conditional rendering blocks.
```typescript
// ❌ Before: Large conditional blocks
const AppInfo = () => {
return (
<div>
{expand ? (
<div className="expanded">
{/* 100 lines of expanded view */}
</div>
) : (
<div className="collapsed">
{/* 50 lines of collapsed view */}
</div>
)}
</div>
)
}
// ✅ After: Separate view components
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
return (
<div className="expanded">
{/* Clean, focused expanded view */}
</div>
)
}
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
return (
<div className="collapsed">
{/* Clean, focused collapsed view */}
</div>
)
}
const AppInfo = () => {
return (
<div>
{expand
? <AppInfoExpanded appDetail={appDetail} onAction={handleAction} />
: <AppInfoCollapsed appDetail={appDetail} onAction={handleAction} />
}
</div>
)
}
```
### Strategy 3: Modal Extraction
Extract modals with their trigger logic.
```typescript
// ❌ Before: Multiple modals in one component
const AppInfo = () => {
const [showEdit, setShowEdit] = useState(false)
const [showDuplicate, setShowDuplicate] = useState(false)
const [showDelete, setShowDelete] = useState(false)
const [showSwitch, setShowSwitch] = useState(false)
const onEdit = async (data) => { /* 20 lines */ }
const onDuplicate = async (data) => { /* 20 lines */ }
const onDelete = async () => { /* 15 lines */ }
return (
<div>
{/* Main content */}
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
{showSwitch && <SwitchModal ... />}
</div>
)
}
// ✅ After: Modal manager component
// app-info-modals.tsx
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null
interface AppInfoModalsProps {
appDetail: AppDetail
activeModal: ModalType
onClose: () => void
onSuccess: () => void
}
function AppInfoModals({
appDetail,
activeModal,
onClose,
onSuccess,
}: AppInfoModalsProps) {
const handleEdit = async (data) => { /* logic */ }
const handleDuplicate = async (data) => { /* logic */ }
const handleDelete = async () => { /* logic */ }
return (
<>
{activeModal === 'edit' && (
<EditModal
appDetail={appDetail}
onConfirm={handleEdit}
onClose={onClose}
/>
)}
{activeModal === 'duplicate' && (
<DuplicateModal
appDetail={appDetail}
onConfirm={handleDuplicate}
onClose={onClose}
/>
)}
{activeModal === 'delete' && (
<DeleteConfirm
onConfirm={handleDelete}
onClose={onClose}
/>
)}
{activeModal === 'switch' && (
<SwitchModal
appDetail={appDetail}
onClose={onClose}
/>
)}
</>
)
}
// Parent component
const AppInfo = () => {
const { activeModal, openModal, closeModal } = useModalState()
return (
<div>
{/* Main content with openModal triggers */}
<Button onClick={() => openModal('edit')}>Edit</Button>
<AppInfoModals
appDetail={appDetail}
activeModal={activeModal}
onClose={closeModal}
onSuccess={handleSuccess}
/>
</div>
)
}
```
### Strategy 4: List Item Extraction
Extract repeated item rendering.
```typescript
// ❌ Before: Inline item rendering
const OperationsList = () => {
return (
<div>
{operations.map(op => (
<div key={op.id} className="operation-item">
<span className="icon">{op.icon}</span>
<span className="title">{op.title}</span>
<span className="description">{op.description}</span>
<button onClick={() => op.onClick()}>
{op.actionLabel}
</button>
{op.badge && <Badge>{op.badge}</Badge>}
{/* More complex rendering... */}
</div>
))}
</div>
)
}
// ✅ After: Extracted item component
interface OperationItemProps {
operation: Operation
onAction: (id: string) => void
}
function OperationItem({ operation, onAction }: OperationItemProps) {
return (
<div className="operation-item">
<span className="icon">{operation.icon}</span>
<span className="title">{operation.title}</span>
<span className="description">{operation.description}</span>
<button onClick={() => onAction(operation.id)}>
{operation.actionLabel}
</button>
{operation.badge && <Badge>{operation.badge}</Badge>}
</div>
)
}
const OperationsList = () => {
const handleAction = useCallback((id: string) => {
const op = operations.find(o => o.id === id)
op?.onClick()
}, [operations])
return (
<div>
{operations.map(op => (
<OperationItem
key={op.id}
operation={op}
onAction={handleAction}
/>
))}
</div>
)
}
```
## Directory Structure Patterns
### Pattern A: Flat Structure (Simple Components)
For components with 2-3 sub-components:
```
component-name/
├── index.tsx # Main component
├── sub-component-a.tsx
├── sub-component-b.tsx
└── types.ts # Shared types
```
### Pattern B: Nested Structure (Complex Components)
For components with many sub-components:
```
component-name/
├── index.tsx # Main orchestration
├── types.ts # Shared types
├── hooks/
│ ├── use-feature-a.ts
│ └── use-feature-b.ts
├── components/
│ ├── header/
│ │ └── index.tsx
│ ├── content/
│ │ └── index.tsx
│ └── modals/
│ └── index.tsx
└── utils/
└── helpers.ts
```
### Pattern C: Feature-Based Structure (Dify Standard)
Following Dify's existing patterns:
```
configuration/
├── index.tsx # Main page component
├── base/ # Base/shared components
│ ├── feature-panel/
│ ├── group-name/
│ └── operation-btn/
├── config/ # Config section
│ ├── index.tsx
│ ├── agent/
│ └── automatic/
├── dataset-config/ # Dataset section
│ ├── index.tsx
│ ├── card-item/
│ └── params-config/
├── debug/ # Debug section
│ ├── index.tsx
│ └── hooks.tsx
└── hooks/ # Shared hooks
└── use-advanced-prompt-config.ts
```
## Props Design
### Minimal Props Principle
Pass only what's needed:
```typescript
// ❌ Bad: Passing entire objects when only some fields needed
<ConfigHeader appDetail={appDetail} modelConfig={modelConfig} />
// ✅ Good: Destructure to minimum required
<ConfigHeader
appName={appDetail.name}
isAdvancedMode={modelConfig.isAdvanced}
onPublish={handlePublish}
/>
```
### Callback Props Pattern
Use callbacks for child-to-parent communication:
```typescript
// Parent
const Parent = () => {
const [value, setValue] = useState('')
return (
<Child
value={value}
onChange={setValue}
onSubmit={handleSubmit}
/>
)
}
// Child
interface ChildProps {
value: string
onChange: (value: string) => void
onSubmit: () => void
}
function Child({ value, onChange, onSubmit }: ChildProps) {
return (
<div>
<input value={value} onChange={e => onChange(e.target.value)} />
<button onClick={onSubmit}>Submit</button>
</div>
)
}
```
### Render Props for Flexibility
When sub-components need parent context:
```typescript
interface ListProps<T> {
items: T[]
renderItem: (item: T, index: number) => React.ReactNode
renderEmpty?: () => React.ReactNode
}
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
if (items.length === 0 && renderEmpty) {
return <>{renderEmpty()}</>
}
return (
<div>
{items.map((item, index) => renderItem(item, index))}
</div>
)
}
// Usage
<List
items={operations}
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
renderEmpty={() => <EmptyState message="No operations" />}
/>
```

View File

@ -0,0 +1,281 @@
# Hook Extraction Patterns
This document provides detailed guidance on extracting custom hooks from complex components in Dify.
## When to Extract Hooks
Extract a custom hook when you identify:
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
1. **Business logic** - Data transformations, validations, or calculations
1. **Reusable patterns** - Logic that appears in multiple components
## Extraction Process
### Step 1: Identify State Groups
Look for state variables that are logically related:
```typescript
// ❌ These belong together - extract to hook
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
const [completionParams, setCompletionParams] = useState<FormValue>({})
const [modelModeType, setModelModeType] = useState<ModelModeType>(...)
// These are model-related state that should be in useModelConfig()
```
### Step 2: Identify Related Effects
Find effects that modify the grouped state:
```typescript
// ❌ These effects belong with the state above
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties.mode
if (mode) {
const newModelConfig = produce(modelConfig, (draft) => {
draft.mode = mode
})
setModelConfig(newModelConfig)
}
}
}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel])
```
### Step 3: Create the Hook
```typescript
// hooks/use-model-config.ts
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type { ModelConfig } from '@/models/debug'
import { produce } from 'immer'
import { useEffect, useState } from 'react'
import { ModelModeType } from '@/types/app'
interface UseModelConfigParams {
initialConfig?: Partial<ModelConfig>
currModel?: { model_properties?: { mode?: ModelModeType } }
hasFetchedDetail: boolean
}
interface UseModelConfigReturn {
modelConfig: ModelConfig
setModelConfig: (config: ModelConfig) => void
completionParams: FormValue
setCompletionParams: (params: FormValue) => void
modelModeType: ModelModeType
}
export const useModelConfig = ({
initialConfig,
currModel,
hasFetchedDetail,
}: UseModelConfigParams): UseModelConfigReturn => {
const [modelConfig, setModelConfig] = useState<ModelConfig>({
provider: 'langgenius/openai/openai',
model_id: 'gpt-3.5-turbo',
mode: ModelModeType.unset,
// ... default values
...initialConfig,
})
const [completionParams, setCompletionParams] = useState<FormValue>({})
const modelModeType = modelConfig.mode
// Fill old app data missing model mode
useEffect(() => {
if (hasFetchedDetail && !modelModeType) {
const mode = currModel?.model_properties?.mode
if (mode) {
setModelConfig(produce(modelConfig, (draft) => {
draft.mode = mode
}))
}
}
}, [hasFetchedDetail, modelModeType, currModel])
return {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
}
}
```
### Step 4: Update Component
```typescript
// Before: 50+ lines of state management
function Configuration() {
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
// ... lots of related state and effects
}
// After: Clean component
function Configuration() {
const {
modelConfig,
setModelConfig,
completionParams,
setCompletionParams,
modelModeType,
} = useModelConfig({
currModel,
hasFetchedDetail,
})
// Component now focuses on UI
}
```
## Naming Conventions
### Hook Names
- Use `use` prefix: `useModelConfig`, `useDatasetConfig`
- Be specific: `useAdvancedPromptConfig` not `usePrompt`
- Include domain: `useWorkflowVariables`, `useMCPServer`
### File Names
- Kebab-case: `use-model-config.ts`
- Place in `hooks/` subdirectory when multiple hooks exist
- Place alongside component for single-use hooks
### Return Type Names
- Suffix with `Return`: `UseModelConfigReturn`
- Suffix params with `Params`: `UseModelConfigParams`
## Common Hook Patterns in Dify
### 1. Data Fetching / Mutation Hooks
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
- Do not introduce deprecated `useInvalid` / `useReset`.
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
### 2. Form State Hook
```typescript
// Pattern: Form state + validation + submission
export const useConfigForm = (initialValues: ConfigFormValues) => {
const [values, setValues] = useState(initialValues)
const [errors, setErrors] = useState<Record<string, string>>({})
const [isSubmitting, setIsSubmitting] = useState(false)
const validate = useCallback(() => {
const newErrors: Record<string, string> = {}
if (!values.name) newErrors.name = 'Name is required'
setErrors(newErrors)
return Object.keys(newErrors).length === 0
}, [values])
const handleChange = useCallback((field: string, value: any) => {
setValues(prev => ({ ...prev, [field]: value }))
}, [])
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
if (!validate()) return
setIsSubmitting(true)
try {
await onSubmit(values)
} finally {
setIsSubmitting(false)
}
}, [values, validate])
return { values, errors, isSubmitting, handleChange, handleSubmit }
}
```
### 3. Modal State Hook
```typescript
// Pattern: Multiple modal management
type ModalType = 'edit' | 'delete' | 'duplicate' | null
export const useModalState = () => {
const [activeModal, setActiveModal] = useState<ModalType>(null)
const [modalData, setModalData] = useState<any>(null)
const openModal = useCallback((type: ModalType, data?: any) => {
setActiveModal(type)
setModalData(data)
}, [])
const closeModal = useCallback(() => {
setActiveModal(null)
setModalData(null)
}, [])
return {
activeModal,
modalData,
openModal,
closeModal,
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
}
}
```
### 4. Toggle/Boolean Hook
```typescript
// Pattern: Boolean state with convenience methods
export const useToggle = (initialValue = false) => {
const [value, setValue] = useState(initialValue)
const toggle = useCallback(() => setValue(v => !v), [])
const setTrue = useCallback(() => setValue(true), [])
const setFalse = useCallback(() => setValue(false), [])
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
}
// Usage
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
```
## Testing Extracted Hooks
After extraction, test hooks in isolation:
```typescript
// use-model-config.spec.ts
import { renderHook, act } from '@testing-library/react'
import { useModelConfig } from './use-model-config'
describe('useModelConfig', () => {
it('should initialize with default values', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: false,
}))
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
expect(result.current.modelModeType).toBe(ModelModeType.unset)
})
it('should update model config', () => {
const { result } = renderHook(() => useModelConfig({
hasFetchedDetail: true,
}))
act(() => {
result.current.setModelConfig({
...result.current.modelConfig,
model_id: 'gpt-4',
})
})
expect(result.current.modelConfig.model_id).toBe('gpt-4')
})
})
```

View File

@ -1,6 +1,6 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
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
@ -12,79 +12,26 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
- 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.
## Feature Workflow Layout
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
- Folders represent groups of related files with a shared owner and a stable reason to change together.
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
## 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.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- 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.
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
- 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.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
## 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 and one-off UI extensions beside the component that needs them.
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
## Generated API Contracts
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
## Nullable API Data
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
- 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
@ -92,8 +39,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
- 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 TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly.
- 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`.
@ -102,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## 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.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader 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, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface.
- 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
@ -123,6 +68,4 @@ Use this as the decision guide for React/TypeScript component structure. Existin
## 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.
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.

View File

@ -29,13 +29,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -91,13 +91,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@ -142,13 +142,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -195,7 +195,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
files: ./coverage.xml
disable_search: true

View File

@ -20,7 +20,7 @@ jobs:
run: echo "autofix.ci updates pull request branches, not merge group refs."
- if: github.event_name != 'merge_group'
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Docker Compose inputs
if: github.event_name != 'merge_group'
@ -66,7 +66,7 @@ jobs:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'

View File

@ -21,7 +21,6 @@ env:
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
jobs:
build:
@ -61,20 +60,6 @@ jobs:
file: "web/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-amd64"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
artifact_context: "agent"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-arm64"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
artifact_context: "agent"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
steps:
- name: Prepare
@ -83,7 +68,7 @@ jobs:
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
@ -93,13 +78,13 @@ jobs:
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
- name: Build Docker image
id: build
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
context: ${{ matrix.build_context }}
@ -137,15 +122,12 @@ jobs:
- service_name: "validate-web-amd64"
build_context: "{{defaultContext}}"
file: "web/Dockerfile"
- service_name: "validate-agent-amd64"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Validate Docker image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.build_context }}
@ -165,9 +147,6 @@ jobs:
- service_name: "merge-web-images"
image_name_env: "DIFY_WEB_IMAGE_NAME"
context: "web"
- service_name: "merge-agent-images"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
context: "agent"
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
@ -177,14 +156,14 @@ jobs:
merge-multiple: true
- name: Login to Docker Hub
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ env.DOCKERHUB_USER }}
password: ${{ env.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env[matrix.image_name_env] }}
tags: |

View File

@ -79,7 +79,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -88,7 +88,7 @@ jobs:
with:
bun-version: latest
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
package_json_field: packageManager
run_install: false
@ -123,7 +123,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -131,7 +131,7 @@ jobs:
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
@ -170,7 +170,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -178,7 +178,7 @@ jobs:
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
@ -233,7 +233,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -241,7 +241,7 @@ jobs:
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
@ -274,7 +274,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
path: cli/test-results/
@ -295,7 +295,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -303,7 +303,7 @@ jobs:
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
@ -351,7 +351,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@ -359,7 +359,7 @@ jobs:
- uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with: { bun-version: latest }
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with: { package_json_field: packageManager, run_install: false }
- run: pnpm install --frozen-lockfile
- run: pnpm tree:gen
@ -408,7 +408,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/

View File

@ -1,74 +0,0 @@
name: CLI Edge Publish
on:
push:
branches: [main]
paths:
- 'cli/**'
- 'packages/contracts/generated/api/openapi/**'
workflow_dispatch:
concurrency:
group: difyctl-edge-publish
cancel-in-progress: false
jobs:
publish:
name: build + publish edge to R2
runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }}
if: vars.DIFYCTL_R2_BUCKET != ''
defaults:
run:
shell: bash
working-directory: ./cli
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
- name: Enable cross-arch native prebuilds
working-directory: ./
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Setup Bun
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
with:
bun-version-file: cli/.bun-version
- name: Compute edge version
id: ver
run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT"
- name: Compile standalone binaries (all targets, all-or-nothing)
run: |
CLI_VERSION="${{ steps.ver.outputs.version }}" \
DIFYCTL_CHANNEL=edge \
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
pnpm build:bin
- name: Generate sha256 checksums
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
- name: Smoke the runner-arch binary
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
- name: Publish to R2
env:
AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }}
AWS_DEFAULT_REGION: auto
AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }}
DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }}
DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }}
DIFYCTL_COMMIT: ${{ github.sha }}
run: |
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}"

View File

@ -35,7 +35,7 @@ jobs:
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -98,7 +98,7 @@ jobs:
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
fetch-depth: 1

View File

@ -24,7 +24,7 @@ jobs:
shell: bash
steps:
- name: Checkout cli ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false

View File

@ -30,7 +30,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -51,7 +51,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with:
directory: cli/coverage
flags: cli

View File

@ -13,13 +13,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@ -63,13 +63,13 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"
@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@ -1,28 +0,0 @@
name: Deploy Agent
permissions:
contents: read
on:
workflow_run:
workflows: ["Build and Push API & Web"]
branches:
- "deploy/agent"
types:
- completed
jobs:
deploy:
runs-on: depot-ubuntu-24.04
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.head_branch == 'deploy/agent'
steps:
- name: Deploy to server
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
with:
host: ${{ secrets.AGENT_SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }}

View File

@ -53,7 +53,7 @@ jobs:
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
- name: Build Docker Image
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
with:
project: ${{ vars.DEPOT_PROJECT_ID }}
push: false
@ -77,10 +77,10 @@ jobs:
file: "web/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build Docker Image
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
push: false
context: ${{ matrix.context }}

View File

@ -24,7 +24,7 @@ jobs:
name: Require cherry-pick provenance
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@ -48,7 +48,7 @@ jobs:
vdb-changed: ${{ steps.changes.outputs.vdb }}
migration-changed: ${{ steps.changes.outputs.migration }}
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: changes
with:

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -21,10 +21,10 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
steps:
- name: Checkout default branch (trusted code)
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -17,12 +17,12 @@ jobs:
pull-requests: write
steps:
- name: Checkout PR branch
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true

View File

@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3

View File

@ -19,7 +19,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: false
python-version: "3.12"
@ -71,7 +71,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -114,7 +114,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -171,7 +171,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false

View File

@ -24,7 +24,7 @@ jobs:
working-directory: sdks/nodejs-client
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false

View File

@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0

View File

@ -24,7 +24,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: ${{ matrix.python-version }}

View File

@ -20,7 +20,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -28,7 +28,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
python-version: "3.12"

View File

@ -31,7 +31,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -64,7 +64,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -83,7 +83,7 @@ jobs:
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: web/coverage
flags: web
@ -102,7 +102,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
@ -113,36 +113,13 @@ jobs:
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui tests
run: vp test run --project unit --coverage --silent=passed-only
run: vp test run --coverage --silent=passed-only
- name: Report coverage
if: ${{ env.CODECOV_TOKEN != '' }}
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
directory: packages/dify-ui/coverage
flags: dify-ui
env:
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
dify-ui-storybook-test:
name: dify-ui Storybook Tests
runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash
working-directory: ./packages/dify-ui
steps:
- name: Checkout code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
- name: Setup web environment
uses: ./.github/actions/setup-web
- name: Install Chromium for Browser Mode
run: vp exec playwright install --with-deps chromium
- name: Run dify-ui Storybook tests
run: vp run test:storybook

View File

@ -157,7 +157,7 @@ build-web:
build-api:
@echo "Building API Docker image: $(API_IMAGE):$(VERSION)..."
docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile .
docker build -t $(API_IMAGE):$(VERSION) ./api
@echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)"
# Push Docker images

View File

@ -774,11 +774,3 @@ EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
# Human input timeout check interval in minutes
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
# Nacos remote settings source HTTP timeouts (seconds).
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
# unresponsive Nacos server cannot stall API startup or token refresh.
# Read timeout for Nacos requests (default: 10.0)
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
# Connect timeout for Nacos requests (default: 3.0)
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0

View File

@ -8,30 +8,18 @@
!dify-agent/src/
!dify-agent/src/**
# Environment configuration and example
.env
*.env.*
# Python related files
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
**/.venv/
**/.mypy_cache/
**/.ruff_cache/
**/.import_linter_cache/
**/.pytest_cache/
**/.hypothesis/
# Upload files and logs
api/storage/**
api/logs/
api/*.log*
# Tests
api/tests
# Editor configuration
**/.vscode/
**/.idea/

View File

@ -33,7 +33,6 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
from clients.agent_backend.request_builder import (
AGENT_SOUL_PROMPT_LAYER_ID,
DIFY_EXECUTION_CONTEXT_LAYER_ID,
DIFY_KNOWLEDGE_BASE_LAYER_ID,
DIFY_PLUGIN_TOOLS_LAYER_ID,
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
WORKFLOW_USER_PROMPT_LAYER_ID,
@ -48,7 +47,6 @@ from clients.agent_backend.request_builder import (
__all__ = [
"AGENT_SOUL_PROMPT_LAYER_ID",
"DIFY_EXECUTION_CONTEXT_LAYER_ID",
"DIFY_KNOWLEDGE_BASE_LAYER_ID",
"DIFY_PLUGIN_TOOLS_LAYER_ID",
"WORKFLOW_NODE_JOB_PROMPT_LAYER_ID",
"WORKFLOW_USER_PROMPT_LAYER_ID",

View File

@ -19,7 +19,6 @@ from agenton.compositor.schemas import LayerSessionSnapshot
from agenton.layers import ExitIntent
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
from dify_agent.layers.dify_plugin import (
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
@ -27,12 +26,10 @@ from dify_agent.layers.dify_plugin import (
DifyPluginLLMLayerConfig,
DifyPluginToolsLayerConfig,
)
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
from dify_agent.layers.execution_context import (
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
DifyExecutionContextLayerConfig,
)
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
from dify_agent.protocol import (
@ -40,7 +37,6 @@ from dify_agent.protocol import (
DIFY_AGENT_MODEL_LAYER_ID,
DIFY_AGENT_OUTPUT_LAYER_ID,
CreateRunRequest,
DeferredToolResultsPayload,
LayerExitSignals,
RunComposition,
RunLayerSpec,
@ -54,10 +50,7 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
DIFY_DRIVE_LAYER_ID = "drive"
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
DIFY_ASK_HUMAN_LAYER_ID = "ask_human"
DIFY_SHELL_LAYER_ID = "shell"
@ -141,22 +134,11 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement; a deferred call ends the run and
# the workflow pauses via the existing HITL form mechanism (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@ -188,21 +170,11 @@ class AgentBackendAgentAppRunInput(BaseModel):
idempotency_key: str | None = None
output: AgentBackendOutputConfig | None = None
tools: DifyPluginToolsLayerConfig | None = None
knowledge: DifyKnowledgeBaseLayerConfig | None = None
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
drive_config: DifyDriveLayerConfig | None = None
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
# the Agent Soul configures human involvement (ENG-635).
ask_human_config: DifyAskHumanLayerConfig | None = None
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
include_shell: bool = False
shell_config: DifyShellLayerConfig | None = None
session_snapshot: CompositorSessionSnapshot | None = None
# Human tool results fed back into a continuation run after a HITL submission
# (ENG-638). Keyed by the original deferred tool_call_id.
deferred_tool_results: DeferredToolResultsPayload | None = None
include_history: bool = True
suspend_on_exit: bool = True
metadata: dict[str, JsonValue] = Field(default_factory=dict)
@ -225,7 +197,7 @@ class AgentBackendRunRequestBuilder:
Layer graph: optional Agent Soul system prompt → user prompt →
execution context → optional history (multi-turn) → LLM → optional
plugin tools / knowledge search → optional structured output. Mirrors the workflow-node
plugin tools → optional structured output. Mirrors the workflow-node
layer ordering minus the workflow-job / previous-node prompt.
"""
layers: list[RunLayerSpec] = []
@ -256,18 +228,6 @@ class AgentBackendRunRequestBuilder:
]
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@ -304,30 +264,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.knowledge,
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
@ -362,7 +298,6 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),
@ -413,12 +348,7 @@ class AgentBackendRunRequestBuilder:
)
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
"""Build a workflow Agent Node run request without defining another wire schema.
Layer graph mirrors the workflow surface: prompts → execution context →
optional drive/history → LLM → optional plugin tools / knowledge search
→ optional auxiliary layers such as ask_human, shell, and structured output.
"""
"""Build a workflow Agent Node run request without defining another wire schema."""
layers: list[RunLayerSpec] = []
if run_input.agent_soul_prompt:
layers.append(
@ -453,18 +383,6 @@ class AgentBackendRunRequestBuilder:
]
)
if run_input.drive_config is not None:
# Drive Skills & Files declaration (dify.drive): a config-only index;
# the agent pulls listed entries through the back proxy by drive_ref.
layers.append(
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.drive_config,
)
)
if run_input.include_history:
layers.append(
RunLayerSpec(
@ -503,30 +421,6 @@ class AgentBackendRunRequestBuilder:
)
)
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
layers.append(
RunLayerSpec(
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.knowledge,
)
)
if run_input.ask_human_config is not None:
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
# later resumes with deferred_tool_results. Needs the history layer above.
layers.append(
RunLayerSpec(
name=DIFY_ASK_HUMAN_LAYER_ID,
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
metadata=run_input.metadata,
config=run_input.ask_human_config,
)
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
@ -561,7 +455,6 @@ class AgentBackendRunRequestBuilder:
idempotency_key=run_input.idempotency_key,
metadata=run_input.metadata,
session_snapshot=run_input.session_snapshot,
deferred_tool_results=run_input.deferred_tool_results,
on_exit=LayerExitSignals(
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
),

View File

@ -11,7 +11,6 @@ from .data_migration import (
migration_data_wizard,
)
from .plugin import (
backfill_plugin_auto_upgrade,
extract_plugins,
extract_unique_plugins,
install_plugins,
@ -50,7 +49,6 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
"cleanup_orphaned_draft_variables",

View File

@ -1,11 +1,10 @@
import json
import logging
import time
from typing import Any, cast
import click
from pydantic import TypeAdapter
from sqlalchemy import delete, func, select
from sqlalchemy import delete, select
from sqlalchemy.engine import CursorResult
from configs import dify_config
@ -16,13 +15,11 @@ from core.plugin.plugin_service import PluginService
from core.tools.utils.system_encryption import encrypt_system_params
from extensions.ext_database import db
from models import Tenant
from models.account import TenantPluginAutoUpgradeStrategy
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
from models.provider_ids import DatasourceProviderID, ToolProviderID
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
from models.tools import ToolOAuthSystemClient
from services.plugin.data_migration import PluginDataMigration
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_migration import PluginMigration
logger = logging.getLogger(__name__)
@ -405,110 +402,6 @@ def migrate_data_for_plugin():
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
stmt = (
select(TenantPluginAutoUpgradeStrategy.tenant_id)
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
)
if limit is not None:
stmt = stmt.limit(limit)
return stmt
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
yield from db.session.scalars(stmt)
@click.command(
"backfill-plugin-auto-upgrade",
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
)
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
def backfill_plugin_auto_upgrade(
tenant_id: tuple[str, ...],
limit: int | None,
batch_size: int,
dry_run: bool,
):
"""
Backfill historical auto-upgrade strategies after the category column exists.
Missing category rows are created from the tenant's tool/default row. Pure default
strategies become latest for model plugins and fix-only for all other categories.
Tenants with include/exclude plugin IDs are split
by installed plugin category using plugin daemon metadata.
"""
start_at = time.perf_counter()
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
if dry_run:
elapsed = time.perf_counter() - start_at
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
return
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
backfilled_count = 0
created_count = 0
normalized_count = 0
skipped_count = 0
failed_count = 0
for index, current_tenant_id in enumerate(tenant_ids, start=1):
try:
result = PluginAutoUpgradeService.backfill_strategy_categories(
current_tenant_id,
)
except Exception as e:
failed_count += 1
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
continue
if result.created_count > 0:
backfilled_count += 1
created_count += result.created_count
elif not result.normalized:
skipped_count += 1
if result.normalized:
normalized_count += 1
if batch_size > 0 and index % batch_size == 0:
click.echo(
click.style(
f"Processed {index}/{candidate_count} tenants. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={time.perf_counter() - start_at:.2f}s",
fg="yellow",
)
)
elapsed = time.perf_counter() - start_at
click.echo(
click.style(
f"Backfill plugin auto-upgrade strategy categories completed. "
f"backfilled={backfilled_count}, created_rows={created_count}, "
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
f"elapsed={elapsed:.2f}s",
fg="green",
)
)
@click.command("extract-plugins", help="Extract plugins.")
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)

View File

@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
CAN_REPLACE_LOGO: bool = Field(
description="Allow customization of the enterprise logo.",
default=True,
default=False,
)
ENTERPRISE_REQUEST_TIMEOUT: int = Field(

View File

@ -31,13 +31,3 @@ class AgentBackendConfig(BaseSettings):
),
default=False,
)
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
description=(
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
"into Agent runs. The declaration is an index only — the agent backend "
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
"until the agent backend registers the dify.drive layer type."
),
default=False,
)

View File

@ -20,12 +20,6 @@ class NacosHttpClient:
self.token: str | None = None
self.token_ttl = 18000
self.token_expire_time: float = 0
# Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API
# service indefinitely during startup or token refresh.
self.timeout = httpx.Timeout(
float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")),
connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")),
)
def http_request(
self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None
@ -34,17 +28,12 @@ class NacosHttpClient:
headers = {}
if params is None:
params = {}
full_url = "http://" + self.server + url
try:
self._inject_auth_info(headers, params)
response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout)
response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params)
response.raise_for_status()
return response.text
except httpx.TimeoutException as e:
logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e)
return f"Request to Nacos timed out: {e}"
except httpx.RequestError as e:
logger.warning("Request to Nacos failed (url=%s): %s", full_url, e)
return f"Request to Nacos failed: {e}"
def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None:
@ -89,16 +78,13 @@ class NacosHttpClient:
params = {"username": self.username, "password": self.password}
url = "http://" + self.server + "/nacos/v1/auth/login"
try:
resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout)
resp = httpx.request("POST", url, headers=None, params=params)
resp.raise_for_status()
response_data = resp.json()
self.token = response_data.get("accessToken")
self.token_ttl = response_data.get("tokenTtl", 18000)
self.token_expire_time = current_time + self.token_ttl - 10
return self.token
except httpx.TimeoutException:
logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout)
raise
except Exception:
logger.exception("[get-access-token] exception occur")
raise

View File

@ -1,8 +1,7 @@
from copy import deepcopy
from typing import Annotated, Any, Literal, override
from typing import Any, Literal
from uuid import UUID
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, model_validator
from pydantic import BaseModel, Field, model_validator
from libs.helper import UUIDStrOrEmpty
@ -10,53 +9,8 @@ from libs.helper import UUIDStrOrEmpty
class ConversationRenamePayload(BaseModel):
name: str | None = Field(
default=None,
description="Conversation name. Required when `auto_generate` is `false`.",
)
auto_generate: bool = Field(
default=False,
description="Automatically generate the conversation name. When `true`, the `name` field is ignored.",
)
@classmethod
@override
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]:
schema = handler.resolve_ref_schema(handler(core_schema))
properties = schema.get("properties")
if not isinstance(properties, dict):
return schema
auto_generate_schema = deepcopy(properties.get("auto_generate", {"type": "boolean"}))
name_schema = deepcopy(properties.get("name", {"type": "string"}))
non_blank_name_schema: dict[str, Any] = {"pattern": r".*\S.*", "type": "string"}
if isinstance(name_schema, dict) and isinstance(name_schema.get("title"), str):
non_blank_name_schema["title"] = name_schema["title"]
auto_generate_true_schema = {**auto_generate_schema, "enum": [True]}
auto_generate_true_schema.pop("default", None)
return {
**schema,
"anyOf": [
{
"properties": {
"auto_generate": auto_generate_true_schema,
"name": name_schema,
},
"required": ["auto_generate"],
"type": "object",
},
{
"properties": {
"auto_generate": {**auto_generate_schema, "enum": [False]},
"name": non_blank_name_schema,
},
"required": ["name"],
"type": "object",
},
],
}
name: str | None = None
auto_generate: bool = False
@model_validator(mode="after")
def validate_name_requirement(self):
@ -70,28 +24,14 @@ class ConversationRenamePayload(BaseModel):
class MessageListQuery(BaseModel):
conversation_id: UUIDStrOrEmpty = Field(description="Conversation ID.")
first_id: UUIDStrOrEmpty | None = Field(
default=None,
description=(
"The ID of the first chat record on the current page. Omit this value to fetch the latest messages; "
"for subsequent pages, use the first message ID from the current list to fetch older messages."
),
)
limit: int = Field(
default=20,
ge=1,
le=100,
description="Number of chat history messages to return per request.",
)
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
class MessageFeedbackPayload(BaseModel):
rating: Literal["like", "dislike"] | None = Field(
default=None,
description="Feedback rating. Set to `null` to revoke previously submitted feedback.",
)
content: str | None = Field(default=None, description="Optional text feedback providing additional detail.")
rating: Literal["like", "dislike"] | None = None
content: str | None = None
# --- Saved message schemas ---
@ -108,39 +48,6 @@ class SavedMessageCreatePayload(BaseModel):
# --- Workflow schemas ---
WORKFLOW_INPUT_FILE_ITEM_SCHEMA: dict[str, object] = {
"type": "object",
"required": ["type", "transfer_method"],
"properties": {
"type": {
"description": "File type.",
"enum": ["document", "image", "audio", "video", "custom"],
"type": "string",
},
"transfer_method": {
"description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.",
"enum": ["remote_url", "local_file"],
"type": "string",
},
"url": {
"description": "File URL when `transfer_method` is `remote_url`.",
"format": "url",
"type": "string",
},
"upload_file_id": {
"description": (
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when "
"`transfer_method` is `local_file`."
),
"type": "string",
},
},
}
WORKFLOW_INPUT_FILE_LIST_SCHEMA: dict[str, object] = {
"anyOf": [{"items": WORKFLOW_INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}]
}
WorkflowInputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(WORKFLOW_INPUT_FILE_LIST_SCHEMA)]
class DefaultBlockConfigQuery(BaseModel):
q: str | None = None
@ -154,22 +61,8 @@ class WorkflowListQuery(BaseModel):
class WorkflowRunPayload(BaseModel):
inputs: dict[str, Any] = Field(
description=(
"Key-value pairs for workflow input variables. Values for file-type variables should be arrays of "
"file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the "
"`user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) "
"response to discover the variable names and types expected by your app."
)
)
files: WorkflowInputFileList = Field(
default=None,
description=(
"File list for workflow system file inputs. Available when file upload is enabled for the workflow. "
"To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use "
"the returned `id` as `upload_file_id` with `transfer_method: local_file`."
),
)
inputs: dict[str, Any]
files: list[dict[str, Any]] | None = Field(default=None)
class WorkflowUpdatePayload(BaseModel):
@ -184,49 +77,28 @@ DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
class ChildChunkCreatePayload(BaseModel):
content: str = Field(description="Child chunk text content.")
content: str
class ChildChunkUpdatePayload(BaseModel):
content: str = Field(description="Child chunk text content.")
content: str
class DocumentBatchDownloadZipPayload(BaseModel):
"""Request payload for bulk downloading documents as a zip archive."""
document_ids: list[UUID] = Field(
...,
min_length=1,
max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS,
description="List of document IDs to include in the ZIP download.",
)
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
class MetadataUpdatePayload(BaseModel):
name: str = Field(description="New metadata field name.")
name: str
# --- Audio schemas ---
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
class TextToAudioPayload(BaseModel):
message_id: UUIDString | None = Field(
default=None,
description="Message ID. Takes priority over `text` when both are provided.",
)
voice: str | None = Field(
default=None,
description=(
"Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. "
"Omit to use the app's configured voice when available; that value is exposed by "
"[Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`."
),
)
text: str | None = Field(default=None, description="Speech content to convert.")
streaming: bool | None = Field(
default=None,
description="Reserved for compatibility; TTS response streaming is determined by the provider output.",
)
message_id: str | None = Field(default=None, description="Message ID")
voice: str | None = Field(default=None, description="Voice to use for TTS")
text: str | None = Field(default=None, description="Text to convert to audio")
streaming: bool | None = Field(default=None, description="Enable streaming response")

View File

@ -35,23 +35,17 @@ class HumanInputFormSubmitPayload(BaseModel):
),
examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE],
)
action: str = Field(
description=(
"ID of the action button the recipient selected. Must match one of the `id` values from the form's "
"`user_actions` list."
)
)
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():
match value:
case None:
result[key] = ""
case dict() | list():
result[key] = json.dumps(value, ensure_ascii=False)
case _:
result[key] = str(value)
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

View File

@ -1,30 +0,0 @@
from collections.abc import Callable
from functools import wraps
from core.rbac import RBACPermission, RBACResourceScope
__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"]
def rbac_permission_required[**P, R](
resource_type: RBACResourceScope,
scene: RBACPermission,
*,
resource_required: bool = True,
) -> Callable[[Callable[P, R]], Callable[P, R]]:
"""Check enterprise RBAC permissions for the current user.
Args:
resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace).
scene: The :class:`RBACPermission` permission point.
resource_required: Whether a concrete resource ID is required.
"""
def decorator(view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
return view(*args, **kwargs)
return decorated
return decorator

View File

@ -54,7 +54,6 @@ from .app import (
agent_app_access,
agent_app_feature,
agent_app_sandbox,
agent_drive_inspector,
annotation,
app,
audio,
@ -156,7 +155,6 @@ __all__ = [
"agent_app_feature",
"agent_app_sandbox",
"agent_composer",
"agent_drive_inspector",
"agent_providers",
"agent_roster",
"annotation",

View File

@ -1,10 +0,0 @@
from uuid import UUID
from extensions.ext_database import db
from models.model import App
from services.agent.roster_service import AgentRosterService
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
"""Resolve the hidden Agent App backing an Agent Console resource."""
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))

View File

@ -1,10 +1,7 @@
from uuid import UUID
from flask_restx import Resource
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
@ -38,10 +35,6 @@ register_response_schema_models(
)
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
class WorkflowAgentComposerApi(Resource):
@console_ns.response(
@ -101,13 +94,7 @@ class WorkflowAgentComposerValidateApi(Resource):
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
),
)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@ -183,18 +170,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/composer")
class AgentComposerApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
class AgentAppComposerApi(Resource):
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, app_model: App):
return dump_response(
AgentAppComposerResponse,
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
)
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@ -203,24 +190,24 @@ class AgentComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
def put(self, tenant_id: str, account_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentAppComposerResponse,
AgentComposerService.save_agent_app_composer(
tenant_id=tenant_id,
app_id=app_id,
app_id=app_model.id,
account_id=account_id,
payload=payload,
),
)
@console_ns.route("/agent/<uuid:agent_id>/composer/validate")
class AgentComposerValidateApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
class AgentAppComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@console_ns.response(
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
@ -228,36 +215,32 @@ class AgentComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
def post(self, tenant_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
agent_id=str(agent_id),
)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/agent/<uuid:agent_id>/composer/candidates")
class AgentComposerCandidatesApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
class AgentAppComposerCandidatesApi(Resource):
@console_ns.response(
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, current_user_id: str, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_id,
app_id=app_model.id,
user_id=current_user_id,
),
)

View File

@ -1,65 +1,31 @@
from uuid import UUID
from flask import abort, request
from flask import request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from pydantic import BaseModel, Field
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
)
from controllers.console.app.app import (
AppListQuery,
CopyAppPayload,
_normalize_app_list_query_args,
)
from controllers.console.app.app import (
AppPagination as GenericAppPagination,
)
from controllers.console.app.app import (
AppPartial as GenericAppPartial,
)
from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
enterprise_license_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
from extensions.ext_database import db
from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentLogMessageListResponse,
AgentLogSourceListResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
AgentRosterResponse,
)
from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
AgentObservabilityService,
AgentStatisticsQueryParams,
)
from services.agent.roster_service import AgentRosterService
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.agent_entities import RosterListQuery
from services.feature_service import FeatureService
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
class AgentInviteOptionsQuery(RosterListQuery):
@ -70,129 +36,22 @@ class AgentIdPath(BaseModel):
agent_id: str
class AgentAppCreatePayload(BaseModel):
name: str = Field(..., min_length=1, description="Agent name")
description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400)
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
# Keep agent-app roster DTOs agent-specific instead of reusing the shared
# /apps response/request models. The roster surface needs Agent-only fields such
# as `role`, while the generic console/apps contracts must stay unchanged.
class AgentAppUpdatePayload(GenericUpdateAppPayload):
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
@field_validator("role")
@classmethod
def validate_role(cls, value: str) -> str:
role = value.strip()
if not role:
raise ValueError("Agent role is required.")
return role
class AgentAppPublishedReferenceResponse(BaseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
class AgentLogsQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, le=100, description="Page size")
keyword: str | None = Field(default=None, description="Search query, answer, or conversation name")
status: str | None = Field(default=None, description="Filter by success, failed, or paused")
source: str | None = Field(
default=None,
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
)
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
@field_validator("keyword", "status", "source", "start", "end", mode="before")
@classmethod
def empty_string_to_none(cls, value: str | None) -> str | None:
if value == "":
return None
return value
class AgentStatisticsQuery(BaseModel):
source: str | None = Field(
default=None,
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
)
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
@field_validator("source", "start", "end", mode="before")
@classmethod
def empty_string_to_none(cls, value: str | None) -> str | None:
if value == "":
return None
return value
class AgentAppPartial(GenericAppPartial):
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
published_reference_count: int = 0
published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list)
class AgentAppDetailWithSite(GenericAppDetailWithSite):
app_id: str | None = None
role: str | None = None
active_config_is_published: bool = False
class AgentAppPagination(GenericAppPagination):
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
validation_alias=AliasChoices("items", "data")
)
register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
CopyAppPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
AgentStatisticsQuery,
AgentIdPath,
AppListQuery,
RosterAgentCreatePayload,
RosterAgentUpdatePayload,
RosterListQuery,
)
register_response_schema_models(
console_ns,
AgentAppPagination,
AgentAppPublishedReferenceResponse,
AgentAppDetailWithSite,
AgentAppPartial,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentLogMessageListResponse,
AgentLogSourceListResponse,
AgentPublishedReferenceResponse,
AgentRosterListResponse,
AgentStatisticSummaryEnvelopeResponse,
AgentRosterResponse,
)
@ -200,237 +59,42 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
def _serialize_agent_app_detail(app_model) -> dict:
"""Serialize an Agent App detail using roster-only DTOs.
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
becomes the backing roster Agent id, `app_id` carries the underlying App
id, and `role` is injected from the backing roster Agent. Keeping that
remap in this serializer lets generated console/agent contracts expose the
roster persona fields without widening the shared /apps detail schema.
"""
app_model = AppService().get_app(app_model)
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_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
roster_service = _agent_roster_service()
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
if not agent:
raise AgentNotFoundError()
payload.pop("bound_agent_id", None)
payload["app_id"] = str(app_model.id)
payload["id"] = agent.id
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
agent=agent,
)
return payload
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
"""Serialize Agent App lists with roster-shaped items.
Each item starts from the shared App list shape, then drops
`bound_agent_id`, rewrites `id` to the backing roster Agent id, stores the
original App id in `app_id`, and injects roster-only `role` when a backing
Agent is present.
"""
app_ids = [str(app.id) for app in app_pagination.items]
roster_service = _agent_roster_service()
agents_by_app_id = roster_service.load_app_backing_agents_by_app_id(
tenant_id=tenant_id,
app_ids=app_ids,
)
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
)
published_references_by_agent_id = roster_service.load_published_references_by_agent_id(
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()],
)
payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
item.pop("bound_agent_id", None)
agent = agents_by_app_id.get(app_id)
if agent:
item["app_id"] = app_id
item["id"] = agent.id
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
published_references = published_references_by_agent_id.get(agent.id, [])
item["published_reference_count"] = len(published_references)
item["published_references"] = [
{
"app_id": reference["app_id"],
"app_name": reference["app_name"],
"app_icon_type": reference["app_icon_type"],
"app_icon": reference["app_icon"],
"app_icon_background": reference["app_icon_background"],
}
for reference in published_references
]
return AgentAppPagination.model_validate(payload).model_dump(
mode="json",
exclude={"data": {"__all__": {"bound_agent_id"}}},
)
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def _agent_observability_service() -> AgentObservabilityService:
return AgentObservabilityService(db.session)
def _parse_observability_time_range(start: str | None, end: str | None, account: Account):
timezone = account.timezone or "UTC"
try:
return parse_time_range(start, end, timezone)
except ValueError as exc:
abort(400, description=str(exc))
@console_ns.route("/agent")
class AgentAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(AppListQuery))
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
@console_ns.route("/agents")
class AgentRosterListApi(Resource):
@console_ns.doc(params=query_params_from_model(RosterListQuery))
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = AppListParams(
page=args.page,
limit=args.limit,
mode="agent",
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
status="normal",
def get(self, tenant_id: str):
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
return dump_response(
AgentRosterListResponse,
_agent_roster_service().list_roster_agents(
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
),
)
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session)
if app_pagination is None:
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account):
args = AgentAppCreatePayload.model_validate(console_ns.payload)
params = CreateAppParams(
name=args.name,
description=args.description,
mode="agent",
agent_role=args.role,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
app = AppService().create_app(current_tenant_id, params, current_user)
return _serialize_agent_app_detail(app), 201
def post(self, tenant_id: str, account_id: str):
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 dump_response(
AgentRosterResponse,
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
), 201
@console_ns.route("/agent/<uuid:agent_id>")
class AgentAppApi(Resource):
@console_ns.response(200, "Agent app detail", console_ns.models[AgentAppDetailWithSite.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model)
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def put(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
args_dict: AppService.ArgsDict = {
"name": args.name,
"description": args.description or "",
"icon_type": args.icon_type,
"icon": args.icon or "",
"icon_background": args.icon_background or "",
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
"max_active_requests": args.max_active_requests or 0,
"role": args.role,
}
updated = AppService().update_app(app_model, args_dict)
return _serialize_agent_app_detail(updated)
@console_ns.response(204, "Agent app deleted successfully")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def delete(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
AppService().delete_app(app_model)
return "", 204
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = CopyAppPayload.model_validate(console_ns.payload or {})
copied_app = _agent_roster_service().duplicate_agent_app(
tenant_id=tenant_id,
agent_id=str(agent_id),
account=current_user,
name=args.name,
description=args.description,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
return _serialize_agent_app_detail(copied_app), 201
@console_ns.route("/agent/invite-options")
@console_ns.route("/agents/invite-options")
class AgentInviteOptionsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
@ -452,115 +116,49 @@ class AgentInviteOptionsApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/logs")
class AgentLogsApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
@console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__])
@console_ns.route("/agents/<uuid:agent_id>")
class AgentRosterDetailApi(Resource):
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
start, end = _parse_observability_time_range(query.start, query.end, current_user)
try:
payload = _agent_observability_service().list_logs(
app=app_model,
agent_id=str(agent_id),
params=AgentLogQueryParams(
page=query.page,
limit=query.limit,
keyword=query.keyword,
status=query.status,
source=query.source,
start=start,
end=end,
),
)
except ValueError as exc:
abort(400, description=str(exc))
return dump_response(AgentLogListResponse, payload)
def get(self, tenant_id: str, agent_id: UUID):
return dump_response(
AgentRosterResponse,
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
)
@console_ns.route("/agent/<uuid:agent_id>/logs/<uuid:conversation_id>/messages")
class AgentLogMessagesApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
@console_ns.response(200, "Agent log messages", console_ns.models[AgentLogMessageListResponse.__name__])
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@edit_permission_required
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True))
start, end = _parse_observability_time_range(query.start, query.end, current_user)
try:
payload = _agent_observability_service().list_log_messages(
app=app_model,
agent_id=str(agent_id),
conversation_id=str(conversation_id),
params=AgentLogQueryParams(
page=query.page,
limit=query.limit,
keyword=query.keyword,
status=query.status,
source=query.source,
start=start,
end=end,
),
)
except ValueError as exc:
abort(400, description=str(exc))
return dump_response(AgentLogMessageListResponse, payload)
def patch(self, tenant_id: str, account_id: str, agent_id: UUID):
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
return dump_response(
AgentRosterResponse,
_agent_roster_service().update_roster_agent(
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id, payload=payload
),
)
@console_ns.route("/agent/<uuid:agent_id>/log-sources")
class AgentLogSourcesApi(Resource):
@console_ns.response(200, "Agent log sources", console_ns.models[AgentLogSourceListResponse.__name__])
@console_ns.response(204, "Agent archived")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@edit_permission_required
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
return dump_response(AgentLogSourceListResponse, payload)
def delete(self, tenant_id: str, account_id: str, agent_id: UUID):
_agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id)
return "", 204
@console_ns.route("/agent/<uuid:agent_id>/statistics/summary")
class AgentStatisticsSummaryApi(Resource):
@console_ns.doc(params=query_params_from_model(AgentStatisticsQuery))
@console_ns.response(
200,
"Agent monitoring summary and chart data",
console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
timezone = current_user.timezone or "UTC"
start, end = _parse_observability_time_range(query.start, query.end, current_user)
try:
payload = _agent_observability_service().get_statistics_summary(
app=app_model,
agent_id=str(agent_id),
params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone),
)
except ValueError as exc:
abort(400, description=str(exc))
return dump_response(AgentStatisticSummaryEnvelopeResponse, payload)
@console_ns.route("/agent/<uuid:agent_id>/versions")
@console_ns.route("/agents/<uuid:agent_id>/versions")
class AgentRosterVersionsApi(Resource):
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
@setup_required
@ -574,7 +172,7 @@ class AgentRosterVersionsApi(Resource):
)
@console_ns.route("/agent/<uuid:agent_id>/versions/<uuid:version_id>")
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
class AgentRosterVersionDetailApi(Resource):
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
@setup_required

View File

@ -1,62 +1,24 @@
import logging
from typing import Any
from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from pydantic import BaseModel, Field, RootModel, field_validator
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
register_schema_models,
)
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError
from models.model import App, AppMode
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.agent.skill_standardize_service import SkillStandardizeService
from services.agent.skill_tool_inference_service import (
SkillToolInferenceError,
SkillToolInferenceResult,
SkillToolInferenceService,
)
from services.agent_drive_service import (
AgentDriveError,
AgentDriveService,
DriveCommitItem,
DriveFileRef,
normalize_drive_key,
)
from services.agent_drive_service import AgentDriveError
from services.agent_service import AgentService
logger = logging.getLogger(__name__)
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
"in": "formData",
"type": "file",
"required": True,
"description": "Skill package (.zip or .skill).",
}
}
from services.file_service import FileService
class AgentLogQuery(BaseModel):
@ -69,27 +31,6 @@ class AgentLogQuery(BaseModel):
return uuid_value(value)
class AgentDriveFilePayload(BaseModel):
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
@field_validator("upload_file_id")
@classmethod
def validate_upload_file_id(cls, value: str) -> str:
return uuid_value(value)
class AgentDriveMutationQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentDriveDeleteFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
class AgentLogMetaResponse(ResponseModel):
status: str
executor: str
@ -127,217 +68,16 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
class AgentSkillUploadResponse(ResponseModel):
skill: AgentSkillRefConfig
manifest: SkillManifest
class AgentSkillUploadResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class AgentDriveFileResponse(ResponseModel):
name: str
drive_key: str
file_id: str
size: int | None = None
mime_type: str | None = None
class AgentSkillStandardizeResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
config_version_id: str | None = None
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
config_version_id: str | None = None
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
register_response_schema_models(
console_ns,
AgentDriveDeleteResponse,
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
if node_id and app_model.mode != AppMode.AGENT:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, str], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _upload_skill_for_app(*, current_user: Account, app_model: App):
"""Upload one skill package and commit its normalized files into the agent drive."""
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201
def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
upload_file = db.session.scalar(
select(UploadFile).where(
UploadFile.id == payload.upload_file_id,
UploadFile.tenant_id == app_model.tenant_id,
)
)
if upload_file is None:
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
try:
key = normalize_drive_key(f"files/{upload_file.name}")
committed = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(
key=key,
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
# ADD FILE uploads exist solely to live in the drive, so the
# drive owns (and physically cleans) the value on delete.
value_owned_by_drive=True,
)
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
file_ref = AgentFileRefConfig.model_validate(
{
"id": row["key"],
"name": upload_file.name,
"file_id": upload_file.id,
"drive_key": row["key"],
"type": row.get("mime_type"),
"size": row.get("size"),
}
)
config_version_id = AgentComposerService.add_drive_file_ref(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_ref=file_ref,
app_id=app_model.id,
node_id=node_id,
)
return {
"file": {
"name": upload_file.name,
"drive_key": row["key"],
"file_id": upload_file.id,
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveDeleteFileQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
try:
key = normalize_drive_key(query.key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_key=key,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
# Soul-first ordering: the ref is already gone; orphan KV rows are
# harmless and an idempotent DELETE retry cleans them.
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
query = query_params_from_request(AgentDriveMutationQuery)
node_id = query.node_id if allow_node_id else None
agent_id = _resolve_agent_id(app_model, node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
skill_slug=slug,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
query = query_params_from_request(AgentDriveMutationQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
try:
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
except SkillToolInferenceError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
register_schema_models(console_ns, AgentLogQuery)
register_response_schema_models(console_ns, AgentLogResponse, AgentSkillUploadResponse, AgentSkillStandardizeResponse)
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
@ -359,190 +99,82 @@ class AgentLogApi(Resource):
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
class AgentSkillUploadByAgentApi(Resource):
@console_ns.doc("upload_agent_skill_by_agent")
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
class AgentSkillUploadApi(Resource):
@console_ns.doc("upload_agent_skill")
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
@console_ns.doc(
consumes=["multipart/form-data"],
params={
"app_id": "Application ID",
**query_params_from_model(AgentDriveMutationQuery),
**_AGENT_SKILL_UPLOAD_PARAMS,
},
@console_ns.doc(description="Upload + validate a Skill package (.zip/.skill) and extract its manifest")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(201, "Skill validated", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Validate an uploaded Skill package and persist the archive.
Returns a validated skill ref (to bind into the Agent soul config on save)
plus its manifest. Standardizing into the agent drive is ENG-594.
"""
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
upload = request.files["file"]
content = upload.stream.read()
try:
manifest = SkillPackageService().validate_and_extract(content=content, filename=upload.filename or "")
except SkillPackageError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
upload_file = FileService(db.engine).upload_file(
filename=upload.filename or "skill.zip",
content=content,
mimetype=upload.mimetype or "application/zip",
user=current_user,
)
skill_ref = manifest.to_skill_ref(file_id=upload_file.id)
return {"skill": skill_ref.model_dump(exclude_none=True), "manifest": manifest.model_dump()}, 201
@console_ns.route("/apps/<uuid:app_id>/agent/skills/standardize")
class AgentSkillStandardizeApi(Resource):
@console_ns.doc("standardize_agent_skill")
@console_ns.doc(description="Validate + standardize a Skill into the agent drive (ENG-594)")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
201,
"Skill standardized into drive",
console_ns.models[AgentSkillStandardizeResponse.__name__],
)
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
@console_ns.response(400, "Invalid skill package or no bound agent")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a Skill, validate it, and commit drive-backed skill files."""
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
agent_id = app_model.bound_agent_id
if not agent_id:
return {"code": "no_bound_agent", "message": "app has no bound agent"}, 400
if "file" not in request.files:
return {"code": "no_file", "message": "no skill file uploaded"}, 400
if len(request.files) > 1:
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
@console_ns.route("/agent/<uuid:agent_id>/files")
class AgentDriveFilesByAgentApi(Resource):
@console_ns.doc("commit_agent_drive_file_by_agent")
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.doc("delete_agent_drive_file_by_agent")
@console_ns.doc(description="Delete one Agent App drive file by key")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/files")
class AgentDriveFilesApi(Resource):
@console_ns.doc("commit_agent_drive_file")
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
@console_ns.response(
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def post(self, current_user: Account, app_model: App):
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App):
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
class AgentSkillByAgentApi(Resource):
@console_ns.doc("delete_agent_skill_by_agent")
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
class AgentSkillApi(Resource):
@console_ns.doc("delete_agent_skill")
@console_ns.doc(
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
@with_current_user
def delete(self, current_user: Account, app_model: App, slug: str):
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug)
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsByAgentApi(Resource):
@console_ns.doc("infer_agent_skill_tools_by_agent")
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID, slug: str):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
class AgentSkillInferToolsApi(Resource):
@console_ns.doc("infer_agent_skill_tools")
@console_ns.doc(
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
)
@console_ns.doc(
params={
"app_id": "Application ID",
"slug": "Skill slug (single path segment)",
**query_params_from_model(AgentDriveMutationQuery),
}
)
@console_ns.response(
200,
"Inference result (draft suggestions, nothing persisted)",
console_ns.models[SkillToolInferenceResult.__name__],
)
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
def post(self, app_model: App, slug: str):
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
upload = request.files["file"]
content = upload.stream.read()
try:
result = SkillStandardizeService().standardize(
content=content,
filename=upload.filename or "",
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
)
except (SkillPackageError, AgentDriveError) as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
return result, 201

View File

@ -5,31 +5,25 @@ reference. This exposes the read-only "Workflow access" surface from the PRD:
which workflow apps use this Agent, without leaking the workflows' internals.
"""
from uuid import UUID
from flask_restx import Resource
from pydantic import Field
from controllers.common.schema import register_response_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.roster_service import AgentRosterService
class AgentReferencingWorkflowResponse(ResponseModel):
app_id: str
app_name: str
app_icon_type: str | None = None
app_icon: str | None = None
app_icon_background: str | None = None
app_mode: str
app_updated_at: int | None = None
workflow_id: str
workflow_version: str
node_ids: list[str] = Field(default_factory=list)
@ -40,23 +34,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel):
register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse)
@console_ns.route("/agent/<uuid:agent_id>/referencing-workflows")
@console_ns.route("/apps/<uuid:app_id>/agent-referencing-workflows")
class AgentAppReferencingWorkflowsResource(Resource):
@console_ns.doc("list_agent_app_referencing_workflows")
@console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(
200,
"Referencing workflows listed successfully",
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
)
@console_ns.response(404, "Agent not found")
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, app_model: App):
workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent(
tenant_id=tenant_id, app_id=app_model.id
)

View File

@ -9,20 +9,17 @@ persists them onto the app's ``app_model_config`` without touching anything the
Soul owns.
"""
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
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.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from events.app_event import app_model_config_was_updated
@ -35,6 +32,7 @@ from models.agent_config_entities import (
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
AgentTextToSpeechFeatureConfig,
)
from models.model import App, AppMode
from services.agent_app_feature_service import AgentAppFeatureConfigService
@ -66,23 +64,22 @@ register_schema_models(console_ns, AgentAppFeaturesPayload)
register_response_schema_models(console_ns, SimpleResultResponse)
@console_ns.route("/agent/<uuid:agent_id>/features")
@console_ns.route("/apps/<uuid:app_id>/agent-features")
class AgentAppFeatureConfigResource(Resource):
@console_ns.doc("update_agent_app_features")
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__])
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(400, "Invalid configuration")
@console_ns.response(404, "Agent not found")
@console_ns.response(404, "App not found")
@setup_required
@login_required
@edit_permission_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def post(self, current_user: Account, app_model: App):
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(

View File

@ -22,7 +22,6 @@ from controllers.common.schema import (
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
@ -133,18 +132,18 @@ def _handle(exc: Exception) -> tuple[dict[str, object], int]:
raise exc
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files")
class AgentAppSandboxListResource(Resource):
@console_ns.doc("list_agent_app_sandbox_files")
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxListQuery)})
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentSandboxListQuery)
try:
result = AgentAppSandboxService().list_files(
@ -158,18 +157,18 @@ class AgentAppSandboxListResource(Resource):
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/read")
class AgentAppSandboxReadResource(Resource):
@console_ns.doc("read_agent_app_sandbox_file")
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentSandboxFileQuery)})
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def get(self, tenant_id: str, app_model: App):
query = query_params_from_request(AgentSandboxFileQuery)
try:
result = AgentAppSandboxService().read_file(
@ -183,7 +182,7 @@ class AgentAppSandboxReadResource(Resource):
return result.model_dump()
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
@console_ns.route("/apps/<uuid:app_id>/agent-sandbox/files/upload")
class AgentAppSandboxUploadResource(Resource):
@console_ns.doc("upload_agent_app_sandbox_file")
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
@ -192,9 +191,9 @@ class AgentAppSandboxUploadResource(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def post(self, tenant_id: str, app_model: App):
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
try:
result = AgentAppSandboxService().upload_file(

View File

@ -1,235 +0,0 @@
"""Console read-only inspector for the agent drive (ENG-624).
``agent-drive`` looks at the *static* drive assets (standardized skills and
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
backend — drive data lives in the API's own DB/storage, served straight from
``AgentDriveService``. Download hands the browser an **external** signed URL
(the inner manifest hands agents internal ones — the two must never mix).
"""
from __future__ import annotations
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_response_schema_models,
)
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
from fields.base import ResponseModel
from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent_drive_service import AgentDriveError, AgentDriveService
class AgentDriveListQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveListByAgentQuery(BaseModel):
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
class AgentDriveFileQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
file_kind: str
created_at: int | None = None
class AgentDriveListResponse(ResponseModel):
items: list[AgentDriveItemResponse] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveDownloadResponse(ResponseModel):
url: str
register_response_schema_models(
console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse
)
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
if node_id:
return AgentComposerService.resolve_workflow_node_agent_id(
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
)
return app_model.bound_agent_id
def _agent_not_bound() -> tuple[dict[str, object], int]:
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
class AgentDriveListByAgentApi(Resource):
@console_ns.doc("list_agent_drive_files_by_agent")
@console_ns.doc(description="List agent drive entries for an Agent App")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveListByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
class AgentDriveDownloadByAgentApi(Resource):
@console_ns.doc("download_agent_drive_file_by_agent")
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
query = query_params_from_request(AgentDriveFileByAgentQuery)
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
class AgentDriveListApi(Resource):
@console_ns.doc("list_agent_drive_files")
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveListQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
except AgentDriveError as exc:
return _handle(exc)
# the inner manifest exposes file_id for agent-side pulls; the console
# inspector is a pure read surface and does not need value pointers
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
class AgentDriveDownloadApi(Resource):
@console_ns.doc("download_agent_drive_file")
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App):
query = query_params_from_request(AgentDriveFileQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
except AgentDriveError as exc:
return _handle(exc)
return {"url": url}
__all__ = [
"AgentDriveDownloadApi",
"AgentDriveDownloadByAgentApi",
"AgentDriveListApi",
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
]

View File

@ -1,7 +1,6 @@
import logging
import re
import uuid
from collections.abc import Sequence
from datetime import datetime
from typing import Any, Literal, cast
@ -42,12 +41,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
from graphon.enums import WorkflowExecutionStatus
from libs.helper import build_icon_url, dump_response, to_timestamp
from libs.helper import build_icon_url, to_timestamp
from libs.login import login_required
from models import Account, App, DatasetPermissionEnum, Workflow
from models.model import IconType
from services.app_dsl_service import AppDslService
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
from services.app_service import AppListParams, AppService, CreateAppParams
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import ImportMode, ImportStatus
from services.entities.knowledge_entities.knowledge_entities import (
@ -64,7 +63,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
)
from services.feature_service import FeatureService
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
register_enum_models(console_ns, IconType)
@ -74,14 +73,10 @@ _CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
class AppListBaseQuery(BaseModel):
class AppListQuery(BaseModel):
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
sort_by: AppListSortBy = Field(
default="last_modified",
description="Sort apps by last modified, recently created, or earliest created",
)
name: str | None = Field(default=None, description="Filter by app name")
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
@ -124,14 +119,6 @@ class AppListBaseQuery(BaseModel):
raise ValueError("Invalid UUID format in creator_ids.") from exc
class AppListQuery(AppListBaseQuery):
pass
class StarredAppListQuery(AppListBaseQuery):
pass
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
normalized: dict[str, str | list[str]] = {}
indexed_tag_ids: list[tuple[int, str]] = []
@ -163,7 +150,9 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field(
..., description="App mode"
)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@ -398,11 +387,6 @@ class AppPartial(ResponseModel):
create_user_name: str | None = None
author_name: str | None = None
has_draft_trigger: bool | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
app_id: str | None = None
is_starred: bool = False
@computed_field(return_type=str | None) # type: ignore
@property
@ -453,8 +437,6 @@ class AppDetailWithSite(AppDetail):
site: Site | None = None
# For Agent App type: the roster Agent backing this app (None otherwise).
bound_agent_id: str | None = None
# For Agent App responses exposed through /agent.
app_id: str | None = None
@computed_field(return_type=str | None) # type: ignore
@property
@ -474,54 +456,12 @@ class AppExportResponse(ResponseModel):
data: str
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in apps]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in apps:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in apps:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse)
register_schema_models(
console_ns,
AppListQuery,
StarredAppListQuery,
CreateAppPayload,
UpdateAppPayload,
CopyAppPayload,
@ -537,7 +477,10 @@ register_schema_models(
ModelConfig,
Site,
DeletedTool,
AppPartial,
AppDetail,
AppDetailWithSite,
AppPagination,
AppExportResponse,
Segmentation,
PreProcessingRule,
@ -557,13 +500,6 @@ register_schema_models(
LoadBalancingPayload,
)
register_response_schema_models(
console_ns,
AppPartial,
AppDetailWithSite,
AppPagination,
)
@console_ns.route("/apps")
class AppListApi(Resource):
@ -585,7 +521,6 @@ class AppListApi(Resource):
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
@ -594,12 +529,51 @@ class AppListApi(Resource):
# get app list
app_service = AppService()
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session)
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
if FeatureService.get_system_features().webapp_auth.enabled:
app_ids = [str(app.id) for app in app_pagination.items]
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
if len(res) != len(app_ids):
raise BadRequest("Invalid app id in webapp auth")
for app in app_pagination.items:
if str(app.id) in res:
app.access_mode = res[str(app.id)].access_mode
workflow_capable_app_ids = [
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
]
draft_trigger_app_ids: set[str] = set()
if workflow_capable_app_ids:
draft_workflows = (
session.execute(
select(Workflow).where(
Workflow.version == Workflow.VERSION_DRAFT,
Workflow.app_id.in_(workflow_capable_app_ids),
Workflow.tenant_id == current_tenant_id,
)
)
.scalars()
.all()
)
trigger_node_types = TRIGGER_NODE_TYPES
for workflow in draft_workflows:
node_id = None
try:
for node_id, node_data in workflow.walk_nodes():
if node_data.get("type") in trigger_node_types:
draft_trigger_app_ids.add(str(workflow.app_id))
break
except Exception:
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
continue
for app in app_pagination.items:
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@ -607,7 +581,7 @@ class AppListApi(Resource):
@console_ns.doc("create_app")
@console_ns.doc(description="Create a new application")
@console_ns.expect(console_ns.models[CreateAppPayload.__name__])
@console_ns.response(201, "App created successfully", console_ns.models[AppDetailWithSite.__name__])
@console_ns.response(201, "App created successfully", console_ns.models[AppDetail.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@setup_required
@ -631,82 +605,10 @@ class AppListApi(Resource):
app_service = AppService()
app = app_service.create_app(current_tenant_id, params, current_user)
app_detail = AppDetailWithSite.model_validate(app, from_attributes=True)
app_detail = AppDetail.model_validate(app, from_attributes=True)
return app_detail.model_dump(mode="json"), 201
@console_ns.route("/apps/starred")
class StarredAppListApi(Resource):
@console_ns.doc("list_starred_apps")
@console_ns.doc(description="Get applications starred by the current account")
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_session(write=False)
@with_current_user_id
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
params = StarredAppListParams(
page=args.page,
limit=args.limit,
mode=args.mode,
sort_by=args.sort_by,
name=args.name,
tag_ids=args.tag_ids,
creator_ids=args.creator_ids,
is_created_by_me=args.is_created_by_me,
)
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, db.session)
if not app_pagination:
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json"), 200
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
return pagination_model.model_dump(mode="json"), 200
@console_ns.route("/apps/<uuid:app_id>/star")
class AppStarApi(Resource):
@console_ns.doc("star_app")
@console_ns.doc(description="Star an application for the current account")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def post(self, session: Session, current_user_id: str, app_model: App):
AppService.star_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.doc("unstar_app")
@console_ns.doc(description="Remove the current account's star from an application")
@console_ns.doc(params={"app_id": "Application ID"})
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "App not found")
@setup_required
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user_id
@with_session
@get_app_model(mode=None)
def delete(self, session: Session, current_user_id: str, app_model: App):
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
return dump_response(SimpleResultResponse, {"result": "success"})
@console_ns.route("/apps/<uuid:app_id>")
class AppApi(Resource):
@console_ns.doc("get_app_detail")
@ -726,7 +628,7 @@ class AppApi(Resource):
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_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)
return response_model.model_dump(mode="json")

View File

@ -1,6 +1,5 @@
import logging
from typing import Any, Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
@ -11,7 +10,6 @@ import services
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
@ -25,7 +23,6 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
with_current_user_id,
)
@ -189,27 +186,51 @@ class ChatMessageApi(Resource):
@edit_permission_required
@with_current_user
def post(self, current_user: Account, app_model: App):
return _create_chat_message(current_user=current_user, app_model=app_model)
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageApi(Resource):
@console_ns.doc("create_agent_chat_message")
@console_ns.doc(description="Generate an Agent App chat message for debugging")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
@console_ns.response(400, "Invalid request parameters")
@console_ns.response(404, "Agent or conversation not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(current_user=current_user, app_model=app_model)
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
@ -224,79 +245,12 @@ class ChatMessageStopApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user_id
def post(self, current_user_id: str, app_model: App, task_id: str):
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
class AgentChatMessageStopApi(Resource):
@console_ns.doc("stop_agent_chat_message")
@console_ns.doc(description="Stop a running Agent App chat message generation")
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
def _create_chat_message(*, current_user: Account, app_model: App):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
args["response_mode"] = "streaming"
args["auto_generate_name"] = False
external_trace_id = get_external_trace_id(request)
if external_trace_id:
args["external_trace_id"] = external_trace_id
try:
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
raise ConversationCompletedError()
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except InvokeError as e:
raise CompletionRequestError(e.description)
except ValueError as e:
raise e
except Exception as e:
logger.exception("internal server error.")
raise InternalServerError()
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
AppTaskService.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.DEBUGGER,
user_id=current_user_id,
app_mode=AppMode.value_of(app_model.mode),
)
return {"result": "success"}, 200
return {"result": "success"}, 200

View File

@ -13,7 +13,6 @@ from controllers.common.controller_schemas import MessageFeedbackPayload as _Mes
from controllers.common.fields import SimpleResultResponse, TextFileResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.app.error import (
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
@ -26,7 +25,6 @@ from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
@ -185,25 +183,67 @@ class ChatMessageListApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@edit_permission_required
def get(self, app_model: App):
return _list_chat_messages(app_model=app_model)
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
class AgentChatMessageListApi(Resource):
@console_ns.doc("list_agent_chat_messages")
@console_ns.doc(description="Get Agent App chat messages for a conversation with pagination")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
@console_ns.response(404, "Agent or conversation not found")
@login_required
@account_initialization_required
@setup_required
@edit_permission_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
)
if not first_message:
raise NotFound("First message not found")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
@ -221,25 +261,44 @@ class MessageFeedbackApi(Resource):
@account_initialization_required
@with_current_user
def post(self, current_user: Account, app_model: App):
return _update_message_feedback(current_user=current_user, app_model=app_model)
args = MessageFeedbackPayload.model_validate(console_ns.payload)
message_id = str(args.message_id)
@console_ns.route("/agent/<uuid:agent_id>/feedbacks")
class AgentMessageFeedbackApi(Resource):
@console_ns.doc("create_agent_message_feedback")
@console_ns.doc(description="Create or update Agent App message feedback")
@console_ns.doc(params={"agent_id": "Agent ID"})
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _update_message_feedback(current_user=current_user, app_model=app_model)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
@console_ns.route("/apps/<uuid:app_id>/annotations/count")
@ -281,28 +340,31 @@ class MessageSuggestedQuestionApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@with_current_user
def get(self, current_user: Account, app_model: App, message_id: UUID):
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions")
class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc("get_agent_message_suggested_questions")
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
return {"data": questions}
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
@ -361,167 +423,14 @@ class MessageApi(Resource):
@login_required
@account_initialization_required
def get(self, app_model: App, message_id: UUID):
return _get_message_detail(app_model=app_model, message_id=message_id)
message_id_str = str(message_id)
@console_ns.route("/agent/<uuid:agent_id>/messages/<uuid:message_id>")
class AgentMessageApi(Resource):
@console_ns.doc("get_agent_message")
@console_ns.doc(description="Get Agent App message details by ID")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
@console_ns.response(404, "Agent or message not found")
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _get_message_detail(app_model=app_model, message_id=message_id)
def _list_chat_messages(*, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
if args.first_id:
first_message = db.session.scalar(
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
)
if not first_message:
raise NotFound("First message not found")
if not message:
raise NotFound("Message Not Exists.")
history_messages = db.session.scalars(
select(Message)
.where(
Message.conversation_id == conversation.id,
Message.created_at < first_message.created_at,
Message.id != first_message.id,
)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
else:
history_messages = db.session.scalars(
select(Message)
.where(Message.conversation_id == conversation.id)
.order_by(Message.created_at.desc())
.limit(args.limit)
).all()
# Initialize has_more based on whether we have a full page
if len(history_messages) == args.limit:
current_page_first_message = history_messages[-1]
# Check if there are more messages before the current page
has_more = db.session.scalar(
select(
exists().where(
Message.conversation_id == conversation.id,
Message.created_at < current_page_first_message.created_at,
Message.id != current_page_first_message.id,
)
)
)
else:
# If we don't have a full page, there are no more messages
has_more = False
history_messages = list(reversed(history_messages))
attach_message_extra_contents(history_messages)
return MessageInfiniteScrollPaginationResponse.model_validate(
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
from_attributes=True,
).model_dump(mode="json")
def _update_message_feedback(*, current_user: Account, app_model: App):
args = MessageFeedbackPayload.model_validate(console_ns.payload)
message_id = str(args.message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
feedback = message.admin_feedback
if not args.rating and feedback:
db.session.delete(feedback)
elif args.rating and feedback:
feedback.rating = FeedbackRating(args.rating)
feedback.content = args.content
elif not args.rating and not feedback:
raise ValueError("rating cannot be None when feedback not exists")
else:
rating_value = args.rating
if rating_value is None:
raise ValueError("rating is required to create feedback")
feedback = MessageFeedback(
app_id=app_model.id,
conversation_id=message.conversation_id,
message_id=message.id,
rating=FeedbackRating(rating_value),
content=args.content,
from_source=FeedbackFromSource.ADMIN,
from_account_id=current_user.id,
)
db.session.add(feedback)
db.session.commit()
return {"result": "success"}
def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
)
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
raise NotFound("Conversation not found")
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
raise ProviderQuotaExceededError()
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except Exception:
logger.exception("internal server error.")
raise InternalServerError()
return {"data": questions}
def _get_message_detail(*, app_model: App, message_id: UUID):
message_id_str = str(message_id)
message = db.session.scalar(
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
)
if not message:
raise NotFound("Message Not Exists.")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
attach_message_extra_contents([message])
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")

View File

@ -2,12 +2,12 @@ import json
import logging
from collections.abc import Sequence
from datetime import datetime
from typing import Any, NotRequired, TypedDict, cast
from typing import Any, NotRequired, TypedDict
from flask import abort, request
from flask_restx import Resource, fields
from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
import services
@ -449,16 +449,8 @@ class DraftWorkflowApi(Resource):
if not workflow:
raise DraftWorkflowNotExist()
from services.agent.workflow_publish_service import WorkflowAgentPublishService
# Return workflow with response-only Agent node job projection so the
# front-end can treat draft graph node data as the editing source.
response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
session=cast(Session, db.session),
draft_workflow=workflow,
)
return response
# return workflow, if not found, return 404
return dump_response(WorkflowResponse, workflow)
@setup_required
@login_required

View File

@ -1,7 +1,6 @@
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from configs import dify_config
from constants.languages import supported_language
@ -12,8 +11,7 @@ from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from models import AccountStatus
from models.account import TenantAccountJoin, TenantAccountRole
from services.account_service import RegisterService, TenantService
from services.account_service import RegisterService
from services.billing_service import BillingService
@ -27,22 +25,18 @@ class ActivatePayload(BaseModel):
workspace_id: str | None = Field(default=None)
email: EmailStr | None = Field(default=None)
token: str
name: str | None = Field(default=None, max_length=30)
interface_language: str | None = Field(default=None)
timezone: str | None = Field(default=None)
name: str = Field(..., max_length=30)
interface_language: str = Field(...)
timezone: str = Field(...)
@field_validator("interface_language")
@classmethod
def validate_lang(cls, value: str | None) -> str | None:
if value is None:
return None
def validate_lang(cls, value: str) -> str:
return supported_language(value)
@field_validator("timezone")
@classmethod
def validate_tz(cls, value: str | None) -> str | None:
if value is None:
return None
def validate_tz(cls, value: str) -> str:
return timezone(value)
@ -54,8 +48,6 @@ class ActivationCheckData(BaseModel):
workspace_name: str | None
workspace_id: str | None
email: str | None
account_status: str | None = None
requires_setup: bool | None = None
class ActivationCheckResponse(BaseModel):
@ -103,20 +95,9 @@ class ActivateCheckApi(Resource):
workspace_name = tenant.name if tenant else None
workspace_id = tenant.id if tenant else None
invitee_email = data.get("email") if data else None
account = invitation.get("account")
account_status = account.status if account else None
requires_setup = data.get("requires_setup")
if requires_setup is None:
requires_setup = account_status == AccountStatus.PENDING
return {
"is_valid": invitation is not None,
"data": {
"workspace_name": workspace_name,
"workspace_id": workspace_id,
"email": invitee_email,
"account_status": account_status,
"requires_setup": requires_setup,
},
"data": {"workspace_name": workspace_name, "workspace_id": workspace_id, "email": invitee_email},
}
else:
return {"is_valid": False}
@ -145,45 +126,15 @@ class ActivateApi(Resource):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()
tenant = invitation["tenant"]
raw_role = invitation["data"].get("role")
try:
role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL
except ValueError:
role = TenantAccountRole.NORMAL
if not TenantAccountRole.is_non_owner_role(role):
role = TenantAccountRole.NORMAL
membership_id = db.session.scalar(
select(TenantAccountJoin.id).where(
TenantAccountJoin.tenant_id == tenant.id,
TenantAccountJoin.account_id == account.id,
)
)
requires_setup = invitation["data"].get("requires_setup")
if requires_setup is None:
requires_setup = account.status == AccountStatus.PENDING
setup_fields: tuple[str, str, str] | None = None
if requires_setup:
if not args.name or not args.interface_language or not args.timezone:
raise AlreadyActivateError()
setup_fields = (args.name, args.interface_language, args.timezone)
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
if membership_id is None:
TenantService.create_tenant_member(tenant, account, str(role))
account.name = args.name
if setup_fields:
account.name = setup_fields[0]
account.interface_language = setup_fields[1]
account.timezone = setup_fields[2]
account.interface_theme = "light"
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
TenantService.switch_tenant(account, tenant.id)
account.interface_language = args.interface_language
account.timezone = args.timezone
account.interface_theme = "light"
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
db.session.commit()
return {"result": "success"}

View File

@ -409,7 +409,6 @@ class DatasetListApi(Resource):
datasets, total = DatasetService.get_datasets(
query.page,
query.limit,
db.session,
current_tenant_id,
current_user,
query.keyword,

View File

@ -23,26 +23,17 @@ from libs.login import resolve_account_fallback
from models.account import Account
from models.dataset import Dataset
from services.dataset_service import DatasetService
from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
from services.hit_testing_service import HitTestingService
logger = logging.getLogger(__name__)
class HitTestingPayload(BaseModel):
query: str = Field(description="Search query text.", max_length=250)
retrieval_model: RetrievalModel | None = Field(
default=None,
description="Retrieval model configuration. Controls how chunks are searched and ranked.",
)
external_retrieval_model: ExternalRetrievalModel = Field(
default=None,
description="Retrieval settings for external knowledge bases.",
)
attachment_ids: list[str] | None = Field(
default=None,
description="List of attachment IDs to include in the retrieval context.",
)
query: str = Field(max_length=250)
retrieval_model: RetrievalModel | None = None
external_retrieval_model: dict[str, Any] | None = Field(default=None)
attachment_ids: list[str] | None = None
class DatasetsHitTestingBase:

View File

@ -994,7 +994,7 @@ class RagPipelineTransformApi(Resource):
dataset_id_str = str(dataset_id)
rag_pipeline_transform_service = RagPipelineTransformService()
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str, db.session)
result = rag_pipeline_transform_service.transform_dataset(dataset_id_str)
return result

View File

@ -73,12 +73,9 @@ def _published_app_filter():
has_published_workflow = exists(select(Workflow.id).where(Workflow.id == App.workflow_id))
has_published_model_config = exists(select(AppModelConfig.id).where(AppModelConfig.id == App.app_model_config_id))
return and_(
App.mode != AppMode.AGENT,
or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
),
return or_(
and_(App.mode.in_(workflow_app_modes), App.workflow_id.isnot(None), has_published_workflow),
and_(~App.mode.in_(workflow_app_modes), App.app_model_config_id.isnot(None), has_published_model_config),
)

View File

@ -9,7 +9,6 @@ from constants.languages import languages
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import account_initialization_required, with_current_user
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import build_icon_url
from libs.login import login_required
@ -66,10 +65,6 @@ class RecommendedAppListResponse(ResponseModel):
categories: list[str]
class LearnDifyAppListResponse(ResponseModel):
recommended_apps: list[RecommendedAppResponse]
class RecommendedAppDetailResponse(RootModel[dict[str, Any]]):
root: dict[str, Any]
@ -80,19 +75,10 @@ register_schema_models(
RecommendedAppInfoResponse,
RecommendedAppResponse,
RecommendedAppListResponse,
LearnDifyAppListResponse,
)
register_response_schema_models(console_ns, RecommendedAppDetailResponse)
def _resolve_language(language: str | None, user: Account) -> str:
if language and language in languages:
return language
if user.interface_language:
return user.interface_language
return languages[0]
@console_ns.route("/explore/apps")
class RecommendedAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@ -103,27 +89,16 @@ class RecommendedAppListApi(Resource):
def get(self, current_user: Account):
# language args
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
language = args.language
if language and language in languages:
language_prefix = language
elif current_user.interface_language:
language_prefix = current_user.interface_language
else:
language_prefix = languages[0]
return RecommendedAppListResponse.model_validate(
RecommendedAppService.get_recommended_apps_and_categories(db.session, language_prefix),
from_attributes=True,
).model_dump(mode="json")
@console_ns.route("/explore/apps/learn-dify")
class LearnDifyAppListApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAppsQuery))
@console_ns.response(200, "Success", console_ns.models[LearnDifyAppListResponse.__name__])
@login_required
@account_initialization_required
@with_current_user
def get(self, current_user: Account):
args = RecommendedAppsQuery.model_validate(request.args.to_dict(flat=True))
language_prefix = _resolve_language(args.language, current_user)
return LearnDifyAppListResponse.model_validate(
RecommendedAppService.get_learn_dify_apps(db.session, language_prefix),
RecommendedAppService.get_recommended_apps_and_categories(language_prefix),
from_attributes=True,
).model_dump(mode="json")
@ -134,4 +109,4 @@ class RecommendedAppApi(Resource):
@login_required
@account_initialization_required
def get(self, app_id: UUID):
return RecommendedAppService.get_recommend_app_detail(db.session, str(app_id))
return RecommendedAppService.get_recommend_app_detail(str(app_id))

View File

@ -223,7 +223,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
@ -296,7 +296,7 @@ class TrialChatApi(TrialAppResource):
response = AppGenerateService.generate(
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=True
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
@ -373,7 +373,7 @@ class TrialChatAudioApi(TrialAppResource):
user_id = current_user.id
response = AudioService.transcript_asr(app_model=app_model, file=file, end_user=None)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@ -420,7 +420,7 @@ class TrialChatTextApi(TrialAppResource):
user_id = current_user.id
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@ -473,7 +473,7 @@ class TrialCompletionApi(TrialAppResource):
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.EXPLORE, streaming=streaming
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
RecommendedAppService.add_trial_app_record(app_id, user_id)
return helper.compact_generate_response(response)
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")

View File

@ -16,7 +16,6 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from models import Account
@ -102,7 +101,7 @@ class TagListApi(Resource):
def get(self, current_tenant_id: str):
raw_args = request.args.to_dict()
param = TagListQueryParam.model_validate(raw_args)
tags = TagService.get_tags(db.session(), param.type, current_tenant_id, param.keyword)
tags = TagService.get_tags(param.type, current_tenant_id, param.keyword)
serialized_tags = [
TagResponse.model_validate(tag, from_attributes=True).model_dump(mode="json") for tag in tags
@ -122,7 +121,7 @@ class TagListApi(Resource):
raise Forbidden()
payload = TagBasePayload.model_validate(console_ns.payload or {})
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type))
response = TagResponse.model_validate(
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
@ -146,9 +145,9 @@ class TagUpdateDeleteApi(Resource):
raise Forbidden()
payload = TagUpdateRequestPayload.model_validate(console_ns.payload or {})
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str)
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
binding_count = TagService.get_tag_binding_count(tag_id_str)
response = TagResponse.model_validate(
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
@ -164,7 +163,7 @@ class TagUpdateDeleteApi(Resource):
def delete(self, tag_id: UUID):
tag_id_str = str(tag_id)
TagService.delete_tag(tag_id_str, db.session)
TagService.delete_tag(tag_id_str)
return "", 204
@ -189,8 +188,7 @@ def _create_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session,
)
)
return {"result": "success"}, 200
@ -204,8 +202,7 @@ def _remove_tag_bindings(current_user: Account) -> tuple[dict[str, str], int]:
tag_ids=payload.tag_ids,
target_id=payload.target_id,
type=payload.type,
),
db.session,
)
)
return {"result": "success"}, 200

View File

@ -232,11 +232,7 @@ class MemberInviteEmailApi(Resource):
)
except AccountAlreadyInTenantError:
invitation_results.append(
{
"status": "already_member",
"email": invitee_email,
"message": "Account already in workspace.",
}
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
)
except Exception as e:
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})

View File

@ -1,11 +1,10 @@
import io
from collections.abc import Mapping
from datetime import datetime
from typing import Any, Literal, TypedDict
from typing import Any, Literal
from flask import request, send_file
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, RootModel
from pydantic import BaseModel, Field, RootModel
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
@ -27,33 +26,15 @@ from controllers.console.wraps import (
with_current_user,
with_current_user_id,
)
from core.helper.position_helper import is_filtered
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
from core.plugin.impl.exc import PluginDaemonClientSideError
from core.plugin.plugin_service import PluginService
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
from core.tools.entities.common_entities import I18nObject
from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from fields.base import ResponseModel
from graphon.model_runtime.utils.encoders import jsonable_encoder
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account, TenantPluginAutoUpgradeStrategy, TenantPluginPermission
from models.provider_ids import ToolProviderID
from services.entities.model_provider_entities import ProviderEntityResponse
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
from services.plugin.plugin_parameter_service import PluginParameterService
from services.plugin.plugin_permission_service import PluginPermissionService
from services.tools.tools_transform_service import ToolTransformService
class AutoUpgradeSettingsResponse(TypedDict):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class ParserList(BaseModel):
@ -61,11 +42,6 @@ class ParserList(BaseModel):
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class PluginCategoryListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number")
page_size: int = Field(default=256, ge=1, le=256, description="Page size (1-256)")
class ParserLatest(BaseModel):
plugin_ids: list[str]
@ -124,8 +100,8 @@ class ParserUninstall(BaseModel):
class ParserPermissionChange(BaseModel):
install_permission: TenantPluginPermission.InstallPermission = TenantPluginPermission.InstallPermission.EVERYONE
debug_permission: TenantPluginPermission.DebugPermission = TenantPluginPermission.DebugPermission.EVERYONE
install_permission: TenantPluginPermission.InstallPermission
debug_permission: TenantPluginPermission.DebugPermission
class ParserDynamicOptions(BaseModel):
@ -161,64 +137,13 @@ class PluginAutoUpgradeSettingsPayload(BaseModel):
include_plugins: list[str] = Field(default_factory=list)
class PluginAutoUpgradeChangeResponse(ResponseModel):
success: bool
message: str | None = None
class PluginAutoUpgradeSettingsResponseModel(ResponseModel):
strategy_setting: TenantPluginAutoUpgradeStrategy.StrategySetting
upgrade_time_of_day: int
upgrade_mode: TenantPluginAutoUpgradeStrategy.UpgradeMode
exclude_plugins: list[str]
include_plugins: list[str]
class PluginAutoUpgradeFetchResponse(ResponseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
auto_upgrade: PluginAutoUpgradeSettingsResponseModel
class PluginDeclarationResponse(ResponseModel):
version: str
author: str | None
name: str
description: I18nObject
icon: str
icon_dark: str | None = None
label: I18nObject
category: PluginCategory
created_at: datetime
resource: Mapping[str, Any]
plugins: Mapping[str, list[str] | None]
tags: list[str] = Field(default_factory=list)
repo: str | None = None
verified: bool = False
tool: Mapping[str, Any] | None = None
model: ProviderEntityResponse | None = None
endpoint: Mapping[str, Any] | None = None
agent_strategy: Mapping[str, Any] | None = None
datasource: Mapping[str, Any] | None = None
trigger: Mapping[str, Any] | None = None
meta: Mapping[str, Any]
class ParserAutoUpgradeChange(BaseModel):
model_config = ConfigDict(extra="forbid")
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserPreferencesChange(BaseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class ParserAutoUpgradeFetch(BaseModel):
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserExcludePlugin(BaseModel):
model_config = ConfigDict(extra="forbid")
plugin_id: str
category: TenantPluginAutoUpgradeStrategy.PluginCategory
class ParserReadme(BaseModel):
@ -232,63 +157,6 @@ class PluginDebuggingKeyResponse(ResponseModel):
port: int
class PluginCategoryInstalledPluginResponse(ResponseModel):
id: str
name: str
tenant_id: str
plugin_id: str
plugin_unique_identifier: str
endpoints_active: int
endpoints_setups: int
installation_id: str
declaration: PluginDeclarationResponse
runtime_type: str
version: str
created_at: datetime
updated_at: datetime
source: PluginInstallationSource
checksum: str
meta: Mapping[str, Any]
class PluginCategoryBuiltinToolResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
author: str
name: str
label: I18nObject
description: I18nObject
parameters: list[Mapping[str, Any]] | None = None
labels: list[str]
output_schema: Mapping[str, object]
class PluginCategoryBuiltinToolProviderResponse(ResponseModel):
model_config = ConfigDict(extra="allow")
id: str
author: str
name: str
plugin_id: str | None
plugin_unique_identifier: str | None
description: I18nObject
icon: str | Mapping[str, str]
icon_dark: str | Mapping[str, str] | None
label: I18nObject
type: ToolProviderType
team_credentials: Mapping[str, object]
is_team_authorization: bool
allow_delete: bool
tools: list[PluginCategoryBuiltinToolResponse]
labels: list[str]
class PluginCategoryListResponse(ResponseModel):
plugins: list[PluginCategoryInstalledPluginResponse]
builtin_tools: list[PluginCategoryBuiltinToolProviderResponse]
has_more: bool
class PluginDaemonOperationResponse(RootModel[Any]):
root: Any
@ -332,6 +200,11 @@ class PluginOperationSuccessResponse(ResponseModel):
message: str | None = None
class PluginPreferencesResponse(ResponseModel):
permission: PluginPermissionSettingsPayload
auto_upgrade: PluginAutoUpgradeSettingsPayload
class PluginReadmeResponse(ResponseModel):
readme: str
@ -339,7 +212,6 @@ class PluginReadmeResponse(ResponseModel):
register_schema_models(
console_ns,
ParserList,
PluginCategoryListQuery,
PluginAutoUpgradeSettingsPayload,
PluginPermissionSettingsPayload,
ParserLatest,
@ -356,21 +228,13 @@ register_schema_models(
ParserPermissionChange,
ParserDynamicOptions,
ParserDynamicOptionsWithCredentials,
ParserAutoUpgradeChange,
ParserAutoUpgradeFetch,
ParserPreferencesChange,
ParserExcludePlugin,
ParserReadme,
)
register_response_schema_models(
console_ns,
PluginAutoUpgradeChangeResponse,
PluginAutoUpgradeFetchResponse,
PluginAutoUpgradeSettingsResponseModel,
BinaryFileResponse,
PluginCategoryBuiltinToolProviderResponse,
PluginCategoryBuiltinToolResponse,
PluginCategoryInstalledPluginResponse,
PluginCategoryListResponse,
PluginDaemonOperationResponse,
PluginDebuggingKeyResponse,
PluginDynamicOptionsResponse,
@ -379,6 +243,7 @@ register_response_schema_models(
PluginManifestResponse,
PluginOperationSuccessResponse,
PluginPermissionResponse,
PluginPreferencesResponse,
PluginReadmeResponse,
PluginTaskResponse,
PluginTasksResponse,
@ -389,36 +254,12 @@ register_response_schema_models(
register_enum_models(
console_ns,
TenantPluginPermission.DebugPermission,
TenantPluginAutoUpgradeStrategy.PluginCategory,
TenantPluginAutoUpgradeStrategy.UpgradeMode,
TenantPluginAutoUpgradeStrategy.StrategySetting,
TenantPluginPermission.InstallPermission,
)
def _default_auto_upgrade_settings(
tenant_id: str,
category: TenantPluginAutoUpgradeStrategy.PluginCategory,
) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": PluginAutoUpgradeService.default_strategy_setting_for_category(category),
"upgrade_time_of_day": PluginAutoUpgradeService.default_upgrade_time_of_day(tenant_id),
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
def _auto_upgrade_settings_to_dict(strategy: TenantPluginAutoUpgradeStrategy) -> AutoUpgradeSettingsResponse:
return {
"strategy_setting": strategy.strategy_setting,
"upgrade_time_of_day": strategy.upgrade_time_of_day,
"upgrade_mode": strategy.upgrade_mode,
"exclude_plugins": strategy.exclude_plugins,
"include_plugins": strategy.include_plugins,
}
def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
"""
Read the uploaded file and validate its actual size before delegating to the plugin service.
@ -433,33 +274,6 @@ def _read_upload_content(file: FileStorage, max_size: int) -> bytes:
return content
def _list_hardcoded_builtin_tool_providers(tenant_id: str) -> list[dict[str, Any]]:
db_builtin_providers = {
str(ToolProviderID(provider.provider)): provider
for provider in ToolManager.list_default_builtin_providers(tenant_id)
}
builtin_providers = []
for provider in ToolManager.list_hardcoded_providers():
if is_filtered(
include_set=dify_config.POSITION_TOOL_INCLUDES_SET,
exclude_set=dify_config.POSITION_TOOL_EXCLUDES_SET,
data=provider,
name_func=lambda provider_controller: provider_controller.entity.identity.name,
):
continue
user_provider = ToolTransformService.builtin_provider_to_user_provider(
provider_controller=provider,
db_provider=db_builtin_providers.get(provider.entity.identity.name),
decrypt_credentials=False,
)
ToolTransformService.repack_provider(tenant_id=tenant_id, provider=user_provider)
builtin_providers.append(user_provider)
return [provider.to_dict() for provider in BuiltinToolProviderSort.sort(builtin_providers)]
@console_ns.route("/workspaces/current/plugin/debugging-key")
class PluginDebuggingKeyApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginDebuggingKeyResponse.__name__])
@ -498,41 +312,6 @@ class PluginListApi(Resource):
return jsonable_encoder({"plugins": plugins_with_total.list, "total": plugins_with_total.total})
@console_ns.route("/workspaces/current/plugin/<string:category>/list")
class PluginCategoryListApi(Resource):
@console_ns.doc(params=query_params_from_model(PluginCategoryListQuery))
@console_ns.response(200, "Success", console_ns.models[PluginCategoryListResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, category: str):
args = PluginCategoryListQuery.model_validate(request.args.to_dict(flat=True))
try:
plugin_category = PluginCategory(category)
except ValueError:
return {"code": "invalid_param", "message": "invalid plugin category"}, 400
try:
plugins = PluginService.list_by_category(tenant_id, plugin_category, args.page, args.page_size)
except PluginDaemonClientSideError as e:
return {"code": "plugin_error", "message": e.description}, 400
builtin_tools = []
if plugin_category == PluginCategory.Tool:
builtin_tools = _list_hardcoded_builtin_tool_providers(tenant_id)
return dump_response(
PluginCategoryListResponse,
{
"plugins": jsonable_encoder(plugins.list),
"builtin_tools": builtin_tools,
"has_more": plugins.has_more,
},
)
@console_ns.route("/workspaces/current/plugin/list/latest-versions")
class PluginListLatestVersionsApi(Resource):
@console_ns.expect(console_ns.models[ParserLatest.__name__])
@ -934,13 +713,11 @@ class PluginChangePermissionApi(Resource):
args = ParserPermissionChange.model_validate(console_ns.payload)
set_permission_result = PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
return jsonable_encoder({"success": True})
return {
"success": PluginPermissionService.change_permission(
tenant_id, args.install_permission, args.debug_permission
)
}
@console_ns.route("/workspaces/current/plugin/permission/fetch")
@ -1029,10 +806,10 @@ class PluginFetchDynamicSelectOptionsWithCredentialsApi(Resource):
return jsonable_encoder({"options": options})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/change")
class PluginChangeAutoUpgradeApi(Resource):
@console_ns.expect(console_ns.models[ParserAutoUpgradeChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeChangeResponse.__name__])
@console_ns.route("/workspaces/current/plugin/preferences/change")
class PluginChangePreferencesApi(Resource):
@console_ns.expect(console_ns.models[ParserPreferencesChange.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -1042,17 +819,38 @@ class PluginChangeAutoUpgradeApi(Resource):
if not user.is_admin_or_owner:
raise Forbidden()
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
args = ParserPreferencesChange.model_validate(console_ns.payload)
permission = args.permission
install_permission = permission.install_permission
debug_permission = permission.debug_permission
auto_upgrade = args.auto_upgrade
strategy_setting = auto_upgrade.strategy_setting
upgrade_time_of_day = auto_upgrade.upgrade_time_of_day
upgrade_mode = auto_upgrade.upgrade_mode
exclude_plugins = auto_upgrade.exclude_plugins
include_plugins = auto_upgrade.include_plugins
# set permission
set_permission_result = PluginPermissionService.change_permission(
tenant_id,
install_permission,
debug_permission,
)
if not set_permission_result:
return jsonable_encoder({"success": False, "message": "Failed to set permission"})
# set auto upgrade strategy
set_auto_upgrade_strategy_result = PluginAutoUpgradeService.change_strategy(
tenant_id,
auto_upgrade.strategy_setting,
auto_upgrade.upgrade_time_of_day,
auto_upgrade.upgrade_mode,
auto_upgrade.exclude_plugins,
auto_upgrade.include_plugins,
category=args.category,
strategy_setting,
upgrade_time_of_day,
upgrade_mode,
exclude_plugins,
include_plugins,
)
if not set_auto_upgrade_strategy_result:
return jsonable_encoder({"success": False, "message": "Failed to set auto upgrade strategy"})
@ -1060,35 +858,49 @@ class PluginChangeAutoUpgradeApi(Resource):
return jsonable_encoder({"success": True})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/fetch")
class PluginFetchAutoUpgradeApi(Resource):
@console_ns.doc(params=query_params_from_model(ParserAutoUpgradeFetch))
@console_ns.response(200, "Success", console_ns.models[PluginAutoUpgradeFetchResponse.__name__])
@console_ns.route("/workspaces/current/plugin/preferences/fetch")
class PluginFetchPreferencesApi(Resource):
@console_ns.response(200, "Success", console_ns.models[PluginPreferencesResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str):
args = ParserAutoUpgradeFetch.model_validate(request.args.to_dict(flat=True))
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id, args.category)
auto_upgrade_dict = (
_auto_upgrade_settings_to_dict(auto_upgrade)
if auto_upgrade
else _default_auto_upgrade_settings(tenant_id, args.category)
)
permission = PluginPermissionService.get_permission(tenant_id)
permission_dict = {
"install_permission": TenantPluginPermission.InstallPermission.EVERYONE,
"debug_permission": TenantPluginPermission.DebugPermission.EVERYONE,
}
return jsonable_encoder(
{
"category": args.category,
"auto_upgrade": auto_upgrade_dict,
if permission:
permission_dict["install_permission"] = permission.install_permission
permission_dict["debug_permission"] = permission.debug_permission
auto_upgrade = PluginAutoUpgradeService.get_strategy(tenant_id)
auto_upgrade_dict = {
"strategy_setting": TenantPluginAutoUpgradeStrategy.StrategySetting.DISABLED,
"upgrade_time_of_day": 0,
"upgrade_mode": TenantPluginAutoUpgradeStrategy.UpgradeMode.EXCLUDE,
"exclude_plugins": [],
"include_plugins": [],
}
if auto_upgrade:
auto_upgrade_dict = {
"strategy_setting": auto_upgrade.strategy_setting,
"upgrade_time_of_day": auto_upgrade.upgrade_time_of_day,
"upgrade_mode": auto_upgrade.upgrade_mode,
"exclude_plugins": auto_upgrade.exclude_plugins,
"include_plugins": auto_upgrade.include_plugins,
}
)
return jsonable_encoder({"permission": permission_dict, "auto_upgrade": auto_upgrade_dict})
@console_ns.route("/workspaces/current/plugin/auto-upgrade/exclude")
@console_ns.route("/workspaces/current/plugin/preferences/autoupgrade/exclude")
class PluginAutoUpgradeExcludePluginApi(Resource):
@console_ns.expect(console_ns.models[ParserExcludePlugin.__name__])
@console_ns.response(200, "Success", console_ns.models[SuccessResponse.__name__])
@console_ns.response(200, "Success", console_ns.models[PluginOperationSuccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@ -1097,9 +909,7 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
# exclude one single plugin
args = ParserExcludePlugin.model_validate(console_ns.payload)
return jsonable_encoder(
{"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id, args.category)}
)
return jsonable_encoder({"success": PluginAutoUpgradeService.exclude_plugin(tenant_id, args.plugin_id)})
@console_ns.route("/workspaces/current/plugin/readme")

View File

@ -126,7 +126,6 @@ class CustomizedSnippetsApi(Resource):
snippet_service = _snippet_service()
snippets, total, has_more = snippet_service.get_snippets(
tenant_id=current_tenant_id,
session=db.session,
page=query.page,
limit=query.limit,
keyword=query.keyword,

View File

@ -31,9 +31,9 @@ from controllers.console.wraps import (
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import OptionalTimestampField, TimestampField, dump_response, to_timestamp
from libs.helper import TimestampField, dump_response, to_timestamp
from libs.login import login_required
from models.account import Account, Tenant, TenantAccountJoin, TenantCustomConfigDict, TenantStatus
from models.account import Account, Tenant, TenantCustomConfigDict, TenantStatus
from services.account_service import TenantService
from services.billing_service import BillingService, SubscriptionPlan
from services.enterprise.enterprise_service import EnterpriseService
@ -219,7 +219,6 @@ tenants_fields = {
"plan": fields.String,
"status": fields.String,
"created_at": TimestampField,
"last_opened_at": OptionalTimestampField,
"current": fields.Boolean,
}
@ -235,12 +234,7 @@ class TenantListApi(Resource):
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, current_user: Account):
tenant_rows: list[tuple[Tenant, TenantAccountJoin]] = [
(tenant, membership)
for tenant, membership in TenantService.get_workspaces_for_account(db.session, current_user.id)
if tenant.status == TenantStatus.NORMAL
]
tenants = [tenant for tenant, _ in tenant_rows]
tenants = TenantService.get_join_tenants(current_user)
tenant_dicts = []
is_enterprise_only = dify_config.ENTERPRISE_ENABLED and not dify_config.BILLING_ENABLED
is_saas = dify_config.EDITION == "CLOUD" and dify_config.BILLING_ENABLED
@ -253,7 +247,7 @@ class TenantListApi(Resource):
if not tenant_plans:
logger.warning("get_plan_bulk returned empty result, falling back to legacy feature path")
for tenant, membership in tenant_rows:
for tenant in tenants:
plan: str = CloudPlan.SANDBOX
if is_saas:
tenant_plan = tenant_plans.get(tenant.id)
@ -272,7 +266,6 @@ class TenantListApi(Resource):
"name": tenant.name,
"status": tenant.status,
"created_at": tenant.created_at,
"last_opened_at": membership.last_opened_at,
"plan": plan,
"current": tenant.id == current_tenant_id if current_tenant_id else False,
}

View File

@ -9,16 +9,14 @@ api = ExternalApi(
bp,
version="1.0",
title="Inner API",
description="Internal APIs for enterprise features, billing, knowledge retrieval, and plugin communication",
description="Internal APIs for enterprise features, billing, and plugin communication",
)
# Create namespace
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .app import dsl as _app_dsl
from .knowledge import retrieval as _knowledge_retrieval
from .plugin import agent_drive as _agent_drive
from .plugin import plugin as _plugin
from .workspace import workspace as _workspace
@ -28,10 +26,8 @@ api.add_namespace(inner_api_ns)
__all__ = [
"_agent_drive",
"_app_dsl",
"_knowledge_retrieval",
"_mail",
"_plugin",
"_runtime_credentials",
"_workspace",
"api",
"bp",

View File

@ -1 +0,0 @@
"""Inner knowledge retrieval endpoints."""

View File

@ -1,110 +0,0 @@
"""Inner API endpoint for tenant-scoped knowledge retrieval.
This controller is a thin HTTP wrapper around
``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``.
It intentionally keeps authorization simple: shared inner API key plus
tenant-scoped app/dataset validation in the service layer.
"""
from flask_restx import Resource
from pydantic import ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import inner_api_only
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
from libs.exception import BaseHTTPException
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError
from services.knowledge_retrieval_inner_service import InnerKnowledgeRetrievalService
class InnerKnowledgeRetrievalHttpError(BaseHTTPException):
error_code = "knowledge_retrieve_failed"
description = "Knowledge retrieval failed."
code = 500
def __init__(
self,
*,
error_code: str | None = None,
description: str | None = None,
status_code: int | None = None,
) -> None:
if error_code is not None:
self.error_code = error_code
if description is not None:
self.description = description
if status_code is not None:
self.code = status_code
super().__init__(self.description)
register_schema_models(inner_api_ns, InnerKnowledgeRetrieveRequest)
register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse)
@inner_api_ns.route("/knowledge/retrieve")
class InnerKnowledgeRetrieveApi(Resource):
"""Retrieve knowledge from one or more datasets within the caller tenant."""
@inner_api_only
@inner_api_ns.doc("inner_knowledge_retrieve")
@inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers")
@inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__])
@inner_api_ns.response(
200,
"Knowledge retrieved successfully",
inner_api_ns.models[InnerKnowledgeRetrieveResponse.__name__],
)
@inner_api_ns.doc(
responses={
400: "Invalid request body",
401: "Unauthorized - invalid inner API key",
403: "Caller tenant does not own the requested resource",
404: "App or dataset not found",
422: "Invalid retrieval configuration",
429: "Knowledge retrieval rate limited",
502: "External knowledge retrieval failed",
500: "Unexpected knowledge retrieval failure",
}
)
def post(self) -> dict[str, object]:
"""Validate the payload, run retrieval, and return workflow-style sources."""
try:
payload = InnerKnowledgeRetrieveRequest.model_validate(inner_api_ns.payload or {})
except ValidationError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code="invalid_request",
description=str(exc),
status_code=400,
) from exc
try:
response = InnerKnowledgeRetrievalService().retrieve(payload)
except InnerKnowledgeRetrievalServiceError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code=exc.error_code,
description=exc.description,
status_code=exc.status_code,
) from exc
except retrieval_exc.RateLimitExceededError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code="knowledge_rate_limited",
description=str(exc),
status_code=429,
) from exc
except ExternalKnowledgeRetrievalError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code="external_knowledge_failed",
description=str(exc),
status_code=502,
) from exc
except ValueError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code="retrieval_config_invalid",
description=str(exc),
status_code=422,
) from exc
return response.model_dump(mode="json", by_alias=True)

View File

@ -1,205 +0,0 @@
"""Inner API endpoints for runtime credential resolution.
Called by Enterprise while resolving AppRunner runtime artifacts. The endpoint
returns decrypted model and tool credentials for in-memory runtime use only.
"""
import json
import logging
from json import JSONDecodeError
from typing import Any
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import select
from sqlalchemy.orm import Session
from controllers.common.schema import register_schema_model
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import enterprise_inner_api_only
from core.helper import encrypter
from core.helper.provider_cache import ToolProviderCredentialsCache
from core.helper.provider_encryption import create_provider_encrypter
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.tools.tool_manager import ToolManager
from extensions.ext_database import db
from models.provider import ProviderCredential
from models.tools import BuiltinToolProvider
logger = logging.getLogger(__name__)
_KIND_MODEL = "model"
_KIND_TOOL = "tool"
# (body, status) pair returned by a resolver helper when resolution fails.
ResolveError = tuple[dict[str, str], int]
class InnerRuntimeCredentialResolveItem(BaseModel):
model_config = ConfigDict(extra="forbid")
credential_id: str = Field(description="Credential id")
provider: str = Field(description="Runtime provider identifier, for example langgenius/openai/openai")
kind: str = Field(description="Credential kind, either 'model' or 'tool'")
class InnerRuntimeCredentialsResolvePayload(BaseModel):
model_config = ConfigDict(extra="forbid")
tenant_id: str = Field(description="Workspace id")
credentials: list[InnerRuntimeCredentialResolveItem] = Field(default_factory=list)
register_schema_model(inner_api_ns, InnerRuntimeCredentialsResolvePayload)
@inner_api_ns.route("/enterprise/credentials/resolve")
class EnterpriseRuntimeCredentialsResolve(Resource):
@setup_required
@enterprise_inner_api_only
@inner_api_ns.doc(
"enterprise_runtime_credentials_resolve",
responses={
200: "Credentials resolved",
400: "Invalid request or credential config",
404: "Provider or credential not found",
},
)
@inner_api_ns.expect(inner_api_ns.models[InnerRuntimeCredentialsResolvePayload.__name__])
def post(self):
args = InnerRuntimeCredentialsResolvePayload.model_validate(inner_api_ns.payload or {})
if not args.credentials:
return {"credentials": []}, 200
# Model resolution shares one provider configuration set; build it lazily
# so a tool-only request never pays for the plugin daemon round trip.
model_configurations = None
resolved: list[dict[str, Any]] = []
for item in args.credentials:
if item.kind == _KIND_MODEL:
if model_configurations is None:
provider_manager = create_plugin_provider_manager(tenant_id=args.tenant_id)
model_configurations = provider_manager.get_configurations(args.tenant_id)
values, error = _resolve_model(args.tenant_id, model_configurations, item)
elif item.kind == _KIND_TOOL:
values, error = _resolve_tool(args.tenant_id, item)
else:
return {"message": f"unsupported credential kind '{item.kind}'"}, 400
if error is not None:
return error
resolved.append(
{
"credential_id": item.credential_id,
"kind": item.kind,
"provider": item.provider,
"values": values,
}
)
return {"credentials": resolved}, 200
def _resolve_model(
tenant_id: str, provider_configurations: Any, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
provider_configuration = provider_configurations.get(item.provider)
if provider_configuration is None:
return None, ({"message": f"provider '{item.provider}' not found"}, 404)
provider_schema = provider_configuration.provider.provider_credential_schema
secret_variables = provider_configuration.extract_secret_variables(
provider_schema.credential_form_schemas if provider_schema else []
)
with Session(db.engine) as session:
stmt = select(ProviderCredential).where(
ProviderCredential.id == item.credential_id,
ProviderCredential.tenant_id == tenant_id,
ProviderCredential.provider_name.in_(provider_configuration._get_provider_names()),
)
credential = session.execute(stmt).scalar_one_or_none()
if credential is None or not credential.encrypted_config:
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
values = json.loads(credential.encrypted_config)
except JSONDecodeError:
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
if not isinstance(values, dict):
return None, ({"message": f"credential '{item.credential_id}' has invalid config"}, 400)
for key in secret_variables:
value = values.get(key)
if value is None:
continue
try:
values[key] = encrypter.decrypt_token(tenant_id=tenant_id, token=value)
except Exception as exc:
logger.warning(
"failed to resolve runtime model credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None
def _resolve_tool(
tenant_id: str, item: InnerRuntimeCredentialResolveItem
) -> tuple[dict[str, Any] | None, ResolveError | None]:
try:
provider_controller = ToolManager.get_builtin_provider(item.provider, tenant_id)
except Exception as exc:
logger.warning(
"failed to load runtime tool provider",
extra={"provider": item.provider, "tenant_id": tenant_id, "error": type(exc).__name__},
)
return None, ({"message": f"tool provider '{item.provider}' not found"}, 404)
with Session(db.engine) as session:
stmt = select(BuiltinToolProvider).where(
BuiltinToolProvider.id == item.credential_id,
BuiltinToolProvider.provider == item.provider,
BuiltinToolProvider.tenant_id == tenant_id,
)
builtin_provider = session.execute(stmt).scalar_one_or_none()
if builtin_provider is None:
return None, ({"message": f"credential '{item.credential_id}' not found"}, 404)
try:
# Tool credentials are stored as a single encrypted dict; the secret
# fields are decided by the schema bound to this credential type.
provider_encrypter, _ = create_provider_encrypter(
tenant_id=tenant_id,
config=[
schema.to_basic_provider_config()
for schema in provider_controller.get_credentials_schema_by_type(builtin_provider.credential_type)
],
cache=ToolProviderCredentialsCache(
tenant_id=tenant_id, provider=item.provider, credential_id=builtin_provider.id
),
)
values = dict(provider_encrypter.decrypt(builtin_provider.credentials))
except Exception as exc:
logger.warning(
"failed to resolve runtime tool credential",
extra={
"credential_id": item.credential_id,
"provider": item.provider,
"tenant_id": tenant_id,
"error": type(exc).__name__,
},
)
return None, ({"message": f"credential '{item.credential_id}' decrypt failed"}, 400)
return values, None

View File

@ -8,39 +8,39 @@ from flask import abort, request
from configs import dify_config
from core.db.session_factory import session_factory
from libs.exception import BaseHTTPException
from models.model import EndUser
class InnerApiUnauthorizedError(BaseHTTPException):
error_code = "inner_api_unauthorized"
description = "Unauthorized."
code = 401
def inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Restrict access to callers authenticated with the shared inner API key."""
def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not dify_config.INNER_API:
abort(404)
# get header 'X-Inner-Api-Key'
inner_api_key = request.headers.get("X-Inner-Api-Key")
if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY:
raise InnerApiUnauthorizedError()
abort(401)
return view(*args, **kwargs)
return decorated
def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)
def enterprise_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
return inner_api_only(view)
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not dify_config.INNER_API:
abort(404)
# get header 'X-Inner-Api-Key'
inner_api_key = request.headers.get("X-Inner-Api-Key")
if not inner_api_key or inner_api_key != dify_config.INNER_API_KEY:
abort(401)
return view(*args, **kwargs)
return decorated
def enterprise_inner_api_user_auth[**P, R](view: Callable[P, R]) -> Callable[P, R]:

View File

@ -174,7 +174,7 @@ class AppListApi(Resource):
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
@ -191,7 +191,7 @@ class AppListApi(Resource):
openapi_visible=True,
)
pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, db.session)
pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params)
if pagination is None:
return empty

View File

@ -23,25 +23,20 @@ from services.annotation_service import (
class AnnotationCreatePayload(BaseModel):
question: str = Field(description="Annotation question.")
answer: str = Field(description="Annotation answer.")
question: str = Field(description="Annotation question")
answer: str = Field(description="Annotation answer")
class AnnotationReplyActionPayload(BaseModel):
score_threshold: float = Field(
description=(
"Minimum similarity score for an annotation to be considered a match. Higher values require closer matches."
),
json_schema_extra={"format": "float"},
)
embedding_provider_name: str = Field(description="Name of the embedding model provider.")
embedding_model_name: str = Field(description="Name of the embedding model to use for annotation matching.")
score_threshold: float = Field(description="Score threshold for annotation matching")
embedding_provider_name: str = Field(description="Embedding provider name")
embedding_model_name: str = Field(description="Embedding model name")
class AnnotationListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number for pagination.")
limit: int = Field(default=20, ge=1, description="Number of items per page.")
keyword: str = Field(default="", description="Keyword to filter annotations by question or answer content.")
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, description="Number of annotations per page")
keyword: str = Field(default="", description="Keyword to search annotations")
class AnnotationJobStatusResponse(ResponseModel):
@ -50,13 +45,6 @@ class AnnotationJobStatusResponse(ResponseModel):
error_msg: str | None = None
ANNOTATION_REPLY_ACTION_PARAM = {
"description": "Action to perform: `enable` or `disable`.",
"enum": ["enable", "disable"],
"type": "string",
}
register_schema_models(
service_api_ns,
AnnotationCreatePayload,
@ -70,22 +58,10 @@ register_response_schema_models(service_api_ns, AnnotationJobStatusResponse)
@service_api_ns.route("/apps/annotation-reply/<string:action>")
class AnnotationReplyActionApi(Resource):
@service_api_ns.doc(
summary="Configure Annotation Reply",
description=(
"Enables or disables the annotation reply feature. Requires embedding model configuration "
"when enabling. Executes asynchronously — use [Get Annotation Reply Job "
"Status](/api-reference/annotations/get-annotation-reply-job-status) to track progress."
),
tags=["Annotations"],
responses={
200: "Annotation reply settings task initiated.",
},
)
@service_api_ns.expect(service_api_ns.models[AnnotationReplyActionPayload.__name__])
@service_api_ns.doc("annotation_reply_action")
@service_api_ns.doc(description="Enable or disable annotation reply feature")
@service_api_ns.doc(params={"action": ANNOTATION_REPLY_ACTION_PARAM})
@service_api_ns.doc(params={"action": "Action to perform: 'enable' or 'disable'"})
@service_api_ns.doc(
responses={
200: "Action completed successfully",
@ -116,29 +92,9 @@ class AnnotationReplyActionApi(Resource):
@service_api_ns.route("/apps/annotation-reply/<string:action>/status/<uuid:job_id>")
class AnnotationReplyActionStatusApi(Resource):
@service_api_ns.doc(
summary="Get Annotation Reply Job Status",
description=(
"Retrieves the status of an asynchronous annotation reply configuration job started by "
"[Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply)."
),
tags=["Annotations"],
responses={
200: "Successfully retrieved task status.",
400: "`invalid_param` : The specified job does not exist.",
},
)
@service_api_ns.doc("get_annotation_reply_action_status")
@service_api_ns.doc(description="Get the status of an annotation reply action job")
@service_api_ns.doc(
params={
"action": ANNOTATION_REPLY_ACTION_PARAM,
"job_id": (
"Job ID returned by "
"[Configure Annotation Reply](/api-reference/annotations/configure-annotation-reply)."
),
}
)
@service_api_ns.doc(params={"action": "Action type", "job_id": "Job ID"})
@service_api_ns.doc(
responses={
200: "Job status retrieved successfully",
@ -171,14 +127,6 @@ class AnnotationReplyActionStatusApi(Resource):
@service_api_ns.route("/apps/annotations")
class AnnotationListApi(Resource):
@service_api_ns.doc(
summary="List Annotations",
description="Retrieves a paginated list of annotations for the application. Supports keyword search filtering.",
tags=["Annotations"],
responses={
200: "Successfully retrieved annotation list.",
},
)
@service_api_ns.doc("list_annotations")
@service_api_ns.doc(description="List annotations for the application")
@service_api_ns.doc(params=query_params_from_model(AnnotationListQuery))
@ -211,17 +159,6 @@ class AnnotationListApi(Resource):
)
return response.model_dump(mode="json")
@service_api_ns.doc(
summary="Create Annotation",
description=(
"Creates a new annotation. Annotations provide predefined question-answer pairs that the app "
"can match and return directly instead of generating a response."
),
tags=["Annotations"],
responses={
201: "Annotation created successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[AnnotationCreatePayload.__name__])
@service_api_ns.doc("create_annotation")
@service_api_ns.doc(description="Create a new annotation")
@ -248,20 +185,10 @@ class AnnotationListApi(Resource):
@service_api_ns.route("/apps/annotations/<uuid:annotation_id>")
class AnnotationUpdateDeleteApi(Resource):
@service_api_ns.doc(
summary="Update Annotation",
description="Updates the question and answer of an existing annotation.",
tags=["Annotations"],
responses={
200: "Annotation updated successfully.",
403: "`forbidden` : Insufficient permissions to edit annotations.",
404: "`not_found` : Annotation does not exist.",
},
)
@service_api_ns.expect(service_api_ns.models[AnnotationCreatePayload.__name__])
@service_api_ns.doc("update_annotation")
@service_api_ns.doc(description="Update an existing annotation")
@service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to update."})
@service_api_ns.doc(params={"annotation_id": "Annotation ID"})
@service_api_ns.doc(
responses={
200: "Annotation updated successfully",
@ -285,19 +212,9 @@ class AnnotationUpdateDeleteApi(Resource):
response = Annotation.model_validate(annotation, from_attributes=True)
return response.model_dump(mode="json")
@service_api_ns.doc(
summary="Delete Annotation",
description="Deletes an annotation and its associated hit history.",
tags=["Annotations"],
responses={
204: "Annotation deleted successfully.",
403: "`forbidden` : Insufficient permissions to edit annotations.",
404: "`not_found` : Annotation does not exist.",
},
)
@service_api_ns.doc("delete_annotation")
@service_api_ns.doc(description="Delete an annotation")
@service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to delete."})
@service_api_ns.doc(params={"annotation_id": "Annotation ID"})
@service_api_ns.doc(
responses={
204: "Annotation deleted successfully",

View File

@ -33,18 +33,6 @@ register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, App
class AppParameterApi(Resource):
"""Resource for app variables."""
@service_api_ns.doc(
summary="Get App Parameters",
description=(
"Retrieve the application's input form configuration, including feature switches, input "
"parameter names, types, and default values."
),
tags=["Applications"],
responses={
200: "Application parameters information.",
400: "`app_unavailable` : App unavailable or misconfigured.",
},
)
@service_api_ns.doc("get_app_parameters")
@service_api_ns.doc(description="Retrieve application input parameters and configuration")
@service_api_ns.doc(
@ -83,14 +71,6 @@ class AppParameterApi(Resource):
@service_api_ns.route("/meta")
class AppMetaApi(Resource):
@service_api_ns.doc(
summary="Get App Meta",
description="Retrieve metadata about this application, including tool icons and other configuration details.",
tags=["Applications"],
responses={
200: "Successfully retrieved application meta information.",
},
)
@service_api_ns.doc("get_app_meta")
@service_api_ns.doc(description="Get application metadata")
@service_api_ns.doc(
@ -112,14 +92,6 @@ class AppMetaApi(Resource):
@service_api_ns.route("/info")
class AppInfoApi(Resource):
@service_api_ns.doc(
summary="Get App Info",
description="Retrieve basic information about this application, including name, description, tags, and mode.",
tags=["Applications"],
responses={
200: "Basic information of the application.",
},
)
@service_api_ns.doc("get_app_info")
@service_api_ns.doc(description="Get basic application information")
@service_api_ns.doc(

View File

@ -20,7 +20,6 @@ from controllers.service_api.app.error import (
ProviderQuotaExceededError,
UnsupportedAudioTypeError,
)
from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
@ -40,40 +39,8 @@ register_response_schema_models(service_api_ns, AudioBinaryResponse, AudioTransc
@service_api_ns.route("/audio-to-text")
class AudioApi(Resource):
@service_api_ns.doc(
summary="Convert Audio to Text",
description=(
"Convert audio file to text. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, "
"`audio/wav`, and `audio/amr`. File size limit is `30 MB`."
),
tags=["TTS"],
responses={
200: "Successfully converted audio to text.",
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
"- `provider_not_support_speech_to_text` : Model provider does not support speech-to-text.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model does not support this operation.\n"
"- `completion_request_error` : Speech recognition request failed."
),
413: "`audio_too_large` : Audio file size exceeded the limit.",
415: "`unsupported_audio_type` : Audio type is not allowed.",
500: "`internal_server_error` : Internal server error.",
},
)
@service_api_ns.doc("audio_to_text")
@service_api_ns.doc(description="Convert audio to text using speech-to-text")
@service_api_ns.doc(
consumes=["multipart/form-data"],
params=multipart_file_params(
include_user=True,
file_description=(
"Audio file to transcribe. Supported MIME types: `audio/mp3`, `audio/mpga`, `audio/m4a`, "
"`audio/wav`, and `audio/amr`. File size limit is `30 MB`."
),
),
)
@service_api_ns.doc(
responses={
200: "Audio successfully transcribed",
@ -132,27 +99,7 @@ register_schema_model(service_api_ns, TextToAudioPayload)
@service_api_ns.route("/text-to-audio")
class TextApi(Resource):
@service_api_ns.doc(
summary="Convert Text to Audio",
description="Convert text to speech.",
tags=["TTS"],
responses={
200: (
"Returns the generated audio. Generator responses are streamed by the service as `audio/mpeg`; "
"otherwise the provider output is returned directly."
),
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model does not support this operation.\n"
"- `completion_request_error` : Text-to-speech request failed."
),
500: "`internal_server_error` : Internal server error.",
},
)
@expect_with_user(service_api_ns, TextToAudioPayload)
@binary_response(service_api_ns, "audio/mpeg")
@service_api_ns.expect(service_api_ns.models[TextToAudioPayload.__name__])
@service_api_ns.doc("text_to_audio")
@service_api_ns.doc(description="Convert text to audio using text-to-speech")
@service_api_ns.doc(
@ -163,7 +110,11 @@ class TextApi(Resource):
500: "Internal server error",
}
)
@service_api_ns.response(200, "Text successfully converted to audio")
@service_api_ns.response(
200,
"Text successfully converted to audio",
service_api_ns.models[AudioBinaryResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON))
def post(self, app_model: App, end_user: EndUser):
"""Convert text to audio using text-to-speech.

View File

@ -5,7 +5,6 @@ from uuid import UUID
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from pydantic.json_schema import SkipJsonSchema
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
@ -21,12 +20,6 @@ from controllers.service_api.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.service_api.schema import (
InputFileList,
expect_user_json,
expect_with_user,
json_or_event_stream_response,
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
@ -58,84 +51,24 @@ def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None
class CompletionRequestPayload(BaseModel):
inputs: dict[str, Any] = Field(
description=(
"Values for app-defined variables. Refer to the `user_input_form` field in the "
"[Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected "
"variable names and types."
)
)
query: str = Field(default="", description="User input or prompt content.")
files: InputFileList = Field(
default=None,
description=(
"File list for multimodal understanding, including images, documents, audio, and video. To attach a "
"local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned "
"`id` as `upload_file_id` with `transfer_method: local_file`."
),
)
response_mode: Literal["blocking", "streaming"] | None = Field(
default=None,
description=(
"Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. When omitted, "
"the request runs in blocking mode."
),
)
retriever_from: SkipJsonSchema[str] = Field(default="dev")
trace_session_id: SkipJsonSchema[str | None] = Field(
default=None, description="Trace session ID for observability grouping"
)
inputs: dict[str, Any]
query: str = Field(default="")
files: list[dict[str, Any]] | None = Field(default=None)
response_mode: Literal["blocking", "streaming"] | None = None
retriever_from: str = Field(default="dev")
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
class ChatRequestPayload(BaseModel):
inputs: dict[str, Any] = Field(
description=(
"Values for app-defined variables. Refer to the `user_input_form` field in the "
"[Get App Parameters](/api-reference/applications/get-app-parameters) response to discover expected "
"variable names and types."
)
)
query: str = Field(description="User input or question content.")
files: InputFileList = Field(
default=None,
description=(
"File list for multimodal understanding, including images, documents, audio, and video. To attach a "
"local file, first upload it via [Upload File](/api-reference/files/upload-file) and use the returned "
"`id` as `upload_file_id` with `transfer_method: local_file`."
),
)
response_mode: Literal["blocking", "streaming"] | None = Field(
default=None,
description=(
"Response mode. `streaming` uses Server-Sent Events; `blocking` returns after completion. New Agent app "
"mode supports streaming only. When omitted, non-Agent apps run in blocking mode and new Agent apps stream."
),
)
conversation_id: UUIDStrOrEmpty | None = Field(
default=None,
description=(
"Conversation ID to continue a conversation. Omit this field or pass an empty string to start a new "
"conversation, then pass the returned `conversation_id` in subsequent requests."
),
)
retriever_from: SkipJsonSchema[str] = Field(default="dev")
auto_generate_name: bool = Field(
default=True,
description=(
"Auto-generate the conversation title. If `false`, use the Rename Conversation API with "
"`auto_generate: true` to generate the title asynchronously."
),
)
workflow_id: str | None = Field(
default=None,
description=(
"Published workflow version ID to execute for advanced chat. If omitted, the app's current published "
"workflow is used."
),
)
trace_session_id: SkipJsonSchema[str | None] = Field(
default=None, description="Trace session ID for observability grouping"
)
inputs: dict[str, Any]
query: str
files: list[dict[str, Any]] | None = Field(default=None)
response_mode: Literal["blocking", "streaming"] | None = None
conversation_id: UUIDStrOrEmpty | None = Field(default=None, description="Conversation UUID")
retriever_from: str = Field(default="dev")
auto_generate_name: bool = Field(default=True, description="Auto generate conversation name")
workflow_id: str | None = Field(default=None, description="Workflow ID for advanced chat")
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
@field_validator("conversation_id", mode="before")
@classmethod
@ -159,33 +92,7 @@ register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResu
@service_api_ns.route("/completion-messages")
class CompletionApi(Resource):
@service_api_ns.doc(
summary="Send Completion Message",
description="Send a request to the text generation application.",
tags=["Completions"],
responses={
200: (
"Successful response. The content type and structure depend on the `response_mode` parameter "
"in the request.\n"
"\n"
"- If `response_mode` is `blocking`, returns `application/json` with a `CompletionResponse` "
"object.\n"
"- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of "
"`ChunkCompletionEvent` objects."
),
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model unavailable.\n"
"- `completion_request_error` : Text generation failed."
),
429: "`too_many_requests` : Too many concurrent requests for this app.",
500: "`internal_server_error` : Internal server error.",
},
)
@expect_with_user(service_api_ns, CompletionRequestPayload)
@json_or_event_stream_response(service_api_ns)
@service_api_ns.expect(service_api_ns.models[CompletionRequestPayload.__name__])
@service_api_ns.doc("create_completion")
@service_api_ns.doc(description="Create a completion for the given prompt")
@service_api_ns.doc(
@ -261,20 +168,9 @@ class CompletionApi(Resource):
@service_api_ns.route("/completion-messages/<string:task_id>/stop")
class CompletionStopApi(Resource):
@service_api_ns.doc(
summary="Stop Completion Message Generation",
description="Stops a completion message generation task. Only supported in `streaming` mode.",
tags=["Completions"],
responses={
400: "`app_unavailable` : App unavailable or misconfigured.",
},
)
@expect_user_json(service_api_ns)
@service_api_ns.doc("stop_completion")
@service_api_ns.doc(description="Stop a running completion task")
@service_api_ns.doc(
params={"task_id": ("Task ID, obtained from a streaming chunk returned by the Send Completion Message API.")}
)
@service_api_ns.doc(params={"task_id": "The ID of the task to stop"})
@service_api_ns.doc(
responses={
200: "Task stopped successfully",
@ -301,39 +197,7 @@ class CompletionStopApi(Resource):
@service_api_ns.route("/chat-messages")
class ChatApi(Resource):
@service_api_ns.doc(
summary="Send Chat Message",
description="Send a request to the chat application.",
tags=["Chats", "Chatflows"],
responses={
200: (
"Successful response. The content type and structure depend on the `response_mode` parameter "
"in the request.\n"
"\n"
"- If `response_mode` is `blocking`, returns `application/json` with a "
"`ChatCompletionResponse` object.\n"
"- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of "
"Server-Sent Events."
),
400: (
"- `app_unavailable` : App unavailable or misconfigured.\n"
"- `not_chat_app` : App mode does not match the API route.\n"
"- `conversation_completed` : The conversation has ended.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model unavailable.\n"
"- `completion_request_error` : Text generation failed."
),
404: "`not_found` : Conversation does not exist.",
429: (
"- `too_many_requests` : Too many concurrent requests for this app.\n"
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
),
500: "`internal_server_error` : Internal server error.",
},
)
@expect_with_user(service_api_ns, ChatRequestPayload)
@json_or_event_stream_response(service_api_ns)
@service_api_ns.expect(service_api_ns.models[ChatRequestPayload.__name__])
@service_api_ns.doc("create_chat_message")
@service_api_ns.doc(description="Send a message in a chat conversation")
@service_api_ns.doc(
@ -412,20 +276,9 @@ class ChatApi(Resource):
@service_api_ns.route("/chat-messages/<string:task_id>/stop")
class ChatStopApi(Resource):
@service_api_ns.doc(
summary="Stop Chat Message Generation",
description="Stops a chat message generation task. Only supported in `streaming` mode.",
tags=["Chats", "Chatflows"],
responses={
400: "`not_chat_app` : App mode does not match the API route.",
},
)
@expect_user_json(service_api_ns)
@service_api_ns.doc("stop_chat_message")
@service_api_ns.doc(description="Stop a running chat message generation")
@service_api_ns.doc(
params={"task_id": "Task ID, obtained from a streaming chunk returned by the Send Chat Message API."}
)
@service_api_ns.doc(params={"task_id": "The ID of the task to stop"})
@service_api_ns.doc(
responses={
200: "Task stopped successfully",

View File

@ -13,7 +13,6 @@ from controllers.common.controller_schemas import ConversationRenamePayload
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.schema import expect_user_json, expect_with_user
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_database import db
@ -30,28 +29,18 @@ from services.conversation_service import ConversationService
class ConversationListQuery(BaseModel):
last_id: UUIDStrOrEmpty | None = Field(
default=None,
description="The ID of the last record on the current page. Used to fetch the next page.",
)
limit: int = Field(default=20, ge=1, le=100, description="Number of records to return.")
last_id: UUIDStrOrEmpty | None = Field(default=None, description="Last conversation ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of conversations to return")
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field(
default="-updated_at",
description="Sorting field. Use the `-` prefix for descending order.",
default="-updated_at", description="Sort order for conversations"
)
class ConversationVariablesQuery(BaseModel):
last_id: UUIDStrOrEmpty | None = Field(
default=None,
description="The ID of the last record on the current page. Used to fetch the next page.",
)
limit: int = Field(default=20, ge=1, le=100, description="Number of records to return.")
last_id: UUIDStrOrEmpty | None = Field(default=None, description="Last variable ID for pagination")
limit: int = Field(default=20, ge=1, le=100, description="Number of variables to return")
variable_name: str | None = Field(
default=None,
description="Filter variables by a specific name.",
min_length=1,
max_length=255,
default=None, description="Filter variables by name", min_length=1, max_length=255
)
@field_validator("variable_name", mode="before")
@ -79,7 +68,7 @@ class ConversationVariablesQuery(BaseModel):
class ConversationVariableUpdatePayload(BaseModel):
value: Any = Field(description="The new value for the variable. Must match the variable's expected type.")
value: Any
class ConversationVariableResponse(ResponseModel):
@ -156,16 +145,6 @@ register_response_schema_models(
@service_api_ns.route("/conversations")
class ConversationApi(Resource):
@service_api_ns.doc(
summary="List Conversations",
description="Retrieve the conversation list for the current user, ordered by most recently active.",
tags=["Conversations"],
responses={
200: "Successfully retrieved conversations list.",
400: "`not_chat_app` : App mode does not match the API route.",
404: "`not_found` : Last conversation does not exist (invalid `last_id`).",
},
)
@service_api_ns.doc(params=query_params_from_model(ConversationListQuery))
@service_api_ns.doc("list_conversations")
@service_api_ns.doc(description="List all conversations for the current user")
@ -218,20 +197,9 @@ class ConversationApi(Resource):
@service_api_ns.route("/conversations/<uuid:c_id>")
class ConversationDetailApi(Resource):
@service_api_ns.doc(
summary="Delete Conversation",
description="Delete a conversation.",
tags=["Conversations"],
responses={
204: "Conversation deleted successfully.",
400: "`not_chat_app` : App mode does not match the API route.",
404: "`not_found` : Conversation does not exist.",
},
)
@expect_user_json(service_api_ns)
@service_api_ns.doc("delete_conversation")
@service_api_ns.doc(description="Delete a specific conversation")
@service_api_ns.doc(params={"c_id": "Conversation ID."})
@service_api_ns.doc(params={"c_id": "Conversation ID"})
@service_api_ns.doc(
responses={
204: "Conversation deleted successfully",
@ -257,23 +225,10 @@ class ConversationDetailApi(Resource):
@service_api_ns.route("/conversations/<uuid:c_id>/name")
class ConversationRenameApi(Resource):
@service_api_ns.doc(
summary="Rename Conversation",
description=(
"Rename a conversation or auto-generate a name. The conversation name is used for display on "
"clients that support multiple conversations."
),
tags=["Conversations"],
responses={
200: "Conversation renamed successfully.",
400: "`not_chat_app` : App mode does not match the API route.",
404: "`not_found` : Conversation does not exist.",
},
)
@expect_with_user(service_api_ns, ConversationRenamePayload)
@service_api_ns.expect(service_api_ns.models[ConversationRenamePayload.__name__])
@service_api_ns.doc("rename_conversation")
@service_api_ns.doc(description="Rename a conversation or auto-generate a name")
@service_api_ns.doc(params={"c_id": "Conversation ID."})
@service_api_ns.doc(params={"c_id": "Conversation ID"})
@service_api_ns.doc(
responses={
200: "Conversation renamed successfully",
@ -312,20 +267,10 @@ class ConversationRenameApi(Resource):
@service_api_ns.route("/conversations/<uuid:c_id>/variables")
class ConversationVariablesApi(Resource):
@service_api_ns.doc(
summary="List Conversation Variables",
description="Retrieve variables from a specific conversation.",
tags=["Conversations"],
responses={
200: "Successfully retrieved conversation variables.",
400: "`not_chat_app` : App mode does not match the API route.",
404: "`not_found` : Conversation does not exist.",
},
)
@service_api_ns.doc(params=query_params_from_model(ConversationVariablesQuery))
@service_api_ns.doc("list_conversation_variables")
@service_api_ns.doc(description="List all variables for a conversation")
@service_api_ns.doc(params={"c_id": "Conversation ID."})
@service_api_ns.doc(params={"c_id": "Conversation ID"})
@service_api_ns.doc(
responses={
200: "Variables retrieved successfully",
@ -367,25 +312,10 @@ class ConversationVariablesApi(Resource):
@service_api_ns.route("/conversations/<uuid:c_id>/variables/<uuid:variable_id>")
class ConversationVariableDetailApi(Resource):
@service_api_ns.doc(
summary="Update Conversation Variable",
description="Update the value of a specific conversation variable. The value must match the expected type.",
tags=["Conversations"],
responses={
200: "Variable updated successfully.",
400: (
"- `not_chat_app` : App mode does not match the API route.\n"
"- `bad_request` : Variable value type mismatch."
),
404: (
"- `not_found` : Conversation does not exist.\n- `not_found` : Conversation variable does not exist."
),
},
)
@expect_with_user(service_api_ns, ConversationVariableUpdatePayload)
@service_api_ns.expect(service_api_ns.models[ConversationVariableUpdatePayload.__name__])
@service_api_ns.doc("update_conversation_variable")
@service_api_ns.doc(description="Update a conversation variable's value")
@service_api_ns.doc(params={"c_id": "Conversation ID.", "variable_id": "Variable ID."})
@service_api_ns.doc(params={"c_id": "Conversation ID", "variable_id": "Variable ID"})
@service_api_ns.doc(
responses={
200: "Variable updated successfully",

View File

@ -12,7 +12,6 @@ from controllers.common.errors import (
)
from controllers.common.schema import register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.schema import multipart_file_params
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from extensions.ext_database import db
from fields.file_fields import FileResponse
@ -24,27 +23,8 @@ register_schema_models(service_api_ns, FileResponse)
@service_api_ns.route("/files/upload")
class FileApi(Resource):
@service_api_ns.doc(
summary="Upload File",
description=(
"Upload a file for use when sending messages, enabling multimodal understanding of images, "
"documents, audio, and video. Uploaded files are for use by the current end-user only."
),
tags=["Files"],
responses={
201: "File uploaded successfully.",
400: (
"- `no_file_uploaded` : No file was provided in the request.\n"
"- `too_many_files` : Only one file is allowed per request.\n"
"- `filename_not_exists_error` : The uploaded file has no filename."
),
413: "`file_too_large` : File size exceeded.",
415: "`unsupported_file_type` : File type not allowed.",
},
)
@service_api_ns.doc("upload_file")
@service_api_ns.doc(description="Upload a file for use in conversations")
@service_api_ns.doc(consumes=["multipart/form-data"], params=multipart_file_params(include_user=True))
@service_api_ns.doc(
responses={
201: "File uploaded successfully",

View File

@ -15,7 +15,6 @@ from controllers.service_api.app.error import (
FileAccessDeniedError,
FileNotFoundError,
)
from controllers.service_api.schema import binary_response
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from extensions.ext_database import db
from extensions.ext_storage import storage
@ -25,35 +24,12 @@ logger = logging.getLogger(__name__)
class FilePreviewQuery(BaseModel):
as_attachment: bool = Field(
default=False,
description="If `true`, forces the file to download as an attachment instead of previewing in browser.",
)
as_attachment: bool = Field(default=False, description="Download as attachment")
register_schema_model(service_api_ns, FilePreviewQuery)
register_response_schema_model(service_api_ns, BinaryFileResponse)
FILE_PREVIEW_RESPONSE_MEDIA_TYPES = [
"application/octet-stream",
"application/pdf",
"audio/aac",
"audio/flac",
"audio/mp4",
"audio/mpeg",
"audio/ogg",
"audio/wav",
"audio/x-m4a",
"image/gif",
"image/jpeg",
"image/png",
"image/webp",
"text/plain",
"video/mp4",
"video/quicktime",
"video/webm",
]
@service_api_ns.route("/files/<uuid:file_id>/preview")
class FilePreviewApi(Resource):
@ -64,36 +40,10 @@ class FilePreviewApi(Resource):
Files can only be accessed if they belong to messages within the requesting app's context.
"""
@service_api_ns.doc(
summary="Download File",
description=(
"Preview or download uploaded files previously uploaded via the [Upload "
"File](/api-reference/files/upload-file) API. Files can only be accessed if they belong to "
"messages within the requesting application."
),
tags=["Files"],
responses={
200: (
"Returns the raw file content. The `Content-Type` header is set to the file's MIME type. If "
"`as_attachment` is `true`, the file is returned as a download with `Content-Disposition: "
"attachment`."
),
403: "`file_access_denied` : Access to the requested file is denied.",
404: "`file_not_found` : The requested file was not found.",
},
)
@service_api_ns.doc(params=query_params_from_model(FilePreviewQuery))
@binary_response(service_api_ns, FILE_PREVIEW_RESPONSE_MEDIA_TYPES)
@service_api_ns.doc("preview_file")
@service_api_ns.doc(description="Preview or download a file uploaded via Service API")
@service_api_ns.doc(
params={
"file_id": (
"The unique identifier of the file to preview, obtained from the "
"[Upload File](/api-reference/files/upload-file) API response."
)
}
)
@service_api_ns.doc(params={"file_id": "UUID of the file to preview"})
@service_api_ns.doc(
responses={
200: "File retrieved successfully",
@ -102,7 +52,11 @@ class FilePreviewApi(Resource):
404: "File not found",
}
)
@service_api_ns.response(200, "File retrieved successfully")
@service_api_ns.response(
200,
"File retrieved successfully",
service_api_ns.models[BinaryFileResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
def get(self, app_model: App, end_user: EndUser, file_id: UUID):
"""

View File

@ -18,7 +18,6 @@ from werkzeug.exceptions import BadRequest, NotFound
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.schema import expect_with_user
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from extensions.ext_database import db
@ -73,23 +72,6 @@ def _ensure_form_is_allowed_for_service_api(form: Form) -> None:
@service_api_ns.route("/form/human_input/<string:form_token>")
class WorkflowHumanInputFormApi(Resource):
@service_api_ns.doc(
summary="Get Human Input Form",
description=(
"Retrieve a paused Human Input form's contents using the `form_token` from a "
"`human_input_required` event. Requires **WebApp** delivery."
),
tags=["Human Input"],
responses={
200: "Form contents retrieved successfully.",
404: "`not_found` : Form not found.",
412: (
"- `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first "
"response wins regardless of which user submits it.\n"
"- `human_input_form_expired` : The form's expiration time passed before submission arrived."
),
},
)
@service_api_ns.doc("get_human_input_form")
@service_api_ns.doc(description="Get a paused human input form by token")
@service_api_ns.doc(params={"form_token": "Human input form token"})
@ -119,29 +101,7 @@ class WorkflowHumanInputFormApi(Resource):
inputs = service.resolve_form_inputs(form)
return _jsonify_form_definition(form, inputs=inputs)
@service_api_ns.doc(
summary="Submit Human Input Form",
description=(
"Submit the recipient's response to a paused Human Input form. The workflow resumes on "
"acceptance; use [Stream Workflow Events](/api-reference/chatflows/stream-workflow-events) "
"to follow subsequent events. Requires **WebApp** delivery."
),
tags=["Human Input"],
responses={
200: "Form submitted successfully. The response body is an empty object.",
400: (
"- `bad_request` : Form recipient type is invalid.\n"
"- `invalid_form_data` : Submission failed validation against the form definition."
),
404: "`not_found` : Form not found.",
412: (
"- `human_input_form_submitted` : Form already submitted. Forms are one-shot; the first "
"response wins regardless of which user submits it.\n"
"- `human_input_form_expired` : The form's expiration time passed before submission arrived."
),
},
)
@expect_with_user(service_api_ns, HumanInputFormSubmitPayload)
@service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__])
@service_api_ns.doc("submit_human_input_form")
@service_api_ns.doc(description="Submit a paused human input form by token")
@service_api_ns.doc(params={"form_token": "Human input form token"})

View File

@ -12,7 +12,6 @@ from controllers.common.fields import SimpleResultStringListResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.schema import expect_with_user
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.entities.app_invoke_entities import InvokeFrom
from fields.base import ResponseModel
@ -31,8 +30,8 @@ logger = logging.getLogger(__name__)
class FeedbackListQuery(BaseModel):
page: int = Field(default=1, ge=1, description="Page number for pagination.")
limit: int = Field(default=20, ge=1, le=101, description="Number of records per page.")
page: int = Field(default=1, ge=1, description="Page number")
limit: int = Field(default=20, ge=1, le=101, description="Number of feedbacks per page")
class AppFeedbackResponse(ResponseModel):
@ -65,19 +64,6 @@ register_response_schema_models(
@service_api_ns.route("/messages")
class MessageListApi(Resource):
@service_api_ns.doc(
summary="List Conversation Messages",
description=(
"Returns historical chat records in a scrolling load format, with the first page returning "
"the latest `limit` messages, i.e., in reverse order."
),
tags=["Conversations"],
responses={
200: "Successfully retrieved conversation history.",
400: "`not_chat_app` : App mode does not match the API route.",
404: ("- `not_found` : Conversation does not exist.\n- `not_found` : First message does not exist."),
},
)
@service_api_ns.doc(params=query_params_from_model(MessageListQuery))
@service_api_ns.doc("list_messages")
@service_api_ns.doc(description="List messages in a conversation")
@ -126,23 +112,11 @@ class MessageListApi(Resource):
@service_api_ns.route("/messages/<uuid:message_id>/feedbacks")
class MessageFeedbackApi(Resource):
@service_api_ns.doc(
summary="Submit Message Feedback",
description=(
"Submit feedback for a message. End users can rate messages as `like` or `dislike`, and "
"optionally provide text feedback. Pass `null` for `rating` to revoke previously submitted "
"feedback."
),
tags=["Feedback"],
responses={
404: "`not_found` : Message does not exist.",
},
)
@expect_with_user(service_api_ns, MessageFeedbackPayload)
@service_api_ns.expect(service_api_ns.models[MessageFeedbackPayload.__name__])
@service_api_ns.response(200, "Feedback submitted successfully", service_api_ns.models[ResultResponse.__name__])
@service_api_ns.doc("create_message_feedback")
@service_api_ns.doc(description="Submit feedback for a message")
@service_api_ns.doc(params={"message_id": "Message ID."})
@service_api_ns.doc(params={"message_id": "Message ID"})
@service_api_ns.doc(
responses={
200: "Feedback submitted successfully",
@ -176,17 +150,6 @@ class MessageFeedbackApi(Resource):
@service_api_ns.route("/app/feedbacks")
class AppGetFeedbacksApi(Resource):
@service_api_ns.doc(
summary="List App Feedbacks",
description=(
"Retrieve a paginated list of all feedback submitted for messages in this application, "
"including both end-user and admin feedback."
),
tags=["Feedback"],
responses={
200: "A list of application feedbacks.",
},
)
@service_api_ns.doc(params=query_params_from_model(FeedbackListQuery))
@service_api_ns.doc("get_app_feedbacks")
@service_api_ns.doc(description="Get all feedbacks for the application")
@ -214,20 +177,6 @@ class AppGetFeedbacksApi(Resource):
@service_api_ns.route("/messages/<uuid:message_id>/suggested")
class MessageSuggestedApi(Resource):
@service_api_ns.doc(
summary="Get Next Suggested Questions",
description="Get next questions suggestions for the current message.",
tags=["Chats", "Chatflows"],
responses={
200: "Successfully retrieved suggested questions.",
400: (
"- `not_chat_app` : App mode does not match the API route.\n"
"- `bad_request` : Suggested questions feature is disabled."
),
404: "`not_found` : Message does not exist.",
500: "`internal_server_error` : Internal server error.",
},
)
@service_api_ns.response(
200,
"Suggested questions retrieved successfully",

View File

@ -17,18 +17,6 @@ register_response_schema_models(service_api_ns, SiteResponse)
class AppSiteApi(Resource):
"""Resource for app sites."""
@service_api_ns.doc(
summary="Get App WebApp Settings",
description=(
"Retrieve the WebApp settings of this application, including site configuration, theme, and "
"customization options."
),
tags=["Applications"],
responses={
200: "WebApp settings of the application.",
403: "`forbidden` : Site not found for this application or the workspace has been archived.",
},
)
@service_api_ns.doc("get_app_site")
@service_api_ns.doc(description="Get application site configuration")
@service_api_ns.doc(

View File

@ -7,7 +7,6 @@ from dateutil.parser import isoparse
from flask import request
from flask_restx import Resource, fields
from pydantic import BaseModel, Field, field_validator
from pydantic.json_schema import SkipJsonSchema
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
@ -22,11 +21,6 @@ from controllers.service_api.app.error import (
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.service_api.schema import (
expect_user_json,
expect_with_user,
json_or_event_stream_response,
)
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
@ -59,41 +53,19 @@ logger = logging.getLogger(__name__)
class WorkflowRunPayload(WorkflowRunPayloadBase):
response_mode: Literal["blocking", "streaming"] | None = Field(
default=None,
description=(
"Response mode. Use `blocking` for synchronous responses or `streaming` for Server-Sent Events. "
"When omitted, the request runs in blocking mode."
),
)
trace_session_id: SkipJsonSchema[str | None] = Field(
default=None, description="Trace session ID for observability grouping"
)
response_mode: Literal["blocking", "streaming"] | None = None
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
class WorkflowLogQuery(BaseModel):
keyword: str | None = Field(default=None, description="Keyword to search in logs.")
status: Literal["succeeded", "failed", "stopped"] | None = Field(
default=None,
description="Filter by execution status.",
)
created_at__before: str | None = Field(
default=None,
description="Filter logs created before this ISO 8601 timestamp.",
json_schema_extra={"format": "date-time"},
)
created_at__after: str | None = Field(
default=None,
description="Filter logs created after this ISO 8601 timestamp.",
json_schema_extra={"format": "date-time"},
)
created_by_end_user_session_id: str | None = Field(
default=None,
description="Filter by end user session ID.",
)
created_by_account: str | None = Field(default=None, description="Filter by account ID.")
page: int = Field(default=1, ge=1, le=99999, description="Page number for pagination.")
limit: int = Field(default=20, ge=1, le=100, description="Number of items per page.")
keyword: str | None = None
status: Literal["succeeded", "failed", "stopped"] | None = None
created_at__before: str | None = None
created_at__after: str | None = None
created_by_end_user_session_id: str | None = None
created_by_account: str | None = None
page: int = Field(default=1, ge=1, le=99999)
limit: int = Field(default=20, ge=1, le=100)
register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery)
@ -205,15 +177,14 @@ register_response_schema_models(
def _serialize_workflow_run(workflow_run: WorkflowRun) -> dict:
status = _enum_value(workflow_run.status)
raw_outputs = workflow_run.outputs_dict
match raw_outputs:
case _ if status == WorkflowExecutionStatus.PAUSED.value or raw_outputs is None:
outputs: dict = {}
case dict():
outputs = raw_outputs
case _ if isinstance(raw_outputs, Mapping):
outputs = dict(raw_outputs)
case _:
outputs = {}
if status == WorkflowExecutionStatus.PAUSED.value or raw_outputs is None:
outputs: dict = {}
elif isinstance(raw_outputs, dict):
outputs = raw_outputs
elif isinstance(raw_outputs, Mapping):
outputs = dict(raw_outputs)
else:
outputs = {}
return WorkflowRunResponse.model_validate(
{
"id": workflow_run.id,
@ -237,23 +208,9 @@ def _serialize_workflow_log_pagination(pagination) -> dict:
@service_api_ns.route("/workflows/run/<string:workflow_run_id>")
class WorkflowRunDetailApi(Resource):
@service_api_ns.doc(
summary="Get Workflow Run Detail",
description="Retrieve the current execution results of a workflow task based on the workflow execution ID.",
tags=["Chatflows", "Workflows"],
responses={
200: "Successfully retrieved workflow run details.",
400: "`not_workflow_app` : App mode does not match the API route.",
404: "`not_found` : Workflow run not found.",
},
)
@service_api_ns.doc("get_workflow_run_detail")
@service_api_ns.doc(description="Get workflow run details")
@service_api_ns.doc(
params={
"workflow_run_id": "Workflow run ID, obtained from the workflow execution response or streaming events."
}
)
@service_api_ns.doc(params={"workflow_run_id": "Workflow run ID"})
@service_api_ns.doc(
responses={
200: "Workflow run details retrieved successfully",
@ -292,37 +249,7 @@ class WorkflowRunDetailApi(Resource):
@service_api_ns.route("/workflows/run")
class WorkflowRunApi(Resource):
@service_api_ns.doc(
summary="Run Workflow",
description="Execute a workflow. Cannot be executed without a published workflow.",
tags=["Workflows"],
responses={
200: (
"Successful response. The content type and structure depend on the `response_mode` parameter "
"in the request.\n"
"\n"
"- If `response_mode` is `blocking`, returns `application/json` with a "
"`WorkflowBlockingResponse` object.\n"
"- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of "
"`ChunkWorkflowEvent` objects."
),
400: (
"- `not_workflow_app` : App mode does not match the API route.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model unavailable.\n"
"- `completion_request_error` : Workflow execution request failed.\n"
"- `invalid_param` : Invalid parameter value."
),
429: (
"- `too_many_requests` : Too many concurrent requests for this app.\n"
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
),
500: "`internal_server_error` : Internal server error.",
},
)
@expect_with_user(service_api_ns, WorkflowRunPayload)
@json_or_event_stream_response(service_api_ns)
@service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__])
@service_api_ns.doc("run_workflow")
@service_api_ns.doc(description="Execute a workflow")
@service_api_ns.doc(
@ -386,52 +313,10 @@ class WorkflowRunApi(Resource):
@service_api_ns.route("/workflows/<string:workflow_id>/run")
class WorkflowRunByIdApi(Resource):
@service_api_ns.doc(
summary="Run Workflow by ID",
description=(
"Execute a specific workflow version identified by its ID. Useful for running a particular "
"published version of the workflow."
),
tags=["Workflows"],
responses={
200: (
"Successful response. The content type and structure depend on the `response_mode` parameter "
"in the request.\n"
"\n"
"- If `response_mode` is `blocking`, returns `application/json` with a "
"`WorkflowBlockingResponse` object.\n"
"- If `response_mode` is `streaming`, returns `text/event-stream` with a stream of "
"`ChunkWorkflowEvent` objects."
),
400: (
"- `not_workflow_app` : App mode does not match the API route.\n"
"- `bad_request` : Workflow is a draft or has an invalid ID format.\n"
"- `provider_not_initialize` : No valid model provider credentials found.\n"
"- `provider_quota_exceeded` : Model provider quota exhausted.\n"
"- `model_currently_not_support` : Current model unavailable.\n"
"- `completion_request_error` : Workflow execution request failed.\n"
"- `invalid_param` : Required parameter missing or invalid."
),
404: "`not_found` : Workflow not found.",
429: (
"- `too_many_requests` : Too many concurrent requests for this app.\n"
"- `rate_limit_error` : The upstream model provider rate limit was exceeded."
),
500: "`internal_server_error` : Internal server error.",
},
)
@expect_with_user(service_api_ns, WorkflowRunPayload)
@json_or_event_stream_response(service_api_ns)
@service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__])
@service_api_ns.doc("run_workflow_by_id")
@service_api_ns.doc(description="Execute a specific workflow by ID")
@service_api_ns.doc(
params={
"workflow_id": (
"Workflow ID of the specific version to execute. This value is returned in the `workflow_id` field "
"of workflow run responses."
)
}
)
@service_api_ns.doc(params={"workflow_id": "Workflow ID to execute"})
@service_api_ns.doc(
responses={
200: "Workflow executed successfully",
@ -502,23 +387,9 @@ class WorkflowRunByIdApi(Resource):
@service_api_ns.route("/workflows/tasks/<string:task_id>/stop")
class WorkflowTaskStopApi(Resource):
@service_api_ns.doc(
summary="Stop Workflow Task",
description="Stop a running workflow task. Only supported in `streaming` mode.",
tags=["Workflows"],
responses={
400: (
"- `not_workflow_app` : App mode does not match the API route.\n"
"- `invalid_param` : Required parameter missing or invalid."
),
},
)
@expect_user_json(service_api_ns)
@service_api_ns.doc("stop_workflow_task")
@service_api_ns.doc(description="Stop a running workflow task")
@service_api_ns.doc(
params={"task_id": "Task ID, obtained from the streaming chunk returned by the Run Workflow API."}
)
@service_api_ns.doc(params={"task_id": "Task ID to stop"})
@service_api_ns.doc(
responses={
200: "Task stopped successfully",
@ -546,14 +417,6 @@ class WorkflowTaskStopApi(Resource):
@service_api_ns.route("/workflows/logs")
class WorkflowAppLogApi(Resource):
@service_api_ns.doc(
summary="List Workflow Logs",
description="Retrieve paginated workflow execution logs with filtering options.",
tags=["Chatflows", "Workflows"],
responses={
200: "Successfully retrieved workflow logs.",
},
)
@service_api_ns.doc(params=query_params_from_model(WorkflowLogQuery))
@service_api_ns.doc("get_workflow_logs")
@service_api_ns.doc(description="Get workflow execution logs")

View File

@ -15,7 +15,6 @@ from controllers.common.fields import EventStreamResponse
from controllers.common.schema import query_params_from_model, register_response_schema_model, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotWorkflowAppError
from controllers.service_api.schema import event_stream_response
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
@ -32,25 +31,9 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream
class WorkflowEventsQuery(BaseModel):
user: str = Field(
...,
description="End-user identifier that originally triggered the run. Must match the creator of the run.",
)
include_state_snapshot: bool = Field(
default=False,
description=(
"When `true`, replay from the persisted state snapshot to include a status summary of already-executed "
"nodes before streaming new events."
),
)
continue_on_pause: bool = Field(
default=False,
description=(
"Set to `true` to keep the stream open across multiple `workflow_paused` events, which is useful when "
"the workflow has more than one Human Input node in sequence. By default, the stream closes after the "
"first pause."
),
)
user: str = Field(..., description="End user identifier")
include_state_snapshot: bool = Field(default=False, description="Replay from persisted state snapshot")
continue_on_pause: bool = Field(default=False, description="Keep the stream open across workflow_paused events")
register_schema_models(service_api_ns, WorkflowEventsQuery)
@ -61,27 +44,9 @@ register_response_schema_model(service_api_ns, EventStreamResponse)
class WorkflowEventsApi(Resource):
"""Service API for getting workflow execution events after resume."""
@service_api_ns.doc(
summary="Stream Workflow Events",
description=(
"Resume the Server-Sent Events stream for a workflow run after a pause or a dropped SSE "
"connection. For runs that have already finished, the stream emits a single "
"`workflow_finished` event and closes."
),
tags=["Chatflows", "Workflows"],
responses={
200: (
"Server-Sent Events stream. Each event is delivered as `data: {JSON}\\n\\n`. Event payloads "
"follow the same schemas as the original streaming response."
),
400: "`not_workflow_app` : Please check if your app mode matches the right API route.",
404: "`not_found` : Workflow run not found.",
},
)
@event_stream_response(service_api_ns)
@service_api_ns.doc("get_workflow_events")
@service_api_ns.doc(description="Get workflow execution events stream after resume")
@service_api_ns.doc(params={"task_id": "Workflow run ID returned by the original workflow run request."})
@service_api_ns.doc(params={"task_id": "Workflow run ID"})
@service_api_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
@service_api_ns.doc(
responses={

View File

@ -1,17 +1,8 @@
from typing import Annotated, Literal, override
from typing import Any, Literal
from uuid import UUID
from flask import request
from pydantic import (
BaseModel,
ConfigDict,
Field,
GetJsonSchemaHandler,
RootModel,
WithJsonSchema,
field_validator,
model_validator,
)
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator
from werkzeug.exceptions import Forbidden, NotFound
import services
@ -31,7 +22,6 @@ from controllers.service_api.wraps import (
)
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.rag.index_processor.constant.index_type import IndexTechniqueType
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.dataset_fields import DatasetDetailResponse
from graphon.model_runtime.entities.model_entities import ModelType
@ -42,12 +32,7 @@ from models.dataset import DatasetPermissionEnum
from models.enums import TagType
from models.provider_ids import ModelProviderID
from services.dataset_service import DatasetPermissionService, DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import (
ExternalRetrievalModel,
KnowledgeProvider,
RetrievalModel,
SummaryIndexSetting,
)
from services.entities.knowledge_entities.knowledge_entities import RetrievalModel
from services.tag_service import (
SaveTagPayload,
TagBindingCreatePayload,
@ -60,133 +45,41 @@ from services.tag_service import (
register_enum_models(service_api_ns, DatasetPermissionEnum)
PartialMemberList = Annotated[
list[dict[str, str]] | None,
WithJsonSchema(
{
"anyOf": [
{
"items": {
"properties": {
"user_id": {
"description": "ID of the team member to grant access.",
"type": "string",
}
},
"type": "object",
},
"type": "array",
},
{"type": "null"},
]
}
),
]
class DatasetCreatePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=40, description="Name of the knowledge base.")
description: str = Field(default="", description="Description of the knowledge base.", max_length=400)
indexing_technique: Literal["high_quality", "economy"] | None = Field(
default=None,
description="`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing.",
)
permission: DatasetPermissionEnum | None = Field(
default=DatasetPermissionEnum.ONLY_ME,
description=(
"Controls who can access this knowledge base. `only_me` restricts access to the creator, "
"`all_team_members` grants workspace-wide access, and `partial_members` grants access to specified "
"members."
),
)
external_knowledge_api_id: str | None = Field(default=None, description="ID of the external knowledge API.")
provider: KnowledgeProvider = Field(
default="vendor",
description="Knowledge base provider: `vendor` for internal knowledge bases, `external` for external ones.",
)
external_knowledge_id: str | None = Field(default=None, description="ID of the external knowledge base.")
retrieval_model: RetrievalModel | None = Field(
default=None,
description="Retrieval model configuration. Controls how chunks are searched and ranked.",
)
embedding_model: str | None = Field(
default=None,
description=(
"Embedding model name. Use the `model` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
embedding_model_provider: str | None = Field(
default=None,
description=(
"Embedding model provider. Use the `provider` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
summary_index_setting: SummaryIndexSetting = Field(
default=None,
description="Summary index configuration.",
)
name: str = Field(..., min_length=1, max_length=40)
description: str = Field(default="", description="Dataset description (max 400 chars)", max_length=400)
indexing_technique: Literal["high_quality", "economy"] | None = None
permission: DatasetPermissionEnum | None = DatasetPermissionEnum.ONLY_ME
external_knowledge_api_id: str | None = None
provider: str = "vendor"
external_knowledge_id: str | None = None
retrieval_model: RetrievalModel | None = None
embedding_model: str | None = None
embedding_model_provider: str | None = None
summary_index_setting: dict | None = Field(default=None)
class DatasetUpdatePayload(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=40, description="Name of the knowledge base.")
description: str | None = Field(default=None, description="Description of the knowledge base.", max_length=400)
indexing_technique: Literal["high_quality", "economy"] | None = Field(
default=None,
description="`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing.",
)
permission: DatasetPermissionEnum | None = Field(
default=None,
description=(
"Controls who can access this knowledge base. `only_me` restricts access to the creator, "
"`all_team_members` grants workspace-wide access, and `partial_members` grants access to specified "
"members."
),
)
embedding_model: str | None = Field(
default=None,
description=(
"Embedding model name. Use the `model` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
embedding_model_provider: str | None = Field(
default=None,
description=(
"Embedding model provider. Use the `provider` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
retrieval_model: RetrievalModel | None = Field(
default=None,
description="Retrieval model configuration. Controls how chunks are searched and ranked.",
)
partial_member_list: PartialMemberList = Field(
default=None,
description="List of team members with access when `permission` is `partial_members`.",
)
external_retrieval_model: ExternalRetrievalModel = Field(
default=None,
description="Retrieval settings for external knowledge bases.",
)
external_knowledge_id: str | None = Field(default=None, description="ID of the external knowledge base.")
external_knowledge_api_id: str | None = Field(default=None, description="ID of the external knowledge API.")
name: str | None = Field(default=None, min_length=1, max_length=40)
description: str | None = Field(default=None, description="Dataset description (max 400 chars)", max_length=400)
indexing_technique: Literal["high_quality", "economy"] | None = None
permission: DatasetPermissionEnum | None = None
embedding_model: str | None = None
embedding_model_provider: str | None = None
retrieval_model: RetrievalModel | None = None
partial_member_list: list[dict[str, str]] | None = None
external_retrieval_model: dict[str, Any] | None = Field(default=None)
external_knowledge_id: str | None = None
external_knowledge_api_id: str | None = None
class DocumentStatusPayload(BaseModel):
document_ids: list[str] = Field(default_factory=list, description="List of document IDs to update.")
DOCUMENT_STATUS_ACTION_PARAM = {
"description": "Action to perform: 'enable', 'disable', 'archive', or 'un_archive'",
"enum": ["enable", "disable", "archive", "un_archive"],
"type": "string",
}
document_ids: list[str] = Field(default_factory=list, description="Document IDs to update")
class TagNamePayload(BaseModel):
name: str = Field(..., min_length=1, max_length=50, description="Tag name.")
name: str = Field(..., min_length=1, max_length=50)
class TagCreatePayload(TagNamePayload):
@ -194,16 +87,16 @@ class TagCreatePayload(TagNamePayload):
class TagUpdatePayload(TagNamePayload):
tag_id: str = Field(description="Tag ID to update.")
tag_id: str
class TagDeletePayload(BaseModel):
tag_id: str = Field(description="Tag ID to delete.")
tag_id: str
class TagBindingPayload(BaseModel):
tag_ids: list[str] = Field(description="Tag IDs to bind.")
target_id: str = Field(description="Knowledge base ID to bind the tags to.")
tag_ids: list[str]
target_id: str
@field_validator("tag_ids")
@classmethod
@ -218,46 +111,7 @@ class TagUnbindingPayload(BaseModel):
tag_ids: list[str] = Field(default_factory=list)
tag_id: str | None = None
target_id: str = Field(description="Knowledge base ID.")
@classmethod
@override
def __get_pydantic_json_schema__(cls, _core_schema: object, _handler: GetJsonSchemaHandler) -> dict[str, object]:
tag_id_property = {
"description": "Legacy single tag ID accepted by the Service API.",
"type": "string",
}
tag_ids_property = {
"description": "Tag IDs to unbind. Use this for new integrations.",
"items": {"type": "string"},
"minItems": 1,
"type": "array",
}
target_id_property = {"description": "Knowledge base ID.", "title": "Target Id", "type": "string"}
return {
"anyOf": [
{
"properties": {
"tag_id": tag_id_property,
"tag_ids": tag_ids_property,
"target_id": target_id_property,
},
"required": ["tag_id", "target_id"],
"type": "object",
},
{
"properties": {
"tag_id": {**tag_id_property, "nullable": True},
"tag_ids": tag_ids_property,
"target_id": target_id_property,
},
"required": ["tag_ids", "target_id"],
"type": "object",
},
],
"description": "Accepts either the legacy tag_id payload or the normalized tag_ids payload.",
"title": cls.__name__,
}
target_id: str
@model_validator(mode="before")
@classmethod
@ -291,14 +145,11 @@ class KnowledgeTagListResponse(RootModel[list[KnowledgeTagResponse]]):
class DatasetListQuery(BaseModel):
page: int = Field(default=1, description="Page number to retrieve.")
limit: int = Field(default=20, description="Number of items per page. Server caps at `100`.")
keyword: str | None = Field(default=None, description="Search keyword to filter by name.")
include_all: bool = Field(
default=False,
description="Whether to include all knowledge bases regardless of permissions.",
)
tag_ids: list[str] = Field(default_factory=list, description="Tag IDs to filter by.")
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
include_all: bool = Field(default=False, description="Include all datasets")
tag_ids: list[str] = Field(default_factory=list, description="Filter by tag IDs")
class DatasetDetailWithPartialMembersResponse(DatasetDetailResponse):
@ -352,14 +203,6 @@ register_response_schema_models(
class DatasetListApi(DatasetApiResource):
"""Resource for datasets."""
@service_api_ns.doc(
summary="List Knowledge Bases",
description="Returns a paginated list of knowledge bases. Supports filtering by keyword and tags.",
tags=["Knowledge Bases"],
responses={
200: "List of knowledge bases.",
},
)
@service_api_ns.doc("list_datasets")
@service_api_ns.doc(description="List all datasets")
@service_api_ns.doc(
@ -418,19 +261,6 @@ class DatasetListApi(DatasetApiResource):
}
return dump_response(DatasetListResponse, response), 200
@service_api_ns.doc(
summary="Create an Empty Knowledge Base",
description=(
"Create a new empty knowledge base. After creation, use [Create Document by "
"Text](/api-reference/documents/create-document-by-text) or [Create Document by "
"File](/api-reference/documents/create-document-by-file) to add documents."
),
tags=["Knowledge Bases"],
responses={
200: "Knowledge base created successfully.",
409: "`dataset_name_duplicate` : The dataset name already exists. Please modify your dataset name.",
},
)
@service_api_ns.expect(service_api_ns.models[DatasetCreatePayload.__name__])
@service_api_ns.doc("create_dataset")
@service_api_ns.doc(description="Create a new dataset")
@ -496,22 +326,9 @@ class DatasetListApi(DatasetApiResource):
class DatasetApi(DatasetApiResource):
"""Resource for dataset."""
@service_api_ns.doc(
summary="Get Knowledge Base",
description=(
"Retrieve detailed information about a specific knowledge base, including its embedding "
"model, retrieval configuration, and document statistics."
),
tags=["Knowledge Bases"],
responses={
200: "Knowledge base details.",
403: "`forbidden` : Insufficient permissions to access this knowledge base.",
404: "`not_found` : Dataset not found.",
},
)
@service_api_ns.doc("get_dataset")
@service_api_ns.doc(description="Get a specific dataset by ID")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Dataset retrieved successfully",
@ -574,23 +391,10 @@ class DatasetApi(DatasetApiResource):
200,
)
@service_api_ns.doc(
summary="Update Knowledge Base",
description=(
"Update the name, description, permissions, or retrieval settings of an existing knowledge "
"base. Only the fields provided in the request body are updated."
),
tags=["Knowledge Bases"],
responses={
200: "Knowledge base updated successfully.",
403: "`forbidden` : Insufficient permissions to access this knowledge base.",
404: "`not_found` : Dataset not found.",
},
)
@service_api_ns.expect(service_api_ns.models[DatasetUpdatePayload.__name__])
@service_api_ns.doc("update_dataset")
@service_api_ns.doc(description="Update an existing dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Dataset updated successfully",
@ -669,25 +473,9 @@ class DatasetApi(DatasetApiResource):
return DatasetDetailWithPartialMembersResponse.model_validate(result_data).model_dump(mode="json"), 200
@service_api_ns.doc(
summary="Delete Knowledge Base",
description=(
"Permanently delete a knowledge base and all its documents. The knowledge base must not be "
"in use by any application."
),
tags=["Knowledge Bases"],
responses={
204: "Success.",
404: "`not_found` : Dataset not found.",
409: (
"`dataset_in_use` : The knowledge base is being used by some apps. Please remove it from the "
"apps before deleting."
),
},
)
@service_api_ns.doc("delete_dataset")
@service_api_ns.doc(description="Delete a dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
204: "Dataset deleted successfully",
@ -730,17 +518,6 @@ class DatasetApi(DatasetApiResource):
class DocumentStatusApi(DatasetApiResource):
"""Resource for batch document status operations."""
@service_api_ns.doc(
summary="Update Document Status in Batch",
description="Enable, disable, archive, or unarchive multiple documents at once.",
tags=["Documents"],
responses={
200: "Documents updated successfully.",
400: "`invalid_action` : Invalid action.",
403: "`forbidden` : Insufficient permissions.",
404: "`not_found` : Knowledge base not found.",
},
)
@service_api_ns.response(
200,
"Document status updated successfully",
@ -750,8 +527,8 @@ class DocumentStatusApi(DatasetApiResource):
@service_api_ns.doc(description="Batch update document status")
@service_api_ns.doc(
params={
"dataset_id": "Knowledge base ID.",
"action": DOCUMENT_STATUS_ACTION_PARAM,
"dataset_id": "Dataset ID",
"action": "Action to perform: 'enable', 'disable', 'archive', or 'un_archive'",
}
)
@service_api_ns.doc(
@ -813,14 +590,6 @@ class DocumentStatusApi(DatasetApiResource):
@service_api_ns.route("/datasets/tags")
class DatasetTagsApi(DatasetApiResource):
@service_api_ns.doc(
summary="List Knowledge Tags",
description="Returns the list of all knowledge base tags in the workspace.",
tags=["Tags"],
responses={
200: "List of tags.",
},
)
@service_api_ns.doc("list_dataset_tags")
@service_api_ns.doc(description="Get all knowledge type tags")
@service_api_ns.doc(
@ -839,17 +608,9 @@ class DatasetTagsApi(DatasetApiResource):
assert isinstance(current_user, Account)
cid = current_user.current_tenant_id
assert cid is not None
tags = TagService.get_tags(db.session(), "knowledge", cid)
tags = TagService.get_tags("knowledge", cid)
return dump_response(KnowledgeTagListResponse, tags), 200
@service_api_ns.doc(
summary="Create Knowledge Tag",
description="Create a new tag for organizing knowledge bases.",
tags=["Tags"],
responses={
200: "Tag created successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[TagCreatePayload.__name__])
@service_api_ns.doc("create_dataset_tag")
@service_api_ns.doc(description="Add a knowledge type tag")
@ -872,7 +633,7 @@ class DatasetTagsApi(DatasetApiResource):
raise Forbidden()
payload = TagCreatePayload.model_validate(service_api_ns.payload or {})
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), db.session)
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE))
response = dump_response(
KnowledgeTagResponse,
@ -880,14 +641,6 @@ class DatasetTagsApi(DatasetApiResource):
)
return response, 200
@service_api_ns.doc(
summary="Update Knowledge Tag",
description="Rename an existing knowledge base tag.",
tags=["Tags"],
responses={
200: "Tag updated successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[TagUpdatePayload.__name__])
@service_api_ns.doc("update_dataset_tag")
@service_api_ns.doc(description="Update a knowledge type tag")
@ -910,9 +663,9 @@ class DatasetTagsApi(DatasetApiResource):
payload = TagUpdatePayload.model_validate(service_api_ns.payload or {})
tag_id = payload.tag_id
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id, db.session)
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id)
binding_count = TagService.get_tag_binding_count(tag_id, db.session)
binding_count = TagService.get_tag_binding_count(tag_id)
response = dump_response(
KnowledgeTagResponse,
@ -920,14 +673,6 @@ class DatasetTagsApi(DatasetApiResource):
)
return response, 200
@service_api_ns.doc(
summary="Delete Knowledge Tag",
description="Permanently delete a knowledge base tag. Does not delete the knowledge bases that were tagged.",
tags=["Tags"],
responses={
204: "Success.",
},
)
@service_api_ns.expect(service_api_ns.models[TagDeletePayload.__name__])
@service_api_ns.doc("delete_dataset_tag")
@service_api_ns.doc(description="Delete a knowledge type tag")
@ -942,21 +687,13 @@ class DatasetTagsApi(DatasetApiResource):
def delete(self, _):
"""Delete a knowledge type tag."""
payload = TagDeletePayload.model_validate(service_api_ns.payload or {})
TagService.delete_tag(payload.tag_id, db.session)
TagService.delete_tag(payload.tag_id)
return "", 204
@service_api_ns.route("/datasets/tags/binding")
class DatasetTagBindingApi(DatasetApiResource):
@service_api_ns.doc(
summary="Create Tag Binding",
description="Bind one or more tags to a knowledge base. A knowledge base can have multiple tags.",
tags=["Tags"],
responses={
204: "Success.",
},
)
@service_api_ns.expect(service_api_ns.models[TagBindingPayload.__name__])
@service_api_ns.doc("bind_dataset_tags")
@service_api_ns.doc(description="Bind tags to a dataset")
@ -975,8 +712,7 @@ class DatasetTagBindingApi(DatasetApiResource):
payload = TagBindingPayload.model_validate(service_api_ns.payload or {})
TagService.save_tag_binding(
TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
db.session,
TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE)
)
return "", 204
@ -984,14 +720,6 @@ class DatasetTagBindingApi(DatasetApiResource):
@service_api_ns.route("/datasets/tags/unbinding")
class DatasetTagUnbindingApi(DatasetApiResource):
@service_api_ns.doc(
summary="Delete Tag Binding",
description="Remove one or more tags from a knowledge base.",
tags=["Tags"],
responses={
204: "Success.",
},
)
@service_api_ns.expect(service_api_ns.models[TagUnbindingPayload.__name__])
@service_api_ns.doc("unbind_dataset_tags")
@service_api_ns.doc(description="Unbind tags from a dataset")
@ -1010,8 +738,7 @@ class DatasetTagUnbindingApi(DatasetApiResource):
payload = TagUnbindingPayload.model_validate(service_api_ns.payload or {})
TagService.delete_tag_binding(
TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
db.session,
TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE)
)
return "", 204
@ -1019,17 +746,9 @@ class DatasetTagUnbindingApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/tags")
class DatasetTagsBindingStatusApi(DatasetApiResource):
@service_api_ns.doc(
summary="Get Knowledge Base Tags",
description="Returns the list of tags bound to a specific knowledge base.",
tags=["Tags"],
responses={
200: "Tags bound to the knowledge base.",
},
)
@service_api_ns.doc("get_dataset_tags_binding_status")
@service_api_ns.doc(description="Get tags bound to a specific dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Tags retrieved successfully",
@ -1046,8 +765,6 @@ class DatasetTagsBindingStatusApi(DatasetApiResource):
dataset_id = kwargs.get("dataset_id")
assert isinstance(current_user, Account)
assert current_user.current_tenant_id is not None
tags = TagService.get_tags_by_target_id(
"knowledge", current_user.current_tenant_id, str(dataset_id), db.session
)
tags = TagService.get_tags_by_target_id("knowledge", current_user.current_tenant_id, str(dataset_id))
tags_list = [{"id": tag.id, "name": tag.name} for tag in tags]
return dump_response(DatasetBoundTagListResponse, {"data": tags_list, "total": len(tags)}), 200

View File

@ -8,12 +8,11 @@ deprecated in generated API docs so clients migrate toward the canonical paths.
import json
from collections.abc import Mapping
from contextlib import ExitStack
from copy import deepcopy
from typing import Annotated, Any, Literal, Self, override
from typing import Any, Literal, Self
from uuid import UUID
from flask import request, send_file
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, field_validator, model_validator
from pydantic import BaseModel, Field, field_validator, model_validator
from sqlalchemy import desc, func, select
from werkzeug.exceptions import Forbidden, NotFound
@ -40,7 +39,6 @@ from controllers.service_api.dataset.error import (
DocumentIndexingError,
InvalidMetadataError,
)
from controllers.service_api.schema import binary_response
from controllers.service_api.wraps import (
DatasetApiResource,
cloud_edition_billing_rate_limit_check,
@ -63,8 +61,6 @@ from models.dataset import Dataset, Document, DocumentSegment
from models.enums import SegmentStatus
from services.dataset_service import DatasetService, DocumentService
from services.entities.knowledge_entities.knowledge_entities import (
DocForm,
IndexingTechnique,
KnowledgeConfig,
ProcessRule,
RetrievalModel,
@ -74,44 +70,16 @@ from services.summary_index_service import SummaryIndexService
class DocumentTextCreatePayload(BaseModel):
name: str = Field(description="Document name.")
text: str = Field(description="Document text content.")
process_rule: ProcessRule | None = Field(default=None, description="Processing rules for chunking.")
original_document_id: str | None = Field(default=None, description="Original document ID for replacement.")
doc_form: DocForm = Field(
default="text_model",
description=(
"`text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, "
"`qa_model` for question-answer pair extraction."
),
)
doc_language: str = Field(default="English", description="Language of the document for processing optimization.")
indexing_technique: IndexingTechnique = Field(
default=None,
description=(
"`high_quality` uses embedding models for precise search; `economy` uses keyword-based indexing. "
"Required when adding the first document to a knowledge base; subsequent documents inherit the "
"knowledge base's indexing technique if omitted."
),
)
retrieval_model: RetrievalModel | None = Field(
default=None,
description="Retrieval model configuration. Controls how chunks are searched and ranked.",
)
embedding_model: str | None = Field(
default=None,
description=(
"Embedding model name. Use the `model` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
embedding_model_provider: str | None = Field(
default=None,
description=(
"Embedding model provider. Use the `provider` field from "
"[Get Available Models](/api-reference/models/get-available-models) with `model_type=text-embedding`."
),
)
name: str
text: str
process_rule: ProcessRule | None = None
original_document_id: str | None = None
doc_form: str = Field(default="text_model")
doc_language: str = Field(default="English")
indexing_technique: str | None = None
retrieval_model: RetrievalModel | None = None
embedding_model: str | None = None
embedding_model_provider: str | None = None
@field_validator("doc_form")
@classmethod
@ -122,21 +90,12 @@ class DocumentTextCreatePayload(BaseModel):
class DocumentTextUpdate(BaseModel):
name: str | None = Field(default=None, description="Document name. Required when `text` is provided.")
text: str | None = Field(default=None, description="Document text content.")
process_rule: ProcessRule | None = Field(default=None, description="Processing rules for chunking.")
doc_form: DocForm = Field(
default="text_model",
description=(
"`text_model` for standard text chunking, `hierarchical_model` for parent-child chunk structure, "
"`qa_model` for question-answer pair extraction."
),
)
doc_language: str = Field(default="English", description="Language of the document for processing optimization.")
retrieval_model: RetrievalModel | None = Field(
default=None,
description="Retrieval model configuration. Controls how chunks are searched and ranked.",
)
name: str | None = None
text: str | None = None
process_rule: ProcessRule | None = None
doc_form: str = "text_model"
doc_language: str = "English"
retrieval_model: RetrievalModel | None = None
@field_validator("doc_form")
@classmethod
@ -145,36 +104,6 @@ class DocumentTextUpdate(BaseModel):
raise ValueError("Invalid doc_form.")
return value
@classmethod
@override
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]:
schema = handler.resolve_ref_schema(handler(core_schema))
properties = schema.get("properties")
if not isinstance(properties, dict):
return schema
text_branch_properties = deepcopy(properties)
text_branch_properties["text"] = _non_null_property_schema(properties.get("text"))
text_branch_properties["name"] = _non_null_property_schema(properties.get("name"))
no_text_branch_properties = deepcopy(properties)
no_text_branch_properties["text"] = {"description": "Document text content.", "type": "null"}
return {
**schema,
"anyOf": [
{
"properties": text_branch_properties,
"required": ["name", "text"],
"type": "object",
},
{
"properties": no_text_branch_properties,
"type": "object",
},
],
}
@model_validator(mode="after")
def check_text_and_name(self) -> Self:
if self.text is not None and self.name is None:
@ -182,59 +111,19 @@ class DocumentTextUpdate(BaseModel):
return self
def _non_null_property_schema(property_schema: object) -> dict[str, Any]:
if not isinstance(property_schema, dict):
return {}
any_of = property_schema.get("anyOf")
if isinstance(any_of, list):
non_null_candidates = [
candidate for candidate in any_of if isinstance(candidate, dict) and candidate.get("type") != "null"
]
if len(non_null_candidates) == 1:
return {
**{key: value for key, value in property_schema.items() if key != "anyOf"},
**deepcopy(non_null_candidates[0]),
}
return deepcopy(property_schema)
DocumentDisplayStatus = Annotated[
str | None,
WithJsonSchema(
{
"anyOf": [
{
"enum": ["queuing", "indexing", "paused", "error", "available", "disabled", "archived"],
"type": "string",
},
{"type": "null"},
]
}
),
]
class DocumentListQuery(BaseModel):
page: int = Field(default=1, description="Page number to retrieve.")
limit: int = Field(default=20, description="Number of items per page. Server caps at `100`.")
keyword: str | None = Field(default=None, description="Search keyword to filter by document name.")
status: DocumentDisplayStatus = Field(default=None, description="Filter by display status.")
page: int = Field(default=1, description="Page number")
limit: int = Field(default=20, description="Number of items per page")
keyword: str | None = Field(default=None, description="Search keyword")
status: str | None = Field(default=None, description="Document status filter")
class DocumentGetQuery(BaseModel):
metadata: Literal["all", "only", "without"] = Field(
default="all",
description=(
"`all` returns all fields including metadata. `only` returns only `id`, `doc_type`, and "
"`doc_metadata`. `without` returns all fields except `doc_metadata`."
),
)
metadata: Literal["all", "only", "without"] = Field(default="all", description="Metadata response mode")
DOCUMENT_CREATE_BY_FILE_PARAMS = {
"dataset_id": "Knowledge base ID.",
"dataset_id": "Dataset ID",
"file": {
"in": "formData",
"type": "file",
@ -245,32 +134,23 @@ DOCUMENT_CREATE_BY_FILE_PARAMS = {
"in": "formData",
"type": "string",
"required": False,
"description": (
"JSON string containing configuration. Accepts the same fields as "
"[Create Document by Text](/api-reference/documents/create-document-by-text) (`indexing_technique`, "
"`doc_form`, `doc_language`, `process_rule`, `retrieval_model`, `embedding_model`, "
"`embedding_model_provider`) except `name` and `text`."
),
"description": "Optional JSON string with document creation settings.",
},
}
DOCUMENT_UPDATE_BY_FILE_PARAMS = {
"dataset_id": "Knowledge base ID.",
"document_id": "Document ID.",
"dataset_id": "Dataset ID",
"document_id": "Document ID",
"file": {
"in": "formData",
"type": "file",
"required": False,
"description": "Replacement document file to upload.",
"description": "Replacement document file.",
},
"data": {
"in": "formData",
"type": "string",
"required": False,
"description": (
"JSON string containing document update settings such as `doc_form`, `doc_language`, `process_rule`, "
"`retrieval_model`, `embedding_model`, and `embedding_model_provider`. `name` and `text` are not used "
"for file updates."
),
"description": "Optional JSON string with document update settings.",
},
}
@ -471,28 +351,10 @@ def _update_document_by_text(tenant_id: str, dataset_id: UUID, document_id: UUID
class DocumentAddByTextApi(DatasetApiResource):
"""Resource for the canonical text document creation route."""
@service_api_ns.doc(
summary="Create Document by Text",
description=(
"Create a document from raw text content. The document is processed asynchronously — use the "
"returned `batch` ID with [Get Document Indexing Status](/api-reference/documents/"
"get-document-indexing-status) to track progress."
),
tags=["Documents"],
responses={
200: "Document created successfully.",
400: (
"- `provider_not_initialize` : No valid model provider credentials found. Please go to "
"Settings -> Model Provider to complete your provider credentials.\n"
"- `invalid_param` : Knowledge base does not exist. / indexing_technique is required. / "
"Invalid doc_form (must be `text_model`, `hierarchical_model`, or `qa_model`)."
),
},
)
@service_api_ns.expect(service_api_ns.models[DocumentTextCreatePayload.__name__])
@service_api_ns.doc("create_document_by_text")
@service_api_ns.doc(description="Create a new document by providing text content")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Document created successfully",
@ -524,7 +386,7 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource):
"Use /datasets/{dataset_id}/document/create-by-text instead."
)
)
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Document created successfully",
@ -547,29 +409,10 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource):
class DocumentUpdateByTextApi(DatasetApiResource):
"""Resource for the canonical text document update route."""
@service_api_ns.doc(
summary="Update Document by Text",
description=(
"Update an existing document's text content, name, or processing configuration. Re-triggers "
"indexing if content changes — use the returned `batch` ID with [Get Document Indexing "
"Status](/api-reference/documents/get-document-indexing-status) to track progress."
),
tags=["Documents"],
responses={
200: "Document updated successfully.",
400: (
"- `provider_not_initialize` : No valid model provider credentials found. Please go to "
"Settings -> Model Provider to complete your provider credentials.\n"
"- `invalid_param` : Knowledge base does not exist, name is required when text is "
"provided, or invalid doc_form (must be `text_model`, `hierarchical_model`, or "
"`qa_model`)."
),
},
)
@service_api_ns.expect(service_api_ns.models[DocumentTextUpdate.__name__])
@service_api_ns.doc("update_document_by_text")
@service_api_ns.doc(description="Update an existing document by providing text content")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
@ -600,7 +443,7 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
"Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead."
)
)
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Document updated successfully",
@ -620,42 +463,11 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
@service_api_ns.route(
"/datasets/<uuid:dataset_id>/document/create_by_file",
doc={
"post": {
"deprecated": True,
"description": (
"Deprecated legacy alias for creating a new document by uploading a file. "
"Use /datasets/{dataset_id}/document/create-by-file instead."
),
}
},
"/datasets/<uuid:dataset_id>/document/create-by-file",
)
@service_api_ns.route("/datasets/<uuid:dataset_id>/document/create-by-file")
class DocumentAddByFileApi(DatasetApiResource):
"""Resource for documents."""
@service_api_ns.doc(
summary="Create Document by File",
description=(
"Create a document by uploading a file. Supports common document formats (PDF, TXT, DOCX, "
"etc.). Processing is asynchronous — use the returned `batch` ID with [Get Document "
"Indexing Status](/api-reference/documents/get-document-indexing-status) to track progress."
),
tags=["Documents"],
responses={
200: "Document created successfully.",
400: (
"- `no_file_uploaded` : Please upload your file.\n"
"- `too_many_files` : Only one file is allowed.\n"
"- `filename_not_exists_error` : The specified filename does not exist.\n"
"- `provider_not_initialize` : No valid model provider credentials found. Please go to "
"Settings -> Model Provider to complete your provider credentials.\n"
"- `invalid_param` : Knowledge base does not exist, external datasets not supported, "
"file too large, unsupported file type, missing required fields, or invalid doc_form "
"(must be `text_model`, `hierarchical_model`, or `qa_model`)."
),
},
)
@service_api_ns.doc("create_document_by_file")
@service_api_ns.doc(description="Create a new document by uploading a file")
@service_api_ns.doc(consumes=["multipart/form-data"], params=DOCUMENT_CREATE_BY_FILE_PARAMS)
@ -846,27 +658,6 @@ def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID
class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
"""Deprecated resource aliases for file document updates."""
@service_api_ns.doc(
summary="Update Document by File",
description=(
"Update an existing document by uploading a new file. Re-triggers indexing — use the returned "
"`batch` ID with [Get Document Indexing Status](/api-reference/documents/"
"get-document-indexing-status) to track progress."
),
tags=["Documents"],
responses={
200: "Document updated successfully.",
400: (
"- `too_many_files` : Only one file is allowed.\n"
"- `filename_not_exists_error` : The specified filename does not exist.\n"
"- `provider_not_initialize` : No valid model provider credentials found. Please go to "
"Settings -> Model Provider to complete your provider credentials.\n"
"- `invalid_param` : Knowledge base does not exist, external datasets not supported, "
"file too large, unsupported file type, or invalid doc_form (must be `text_model`, "
"`hierarchical_model`, or `qa_model`)."
),
},
)
@service_api_ns.doc("update_document_by_file_deprecated")
@service_api_ns.doc(deprecated=True)
@service_api_ns.doc(
@ -895,21 +686,9 @@ class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents")
class DocumentListApi(DatasetApiResource):
@service_api_ns.doc(
summary="List Documents",
description=(
"Returns a paginated list of documents in the knowledge base. Supports filtering by keyword "
"and indexing status."
),
tags=["Documents"],
responses={
200: "List of documents.",
404: "`not_found` : Knowledge base not found.",
},
)
@service_api_ns.doc("list_documents")
@service_api_ns.doc(description="List all documents in a dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", **query_params_from_model(DocumentListQuery)})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", **query_params_from_model(DocumentListQuery)})
@service_api_ns.doc(
responses={
200: "Documents retrieved successfully",
@ -967,23 +746,10 @@ class DocumentListApi(DatasetApiResource):
class DocumentBatchDownloadZipApi(DatasetApiResource):
"""Download multiple uploaded-file documents as a single ZIP archive."""
@service_api_ns.doc(
summary="Download Documents as ZIP",
description=(
"Download multiple uploaded-file documents as a single ZIP archive. Accepts up to `100` document IDs."
),
tags=["Documents"],
responses={
200: "ZIP archive containing the requested documents.",
403: "`forbidden` : Insufficient permissions.",
404: "`not_found` : Document or dataset not found.",
},
)
@binary_response(service_api_ns, "application/zip")
@service_api_ns.expect(service_api_ns.models[DocumentBatchDownloadZipPayload.__name__])
@service_api_ns.doc("download_documents_as_zip")
@service_api_ns.doc(description="Download selected uploaded documents as a single ZIP archive")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "ZIP archive generated successfully",
@ -992,7 +758,11 @@ class DocumentBatchDownloadZipApi(DatasetApiResource):
404: "Document or dataset not found",
}
)
@service_api_ns.response(200, "ZIP archive generated successfully")
@service_api_ns.response(
200,
"ZIP archive generated successfully",
service_api_ns.models[BinaryFileResponse.__name__],
)
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
def post(self, tenant_id, dataset_id: UUID):
payload = DocumentBatchDownloadZipPayload.model_validate(service_api_ns.payload or {})
@ -1019,23 +789,9 @@ class DocumentBatchDownloadZipApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<string:batch>/indexing-status")
class DocumentIndexingStatusApi(DatasetApiResource):
@service_api_ns.doc(
summary="Get Document Indexing Status",
description=(
"Check the indexing progress of documents in a batch. Returns the current processing stage "
"and chunk completion counts for each document. Poll this endpoint until `indexing_status` "
"reaches `completed` or `error`. The status progresses through: `waiting` → `parsing` → "
"`cleaning` → `splitting` → `indexing` → `completed`."
),
tags=["Documents"],
responses={
200: "Indexing status for documents in the batch.",
404: "`not_found` : Knowledge base not found. / Documents not found.",
},
)
@service_api_ns.doc("get_document_indexing_status")
@service_api_ns.doc(description="Get indexing status for documents in a batch")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "batch": "Batch ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "batch": "Batch ID"})
@service_api_ns.doc(
responses={
200: "Indexing status retrieved successfully",
@ -1105,19 +861,9 @@ class DocumentIndexingStatusApi(DatasetApiResource):
class DocumentDownloadApi(DatasetApiResource):
"""Return a signed download URL for a document's original uploaded file."""
@service_api_ns.doc(
summary="Download Document",
description="Get a signed download URL for a document's original uploaded file.",
tags=["Documents"],
responses={
200: "Download URL generated successfully.",
403: "`forbidden` : No permission to access this document.",
404: "`not_found` : Document not found.",
},
)
@service_api_ns.doc("get_document_download_url")
@service_api_ns.doc(description="Get a signed download URL for a document's original uploaded file")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
200: "Download URL generated successfully",
@ -1149,27 +895,9 @@ class DocumentDownloadApi(DatasetApiResource):
class DocumentApi(DatasetApiResource):
METADATA_CHOICES = {"all", "only", "without"}
@service_api_ns.doc(
summary="Get Document",
description=(
"Retrieve detailed information about a specific document, including its indexing status, "
"metadata, and processing statistics."
),
tags=["Documents"],
responses={
200: (
"Document details. The response shape varies based on the `metadata` query parameter. When "
"`metadata` is `only`, only `id`, `doc_type`, and `doc_metadata` are returned. When "
"`metadata` is `without`, `doc_type` and `doc_metadata` are omitted."
),
400: "`invalid_metadata` : Invalid metadata value for the specified key.",
403: "`forbidden` : No permission.",
404: "`not_found` : Document not found.",
},
)
@service_api_ns.doc("get_document")
@service_api_ns.doc(description="Get a specific document by ID")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(params=query_params_from_model(DocumentGetQuery))
@service_api_ns.doc(
responses={
@ -1308,20 +1036,9 @@ class DocumentApi(DatasetApiResource):
"""Update document by file on the canonical document resource."""
return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.doc(
summary="Delete Document",
description="Permanently delete a document and all its chunks from the knowledge base.",
tags=["Documents"],
responses={
204: "Success.",
400: "`document_indexing` : Cannot delete document during indexing.",
403: "`archived_document_immutable` : The archived document is not editable.",
404: "`not_found` : Document Not Exists.",
},
)
@service_api_ns.doc("delete_document")
@service_api_ns.doc(description="Delete a document")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
204: "Document deleted successfully",

View File

@ -13,35 +13,9 @@ register_response_schema_models(service_api_ns, HitTestingResponse)
@service_api_ns.route("/datasets/<uuid:dataset_id>/hit-testing", "/datasets/<uuid:dataset_id>/retrieve")
class HitTestingApi(DatasetApiResource, DatasetsHitTestingBase):
@service_api_ns.doc(
summary="Retrieve Chunks from a Knowledge Base / Test Retrieval",
description=(
"Performs a search query against a knowledge base to retrieve the most relevant chunks. This "
"endpoint can be used for both production retrieval and test retrieval."
),
tags=["Knowledge Bases"],
responses={
200: "Retrieval results.",
400: (
"- `dataset_not_initialized` : The dataset is still being initialized or indexing. Please "
"wait a moment.\n"
"- `provider_not_initialize` : No valid model provider credentials found. Please go to "
"Settings -> Model Provider to complete your provider credentials.\n"
"- `provider_quota_exceeded` : Your quota for Dify Hosted OpenAI has been exhausted. Please "
"go to Settings -> Model Provider to complete your own provider credentials.\n"
"- `model_currently_not_support` : Dify Hosted OpenAI trial currently not support the GPT-4 "
"model.\n"
"- `completion_request_error` : Completion request failed.\n"
"- `invalid_param` : Invalid parameter value."
),
403: "`forbidden` : Insufficient permissions.",
404: "`not_found` : Knowledge base not found.",
500: "`internal_server_error` : An internal error occurred during retrieval.",
},
)
@service_api_ns.doc("dataset_hit_testing")
@service_api_ns.doc(description="Perform hit testing on a dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.response(
200,
"Hit testing results",

View File

@ -24,12 +24,6 @@ from services.entities.knowledge_entities.knowledge_entities import (
)
from services.metadata_service import MetadataService
BUILT_IN_METADATA_ACTION_PARAM = {
"description": "`enable` to activate built-in metadata fields, `disable` to deactivate them.",
"enum": ["enable", "disable"],
"type": "string",
}
register_schema_model(service_api_ns, MetadataUpdatePayload)
register_schema_models(
service_api_ns,
@ -49,21 +43,10 @@ register_response_schema_models(
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata")
class DatasetMetadataCreateServiceApi(DatasetApiResource):
@service_api_ns.doc(
summary="Create Metadata Field",
description=(
"Create a custom metadata field for the knowledge base. Metadata fields can be used to "
"annotate documents with structured information."
),
tags=["Metadata"],
responses={
201: "Metadata field created successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[MetadataArgs.__name__])
@service_api_ns.doc("create_dataset_metadata")
@service_api_ns.doc(description="Create metadata for a dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
201: "Metadata created successfully",
@ -88,20 +71,9 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args)
return dump_response(DatasetMetadataResponse, metadata), 201
@service_api_ns.doc(
summary="List Metadata Fields",
description=(
"Returns the list of all metadata fields (both custom and built-in) for the knowledge base, "
"along with the count of documents using each field."
),
tags=["Metadata"],
responses={
200: "Metadata fields for the knowledge base.",
},
)
@service_api_ns.doc("get_dataset_metadata")
@service_api_ns.doc(description="Get all metadata for a dataset")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Metadata retrieved successfully",
@ -124,18 +96,10 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata/<uuid:metadata_id>")
class DatasetMetadataServiceApi(DatasetApiResource):
@service_api_ns.doc(
summary="Update Metadata Field",
description="Rename a custom metadata field.",
tags=["Metadata"],
responses={
200: "Metadata field updated successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[MetadataUpdatePayload.__name__])
@service_api_ns.doc("update_dataset_metadata")
@service_api_ns.doc(description="Update metadata name")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "metadata_id": "Metadata ID"})
@service_api_ns.doc(
responses={
200: "Metadata updated successfully",
@ -161,20 +125,9 @@ class DatasetMetadataServiceApi(DatasetApiResource):
metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, payload.name)
return dump_response(DatasetMetadataResponse, metadata), 200
@service_api_ns.doc(
summary="Delete Metadata Field",
description=(
"Permanently delete a custom metadata field. Documents using this field will lose their "
"metadata values for it."
),
tags=["Metadata"],
responses={
204: "Success.",
},
)
@service_api_ns.doc("delete_dataset_metadata")
@service_api_ns.doc(description="Delete metadata")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "metadata_id": "Metadata ID"})
@service_api_ns.doc(
responses={
204: "Metadata deleted successfully",
@ -199,19 +152,8 @@ class DatasetMetadataServiceApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata/built-in")
class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource):
@service_api_ns.doc(
summary="Get Built-in Metadata Fields",
description=(
"Returns the list of built-in metadata fields provided by the system (e.g., document type, source URL)."
),
tags=["Metadata"],
responses={
200: "Built-in metadata fields.",
},
)
@service_api_ns.doc("get_built_in_fields")
@service_api_ns.doc(description="Get all built-in metadata fields")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(
responses={
200: "Built-in fields retrieved successfully",
@ -231,17 +173,9 @@ class DatasetMetadataBuiltInFieldServiceApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/metadata/built-in/<string:action>")
class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
@service_api_ns.doc(
summary="Update Built-in Metadata Field",
description="Enable or disable built-in metadata fields for the knowledge base.",
tags=["Metadata"],
responses={
200: "Built-in metadata field toggled successfully.",
},
)
@service_api_ns.doc("toggle_built_in_field")
@service_api_ns.doc(description="Enable or disable built-in metadata field")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "action": BUILT_IN_METADATA_ACTION_PARAM})
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "action": "Action to perform: 'enable' or 'disable'"})
@service_api_ns.doc(
responses={
200: "Action completed successfully",
@ -271,21 +205,10 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/metadata")
class DocumentMetadataEditServiceApi(DatasetApiResource):
@service_api_ns.doc(
summary="Update Document Metadata in Batch",
description=(
"Update metadata values for multiple documents at once. Each document in the request "
"receives the specified metadata key-value pairs."
),
tags=["Metadata"],
responses={
200: "Document metadata updated successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[MetadataOperationData.__name__])
@service_api_ns.doc("update_documents_metadata")
@service_api_ns.doc(description="Update metadata for multiple documents")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
@service_api_ns.doc(
responses={
200: "Documents metadata updated successfully",

View File

@ -19,11 +19,6 @@ from controllers.common.schema import (
from controllers.service_api import service_api_ns
from controllers.service_api.dataset.error import PipelineRunError
from controllers.service_api.dataset.rag_pipeline.serializers import serialize_upload_file
from controllers.service_api.schema import (
event_stream_response,
json_or_event_stream_response,
multipart_file_params,
)
from controllers.service_api.wraps import DatasetApiResource
from core.app.apps.pipeline.pipeline_generator import PipelineGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
@ -37,7 +32,6 @@ from services.errors.file import FileTooLargeError, UnsupportedFileTypeError
from services.file_service import FileService
from services.rag_pipeline.entity.pipeline_service_api_entities import (
DatasourceNodeRunApiEntity,
DatasourceType,
PipelineRunApiEntity,
)
from services.rag_pipeline.pipeline_generate_service import PipelineGenerateService
@ -45,27 +39,14 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService
class DatasourceNodeRunPayload(BaseModel):
inputs: dict[str, Any] = Field(description="Input variables for the datasource node.")
datasource_type: DatasourceType = Field(description="Type of the datasource.")
credential_id: str | None = Field(
default=None, description="Datasource credential ID. Uses the default if omitted."
)
is_published: bool = Field(
description=(
"Whether to run the published or draft version of the node. `true` runs the published version, "
"`false` runs the draft."
)
)
inputs: dict[str, Any]
datasource_type: str
credential_id: str | None = None
is_published: bool
class DatasourcePluginsQuery(BaseModel):
is_published: bool = Field(
default=True,
description=(
"Whether to retrieve nodes from the published or draft pipeline. `true` returns nodes from the published "
"version, `false` returns nodes from the draft."
),
)
is_published: bool = True
class DatasourceCredentialInfoResponse(ResponseModel):
@ -114,21 +95,13 @@ register_response_schema_models(
class DatasourcePluginsApi(DatasetApiResource):
"""Resource for datasource plugins."""
@service_api_ns.doc(
summary="List Datasource Plugins",
description=(
"List the datasource nodes configured in the knowledge pipeline. Each node includes the "
"plugin it uses plus the metadata needed to run it."
),
tags=["Knowledge Pipeline"],
responses={
200: "List of datasource nodes configured in the pipeline.",
404: "`not_found` : Dataset not found.",
},
)
@service_api_ns.doc(shortcut="list_rag_pipeline_datasource_plugins")
@service_api_ns.doc(description="List all datasource plugins for a rag pipeline")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(
path={
"dataset_id": "Dataset ID",
}
)
@service_api_ns.doc(params=query_params_from_model(DatasourcePluginsQuery))
@service_api_ns.doc(
responses={
@ -164,22 +137,13 @@ class DatasourcePluginsApi(DatasetApiResource):
class DatasourceNodeRunApi(DatasetApiResource):
"""Resource for datasource node run."""
@service_api_ns.doc(
summary="Run Datasource Node",
description=(
"Execute a single datasource node within the knowledge pipeline. Returns a streaming "
"response with the node execution results."
),
tags=["Knowledge Pipeline"],
responses={
200: "Streaming response with node execution events.",
404: "`not_found` : Dataset not found.",
},
)
@event_stream_response(service_api_ns)
@service_api_ns.doc(shortcut="pipeline_datasource_node_run")
@service_api_ns.doc(description="Run a datasource node for a rag pipeline")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "node_id": "ID of the datasource node to execute."})
@service_api_ns.doc(
path={
"dataset_id": "Dataset ID",
}
)
@service_api_ns.doc(
responses={
200: "Datasource node run successfully",
@ -231,27 +195,13 @@ class DatasourceNodeRunApi(DatasetApiResource):
class PipelineRunApi(DatasetApiResource):
"""Resource for datasource node run."""
@service_api_ns.doc(
summary="Run Pipeline",
description=(
"Execute the full knowledge pipeline for a knowledge base. Supports both streaming and "
"blocking response modes."
),
tags=["Knowledge Pipeline"],
responses={
200: (
"Pipeline execution result. Format depends on `response_mode`: streaming returns a "
"`text/event-stream`, blocking returns a JSON object."
),
403: "`forbidden` : Forbidden.",
404: "`not_found` : Dataset not found.",
500: "`pipeline_run_error` : Pipeline execution failed.",
},
)
@json_or_event_stream_response(service_api_ns)
@service_api_ns.doc(shortcut="pipeline_datasource_node_run")
@service_api_ns.doc(description="Run a datasource node for a rag pipeline")
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
@service_api_ns.doc(
path={
"dataset_id": "Dataset ID",
}
)
@service_api_ns.doc(
responses={
200: "Pipeline run successfully",
@ -298,24 +248,8 @@ class PipelineRunApi(DatasetApiResource):
class KnowledgebasePipelineFileUploadApi(DatasetApiResource):
"""Resource for uploading a file to a knowledgebase pipeline."""
@service_api_ns.doc(
summary="Upload Pipeline File",
description="Upload a file for use in a knowledge pipeline. Accepts a single file via `multipart/form-data`.",
tags=["Knowledge Pipeline"],
responses={
201: "File uploaded successfully.",
400: (
"- `no_file_uploaded` : Please upload your file.\n"
"- `filename_not_exists_error` : The specified filename does not exist.\n"
"- `too_many_files` : Only one file is allowed."
),
413: "`file_too_large` : File size exceeded.",
415: "`unsupported_file_type` : File type not allowed.",
},
)
@service_api_ns.doc(shortcut="knowledgebase_pipeline_file_upload")
@service_api_ns.doc(description="Upload a file to a knowledgebase pipeline")
@service_api_ns.doc(consumes=["multipart/form-data"], params=multipart_file_params(include_user=False))
@service_api_ns.doc(
responses={
201: "File uploaded successfully",

View File

@ -47,10 +47,10 @@ from services.summary_index_service import SummaryIndexService
class SegmentCreateItemPayload(BaseModel):
content: str = Field(min_length=1, description="Chunk text content.")
answer: str | None = Field(default=None, description="Answer content for QA mode.")
keywords: list[str] | None = Field(default=None, description="Keywords for the chunk.")
attachment_ids: list[str] | None = Field(default=None, description="Attachment file IDs.")
content: str = Field(min_length=1)
answer: str | None = None
keywords: list[str] | None = None
attachment_ids: list[str] | None = None
@field_validator("content")
@classmethod
@ -61,34 +61,31 @@ class SegmentCreateItemPayload(BaseModel):
class SegmentCreatePayload(BaseModel):
segments: list[SegmentCreateItemPayload] = Field(min_length=1, description="Array of chunk objects to create.")
segments: list[SegmentCreateItemPayload] = Field(min_length=1)
class SegmentListQuery(BaseModel):
limit: int = Field(default=20, ge=1, description="Number of items per page. Server caps at `100`.")
page: int = Field(default=1, ge=1, description="Page number to retrieve.")
status: list[str] = Field(
default_factory=list,
description="Filter chunks by indexing status, such as `completed`, `indexing`, or `error`.",
)
keyword: str | None = Field(default=None, description="Search keyword.")
limit: int = Field(default=20, ge=1)
page: int = Field(default=1, ge=1)
status: list[str] = Field(default_factory=list)
keyword: str | None = None
class SegmentUpdatePayload(BaseModel):
segment: SegmentUpdateArgs = Field(description="Chunk update payload.")
segment: SegmentUpdateArgs
class ChildChunkListQuery(BaseModel):
limit: int = Field(default=20, ge=1, description="Number of items per page. Server caps at `100`.")
keyword: str | None = Field(default=None, description="Search keyword.")
page: int = Field(default=1, ge=1, description="Page number to retrieve.")
limit: int = Field(default=20, ge=1)
keyword: str | None = None
page: int = Field(default=1, ge=1)
class SegmentDocParams:
DATASET_DOCUMENT = {"dataset_id": "Knowledge base ID.", "document_id": "Document ID."}
DATASET_DOCUMENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Chunk ID."}
DATASET_DOCUMENT_PARENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Chunk ID."}
DATASET_DOCUMENT_CHILD_CHUNK = {**DATASET_DOCUMENT_PARENT_SEGMENT, "child_chunk_id": "Child chunk ID."}
DATASET_DOCUMENT = {"dataset_id": "Dataset ID", "document_id": "Document ID"}
DATASET_DOCUMENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Segment ID"}
DATASET_DOCUMENT_PARENT_SEGMENT = {**DATASET_DOCUMENT, "segment_id": "Parent segment ID"}
DATASET_DOCUMENT_CHILD_CHUNK = {**DATASET_DOCUMENT_PARENT_SEGMENT, "child_chunk_id": "Child chunk ID"}
class SegmentCreateListResponse(ResponseModel):
@ -131,18 +128,6 @@ register_response_schema_models(
class SegmentApi(DatasetApiResource):
"""Resource for segments."""
@service_api_ns.doc(
summary="Create Chunks",
description=(
"Create one or more chunks within a document. Each chunk can include optional keywords and an "
"answer field (for QA-mode documents)."
),
tags=["Chunks"],
responses={
200: "Chunks created successfully.",
404: "`not_found` : Document is not completed or is disabled.",
},
)
@service_api_ns.expect(service_api_ns.models[SegmentCreatePayload.__name__])
@service_api_ns.doc("create_segments")
@service_api_ns.doc(description="Create segments in a document")
@ -224,14 +209,6 @@ class SegmentApi(DatasetApiResource):
}
return dump_response(SegmentCreateListResponse, response), 200
@service_api_ns.doc(
summary="List Chunks",
description="Returns a paginated list of chunks within a document. Supports filtering by keyword and status.",
tags=["Chunks"],
responses={
200: "List of chunks.",
},
)
@service_api_ns.doc("list_segments")
@service_api_ns.doc(description="List segments in a document")
@service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT)
@ -317,14 +294,6 @@ class SegmentApi(DatasetApiResource):
@service_api_ns.route("/datasets/<uuid:dataset_id>/documents/<uuid:document_id>/segments/<uuid:segment_id>")
class DatasetSegmentApi(DatasetApiResource):
@service_api_ns.doc(
summary="Delete Chunk",
description="Permanently delete a chunk from the document.",
tags=["Chunks"],
responses={
204: "Success.",
},
)
@service_api_ns.doc("delete_segment")
@service_api_ns.doc(description="Delete a specific segment")
@service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_SEGMENT)
@ -360,14 +329,6 @@ class DatasetSegmentApi(DatasetApiResource):
SegmentService.delete_segment(segment, document, dataset)
return "", 204
@service_api_ns.doc(
summary="Update Chunk",
description="Update a chunk's content, keywords, or answer. Re-triggers indexing for the modified chunk.",
tags=["Chunks"],
responses={
200: "Chunk updated successfully.",
},
)
@service_api_ns.expect(service_api_ns.models[SegmentUpdatePayload.__name__])
@service_api_ns.doc("update_segment")
@service_api_ns.doc(description="Update a specific segment")
@ -430,17 +391,6 @@ class DatasetSegmentApi(DatasetApiResource):
}
return dump_response(SegmentDetailResponse, response), 200
@service_api_ns.doc(
summary="Get Chunk",
description=(
"Retrieve detailed information about a specific chunk, including its content, keywords, and "
"indexing status."
),
tags=["Chunks"],
responses={
200: "Chunk details.",
},
)
@service_api_ns.doc("get_segment")
@service_api_ns.doc(description="Get a specific segment by ID")
@service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_SEGMENT)
@ -492,15 +442,6 @@ class DatasetSegmentApi(DatasetApiResource):
class ChildChunkApi(DatasetApiResource):
"""Resource for child chunks."""
@service_api_ns.doc(
summary="Create Child Chunk",
description="Create a child chunk under the specified segment.",
tags=["Chunks"],
responses={
200: "Child chunk created successfully.",
400: "`invalid_param` : Create child chunk index failed.",
},
)
@service_api_ns.expect(service_api_ns.models[ChildChunkCreatePayload.__name__])
@service_api_ns.doc("create_child_chunk")
@service_api_ns.doc(description="Create a new child chunk for a segment")
@ -570,14 +511,6 @@ class ChildChunkApi(DatasetApiResource):
return dump_response(ChildChunkDetailResponse, {"data": child_chunk}), 200
@service_api_ns.doc(
summary="List Child Chunks",
description="Returns a paginated list of child chunks under a specific parent chunk.",
tags=["Chunks"],
responses={
200: "List of child chunks.",
},
)
@service_api_ns.doc("list_child_chunks")
@service_api_ns.doc(description="List child chunks for a segment")
@service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_PARENT_SEGMENT)
@ -643,15 +576,6 @@ class ChildChunkApi(DatasetApiResource):
class DatasetChildChunkApi(DatasetApiResource):
"""Resource for updating child chunks."""
@service_api_ns.doc(
summary="Delete Child Chunk",
description="Permanently delete a child chunk from its parent chunk.",
tags=["Chunks"],
responses={
204: "Success.",
400: "`invalid_param` : Delete child chunk index failed.",
},
)
@service_api_ns.doc("delete_child_chunk")
@service_api_ns.doc(description="Delete a specific child chunk")
@service_api_ns.doc(params=SegmentDocParams.DATASET_DOCUMENT_CHILD_CHUNK)
@ -710,15 +634,6 @@ class DatasetChildChunkApi(DatasetApiResource):
return "", 204
@service_api_ns.doc(
summary="Update Child Chunk",
description="Update the content of an existing child chunk.",
tags=["Chunks"],
responses={
200: "Child chunk updated successfully.",
400: "`invalid_param` : Update child chunk index failed.",
},
)
@service_api_ns.expect(service_api_ns.models[ChildChunkUpdatePayload.__name__])
@service_api_ns.doc("update_child_chunk")
@service_api_ns.doc(description="Update a specific child chunk")

View File

@ -17,18 +17,6 @@ register_response_schema_models(service_api_ns, EndUserDetail)
class EndUserApi(Resource):
"""Resource for retrieving end user details by ID."""
@service_api_ns.doc(
summary="Get End User Info",
description=(
"Retrieve an end user by ID. Useful when other APIs return an end-user ID (e.g., "
"`created_by` from [Upload File](/api-reference/files/upload-file))."
),
tags=["End Users"],
responses={
200: "End user retrieved successfully.",
404: "`end_user_not_found` : End user not found.",
},
)
@service_api_ns.doc("get_end_user")
@service_api_ns.doc(description="Get an end user by ID")
@service_api_ns.doc(

View File

@ -1,167 +0,0 @@
"""Service API OpenAPI documentation helpers.
These helpers keep documentation-only request shapes next to controller
definitions without changing the Pydantic models used for runtime validation.
"""
from __future__ import annotations
from collections.abc import Sequence
from copy import deepcopy
from typing import Annotated, Any, cast
from flask_restx import Namespace
from pydantic import BaseModel, WithJsonSchema
USER_DESCRIPTION = (
"User identifier, unique within the application. This identifier scopes data access; resources created with "
"one `user` value are only visible when queried with the same `user` value."
)
USER_PROPERTY_SCHEMA: dict[str, object] = {"description": USER_DESCRIPTION, "type": "string"}
USER_QUERY_PARAM: dict[str, object] = {
"description": "User identifier, used for end-user context.",
"in": "query",
"type": "string",
}
USER_FORM_PARAM: dict[str, object] = {
"description": USER_DESCRIPTION,
"in": "formData",
"type": "string",
}
FILE_FORM_PARAM: dict[str, object] = {
"description": "The file to upload.",
"in": "formData",
"required": True,
"type": "file",
}
USER_FETCH_FROM_ATTR = "_dify_service_api_user_fetch_from"
USER_REQUIRED_ATTR = "_dify_service_api_user_required"
JSON_USER_FETCH_FROM = "JSON"
INPUT_FILE_ITEM_SCHEMA: dict[str, object] = {
"type": "object",
"required": ["type", "transfer_method"],
"properties": {
"type": {
"description": "File type.",
"enum": ["document", "image", "audio", "video", "custom"],
"type": "string",
},
"transfer_method": {
"description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.",
"enum": ["remote_url", "local_file"],
"type": "string",
},
"url": {
"description": "File URL when `transfer_method` is `remote_url`.",
"format": "url",
"type": "string",
},
"upload_file_id": {
"description": (
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when "
"`transfer_method` is `local_file`."
),
"type": "string",
},
},
}
INPUT_FILE_LIST_SCHEMA: dict[str, object] = {
"anyOf": [{"items": INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}]
}
InputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(INPUT_FILE_LIST_SCHEMA)]
def expect_with_user(namespace: Namespace, model: type[BaseModel]):
"""Document a JSON request body as ``model`` plus Service API ``user``."""
source_model = namespace.models[model.__name__]
model_name = f"{model.__name__}WithUser"
def decorator(view_func):
required = _json_user_required(view_func)
schema = cast(dict[str, object], deepcopy(source_model.__schema__))
_add_user_property(schema, required=required)
if model_name not in namespace.models:
namespace.schema_model(model_name, schema)
return namespace.expect(namespace.models[model_name], validate=False)(view_func)
return decorator
def expect_user_json(namespace: Namespace):
"""Document a JSON request body that only carries the Service API ``user``."""
def decorator(view_func):
required = _json_user_required(view_func)
schema: dict[str, object] = {"properties": {}, "title": "ServiceApiUserPayload", "type": "object"}
_add_user_property(schema, required=required)
model_name = "RequiredServiceApiUserPayload" if required else "OptionalServiceApiUserPayload"
if model_name not in namespace.models:
namespace.schema_model(model_name, schema)
return namespace.expect(namespace.models[model_name], validate=False)(view_func)
return decorator
def multipart_file_params(*, include_user: bool, file_description: str | None = None) -> dict[str, dict[str, object]]:
file_param = deepcopy(FILE_FORM_PARAM)
if file_description is not None:
file_param["description"] = file_description
params: dict[str, dict[str, object]] = {"file": file_param}
if include_user:
params["user"] = USER_FORM_PARAM
return deepcopy(params)
def json_or_event_stream_response(namespace: Namespace):
return namespace.doc(produces=["application/json", "text/event-stream"])
def event_stream_response(namespace: Namespace):
return namespace.doc(produces=["text/event-stream"])
def binary_response(namespace: Namespace, media_type: str | Sequence[str]):
media_types = [media_type] if isinstance(media_type, str) else list(media_type)
return namespace.doc(produces=media_types)
def _json_user_required(view_func) -> bool:
fetch_from = getattr(view_func, USER_FETCH_FROM_ATTR, None)
if fetch_from != JSON_USER_FETCH_FROM:
raise ValueError("JSON user documentation must match validate_app_token(fetch_user_arg=WhereisUserArg.JSON)")
return bool(getattr(view_func, USER_REQUIRED_ATTR, False))
def _add_user_property(schema: dict[str, object], *, required: bool) -> None:
variants: list[dict[str, object]] = []
for keyword in ("anyOf", "oneOf"):
candidates = schema.get(keyword)
if isinstance(candidates, list):
variants.extend(candidate for candidate in candidates if isinstance(candidate, dict))
if variants:
for variant in variants:
_add_user_property_to_object_schema(variant, required=required)
_add_user_property_to_object_schema(schema, required=required)
def _add_user_property_to_object_schema(schema: dict[str, object], *, required: bool) -> None:
properties = schema.setdefault("properties", {})
if isinstance(properties, dict):
cast(dict[str, object], properties)["user"] = USER_PROPERTY_SCHEMA
if required:
required_fields = schema.setdefault("required", [])
if isinstance(required_fields, list) and "user" not in required_fields:
required_fields.append("user")
else:
required_fields = schema.get("required")
if isinstance(required_fields, list) and "user" in required_fields:
required_fields.remove("user")
if required_fields == []:
schema.pop("required", None)

View File

@ -9,12 +9,6 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder
from services.entities.model_provider_entities import ProviderWithModelsResponse
from services.model_provider_service import ModelProviderService
MODEL_TYPE_PARAM = {
"description": "Type of model to retrieve.",
"enum": ["text-embedding", "rerank", "llm", "tts", "speech2text", "moderation"],
"type": "string",
}
class ProviderWithModelsListResponse(ResponseModel):
data: list[ProviderWithModelsResponse]
@ -25,20 +19,9 @@ register_response_schema_models(service_api_ns, ProviderWithModelsListResponse)
@service_api_ns.route("/workspaces/current/models/model-types/<string:model_type>")
class ModelProviderAvailableModelApi(Resource):
@service_api_ns.doc(
summary="Get Available Models",
description=(
"Retrieve the list of available models by type. Primarily used to query `text-embedding` and "
"`rerank` models for knowledge base configuration."
),
tags=["Models"],
responses={
200: "Available models for the specified type.",
},
)
@service_api_ns.doc("get_available_models")
@service_api_ns.doc(description="Get available models by model type")
@service_api_ns.doc(params={"model_type": MODEL_TYPE_PARAM})
@service_api_ns.doc(params={"model_type": "Type of model to retrieve"})
@service_api_ns.doc(
responses={
200: "Models retrieved successfully",

View File

@ -4,23 +4,16 @@ import time
from collections.abc import Callable
from enum import StrEnum, auto
from functools import wraps
from typing import Protocol, cast, overload
from typing import cast, overload
from flask import current_app, request
from flask_login import user_logged_in
from flask_restx import Resource
from flask_restx.utils import merge
from pydantic import BaseModel
from sqlalchemy import select
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from configs import dify_config
from controllers.service_api.schema import (
USER_FETCH_FROM_ATTR,
USER_FORM_PARAM,
USER_QUERY_PARAM,
USER_REQUIRED_ATTR,
)
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
@ -35,12 +28,6 @@ from services.feature_service import FeatureService
logger = logging.getLogger(__name__)
class _RestxDocumentedView(Protocol):
"""Callable view object carrying Flask-RESTX documentation metadata."""
__apidoc__: dict[str, object]
class WhereisUserArg(StrEnum):
"""
Enum for whereis_user_arg.
@ -56,35 +43,6 @@ class FetchUserArg(BaseModel):
required: bool = False
APP_TOKEN_FORBIDDEN_RESPONSE = {
403: "Forbidden - token scope, app, dataset, or workspace access denied",
}
DATASET_TOKEN_AUTH_RESPONSES = {
401: "Unauthorized - invalid API token",
403: "Forbidden - dataset API access or workspace access denied",
}
def _document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
doc: dict[str, object] = {"responses": APP_TOKEN_FORBIDDEN_RESPONSE}
if fetch_user_arg is not None:
setattr(view_func, USER_FETCH_FROM_ATTR, fetch_user_arg.fetch_from.name)
setattr(view_func, USER_REQUIRED_ATTR, fetch_user_arg.required)
match fetch_user_arg.fetch_from:
case WhereisUserArg.QUERY:
doc["params"] = {"user": {**USER_QUERY_PARAM, "required": fetch_user_arg.required}}
case WhereisUserArg.FORM:
doc["params"] = {"user": {**USER_FORM_PARAM, "required": fetch_user_arg.required}}
case WhereisUserArg.JSON:
pass
cast(_RestxDocumentedView, view_func).__apidoc__ = cast(
dict[str, object],
merge(getattr(view_func, "__apidoc__", {}), doc),
)
@overload
def validate_app_token[**P, R](view: Callable[P, R]) -> Callable[P, R]: ...
@ -168,7 +126,6 @@ def validate_app_token[**P, R](
return view_func(*args, **kwargs)
_document_app_token_contract(decorated_view, fetch_user_arg)
return decorated_view
if view is None:
@ -386,8 +343,6 @@ def validate_and_get_api_token(scope: str | None = None):
class DatasetApiResource(Resource):
__apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES}
method_decorators = [validate_dataset_token]
def get_dataset(self, dataset_id: str, tenant_id: str) -> Dataset:

View File

@ -118,7 +118,7 @@ class BaseAgentRunner(AppRunner):
features = model_schema.features if model_schema and model_schema.features else []
self.stream_tool_call = ModelFeature.STREAM_TOOL_CALL in features
self.files = application_generate_entity.files if ModelFeature.VISION in features else []
self.query: str = ""
self.query: str | None = ""
self._current_thoughts: list[PromptMessage] = []
def _repack_app_generate_entity(

View File

@ -1,5 +1,6 @@
import json
import logging
import re
import time
from collections.abc import Callable, Generator, Mapping
from contextlib import contextmanager
@ -1009,7 +1010,11 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
if message.status == MessageStatus.PAUSED:
message.status = MessageStatus.NORMAL
# If there are assistant files, remove markdown image links from answer
answer_text = self._task_state.answer
if self._recorded_files:
# Remove markdown image links since we're storing files separately
answer_text = re.sub(r"!\[.*?\]\(.*?\)", "", answer_text).strip()
message.answer = answer_text
message.updated_at = naive_utc_now()

View File

@ -72,12 +72,8 @@ class AgentAppGenerator(MessageBasedAppGenerator):
query = query.replace("\x00", "")
inputs = args["inputs"]
# Resolve the bound roster Agent + its current Agent Soul snapshot.
# Resolve the bound roster Agent + its published Agent Soul snapshot.
agent, snapshot, agent_soul = self._resolve_agent(app_model)
runtime_session_snapshot_id = self._runtime_session_snapshot_id(
invoke_from=invoke_from,
snapshot_id=snapshot.id,
)
conversation = None
conversation_id = args.get("conversation_id")
@ -124,7 +120,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
agent_runtime_session_snapshot_id=runtime_session_snapshot_id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
@ -163,110 +158,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
)
return AgentAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
def resume_after_form_submission(
self,
*,
app_model: App,
user: Account | EndUser,
conversation_id: str,
invoke_from: InvokeFrom,
) -> None:
"""Resume an Agent App conversation after a submitted ask_human HITL form.
ENG-635: triggered by a background task (not an HTTP request). Runs one
blocking turn with no user query; the runner threads the human's reply
into the agent run as deferred_tool_results and the assistant answer is
persisted to the conversation. Live streaming to a reconnected client is
out of scope here — the message is persisted and can be re-fetched.
"""
agent, snapshot, agent_soul = self._resolve_agent(app_model)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=conversation_id, user=user
)
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model,
agent_soul=agent_soul,
app_model_config=app_model.app_model_config,
conversation=conversation,
)
model_conf = ModelConfigConverter.convert(app_config)
trace_manager = TraceQueueManager(app_model.id, user.id if isinstance(user, Account) else user.session_id)
# ENG-638: the agent backend requires the resume composition's layer
# names to match the suspended snapshot, which includes the per-turn
# user-prompt layer. So re-send the original user message (the paused
# turn's query); the continuation is driven by deferred_tool_results and
# the restored snapshot, not by re-processing this prompt. A blank prompt
# would drop the user-prompt layer and fail the snapshot match.
paused_message = db.session.scalar(
select(Message)
.where(Message.conversation_id == conversation.id, Message.query != "")
.order_by(Message.created_at.desc())
.limit(1)
)
resume_query = paused_message.query if paused_message and paused_message.query else "(resumed)"
application_generate_entity = AgentAppGenerateEntity(
task_id=str(uuid.uuid4()),
app_config=app_config,
model_conf=model_conf,
conversation_id=conversation.id,
# A resume carries no new user inputs; the human's answer is the
# submitted form, threaded in by the runner as deferred_tool_results.
# The query re-sends the paused turn's message (see above).
inputs={},
query=resume_query,
files=[],
parent_message_id=UUID_NIL,
user_id=user.id,
stream=False,
invoke_from=invoke_from,
extras={"auto_generate_conversation_name": False},
call_depth=0,
trace_manager=trace_manager,
agent_id=agent.id,
agent_config_snapshot_id=snapshot.id,
)
conversation, message = self._init_generate_records(application_generate_entity, conversation)
queue_manager = MessageBasedAppQueueManager(
task_id=application_generate_entity.task_id,
user_id=application_generate_entity.user_id,
invoke_from=application_generate_entity.invoke_from,
conversation_id=conversation.id,
app_mode=conversation.mode,
message_id=message.id,
)
context = contextvars.copy_context()
worker_thread = threading.Thread(
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"context": context,
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
"conversation_id": conversation.id,
"message_id": message.id,
"user_from": UserFrom.ACCOUNT if isinstance(user, Account) else UserFrom.END_USER,
# Resume continues a paused agent run; skip input guards (see _generate_worker).
"is_resume": True,
},
)
worker_thread.start()
# Blocking: drive the chat task pipeline to persist the assistant answer.
self._handle_response(
application_generate_entity=application_generate_entity,
queue_manager=queue_manager,
conversation=conversation,
message=message,
user=user,
stream=False,
)
def _generate_worker(
self,
*,
@ -277,7 +168,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
conversation_id: str,
message_id: str,
user_from: UserFrom,
is_resume: bool = False,
) -> None:
from libs.flask_utils import preserve_flask_contexts
@ -287,30 +177,20 @@ class AgentAppGenerator(MessageBasedAppGenerator):
message = self._get_message(message_id)
app_config = application_generate_entity.app_config
if is_resume:
# ENG-638: a resume continues a paused agent run; the human's
# reply is threaded in by the runner as deferred_tool_results.
# The query is the replayed paused-turn message, kept only to
# match the suspended snapshot's layers — it is NOT new
# end-user input, so input guards must NOT run. Moderation or an
# annotation match on the replayed query would short-circuit the
# turn and drop the human reply, stranding the ask_human session.
query = application_generate_entity.query or ""
else:
# Apply app-level input guards (content moderation + annotation
# reply) before reaching the Agent backend, mirroring the EasyUI
# chat / agent-chat runners. These can short-circuit the turn.
app_model = db.session.get(App, app_config.app_id)
if app_model is None:
raise AgentAppGeneratorError("App not found")
handled, query = self._run_input_guards(
application_generate_entity=application_generate_entity,
app_model=app_model,
message=message,
queue_manager=queue_manager,
)
if handled:
return
# Apply app-level input guards (content moderation + annotation
# reply) before reaching the Agent backend, mirroring the EasyUI
# chat / agent-chat runners. These can short-circuit the turn.
app_model = db.session.get(App, app_config.app_id)
if app_model is None:
raise AgentAppGeneratorError("App not found")
handled, query = self._run_input_guards(
application_generate_entity=application_generate_entity,
app_model=app_model,
message=message,
queue_manager=queue_manager,
)
if handled:
return
dify_context = DifyRunContext(
tenant_id=app_config.tenant_id,
@ -346,7 +226,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
message_id=message.id,
model_name=application_generate_entity.model_conf.model,
queue_manager=queue_manager,
session_scope_snapshot_id=application_generate_entity.agent_runtime_session_snapshot_id,
)
except GenerateTaskStoppedError:
pass
@ -379,7 +258,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
app_config = application_generate_entity.app_config
model_name = application_generate_entity.model_conf.model
query = application_generate_entity.query or ""
query = application_generate_entity.query
# content moderation (sensitive_word_avoidance); a blocked input yields a
# preset answer, an "overridden" action returns a sanitized query.
@ -394,7 +273,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
trace_manager=application_generate_entity.trace_manager,
)
except ModerationError as e:
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e))
return True, query
# annotation reply: a matching annotation answers the turn deterministically.
@ -411,12 +290,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
QueueAnnotationReplyEvent(message_annotation_id=annotation_reply.id),
PublishFrom.APPLICATION_MANAGER,
)
publish_text_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=annotation_reply.content,
user_query=query,
)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=annotation_reply.content)
return True, query
return False, query
@ -436,21 +310,6 @@ class AgentAppGenerator(MessageBasedAppGenerator):
tenant_id=app_model.tenant_id, agent_id=agent.id, snapshot_id=agent.active_config_snapshot_id
)
@staticmethod
def _runtime_session_snapshot_id(*, invoke_from: InvokeFrom, snapshot_id: str) -> str | None:
"""Return the session scope snapshot id for Agent App runtime state.
Console preview/debug chat is an editing workspace: saving Agent Soul
creates replacement snapshots, but the user expects the same preview
conversation to keep context while trying prompt changes. Use a stable
NULL snapshot scope for debugger runs so each turn can use the latest
Agent Soul while reusing the conversation history. Published/web/API
runs keep snapshot-scoped sessions for reproducible runtime state.
"""
if invoke_from == InvokeFrom.DEBUGGER:
return None
return snapshot_id
@staticmethod
def _resolve_agent_by_id(
*, tenant_id: str, agent_id: str, snapshot_id: str | None

View File

@ -14,12 +14,9 @@ import json
import logging
from typing import Any
from dify_agent.layers.ask_human import AskHumanToolArgs
from dify_agent.protocol import DeferredToolResultsPayload
from pydantic import JsonValue
from clients.agent_backend import (
AgentBackendDeferredToolCallInternalEvent,
AgentBackendError,
AgentBackendInternalEventType,
AgentBackendRunClient,
@ -30,48 +27,21 @@ from clients.agent_backend import (
)
from core.app.apps.agent_app.runtime_request_builder import (
AgentAppRuntimeBuildContext,
AgentAppRuntimeRequest,
AgentAppRuntimeRequestBuilder,
)
from core.app.apps.agent_app.session_store import (
AgentAppRuntimeSessionStore,
AgentAppSessionScope,
StoredAgentAppSession,
)
from core.app.apps.agent_app.session_store import AgentAppRuntimeSessionStore, AgentAppSessionScope
from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.entities.app_invoke_entities import DifyRunContext
from core.app.entities.queue_entities import QueueLLMChunkEvent, QueueMessageEndEvent
from core.repositories.human_input_repository import HumanInputFormRepository, HumanInputFormRepositoryImpl
from core.workflow.nodes.agent_v2.ask_human_hitl import AskHumanFormBuildError, create_ask_human_form
from core.workflow.nodes.agent_v2.ask_human_resume import build_deferred_tool_results, resolve_ask_human_form
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from models.agent_config_entities import AgentSoulConfig
logger = logging.getLogger(__name__)
class _DefaultSessionScopeSnapshotId:
pass
_DEFAULT_SESSION_SCOPE_SNAPSHOT_ID = _DefaultSessionScopeSnapshotId()
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
if not user_query:
return []
return [UserPromptMessage(content=user_query)]
def publish_text_answer(
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
user_query: str | None = None,
) -> None:
def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
"""Publish a complete assistant answer as one chunk + message-end.
The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed
@ -79,53 +49,17 @@ def publish_text_answer(
both the backend-produced answer and short-circuited answers (moderation /
annotation reply) share the exact same persistence + SSE path.
"""
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=answer,
user_query=user_query,
)
publish_message_end(
queue_manager=queue_manager,
model_name=model_name,
answer=answer,
user_query=user_query,
)
def publish_text_delta(
*,
queue_manager: AppQueueManager,
model_name: str,
delta: str,
user_query: str | None = None,
) -> None:
"""Publish one assistant text delta through the EasyUI chat pipeline."""
if not delta:
return
prompt_messages = _prompt_messages_from_query(user_query)
chunk = LLMResultChunk(
model=model_name,
prompt_messages=prompt_messages,
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=delta)),
prompt_messages=[],
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
)
queue_manager.publish(QueueLLMChunkEvent(chunk=chunk), PublishFrom.APPLICATION_MANAGER)
def publish_message_end(
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
user_query: str | None = None,
) -> None:
"""Publish the terminal assistant result without emitting another delta."""
prompt_messages = _prompt_messages_from_query(user_query)
queue_manager.publish(
QueueMessageEndEvent(
llm_result=LLMResult(
model=model_name,
prompt_messages=prompt_messages,
prompt_messages=[],
message=AssistantPromptMessage(content=answer),
usage=LLMUsage.empty_usage(),
),
@ -162,26 +96,15 @@ class AgentAppRunner:
message_id: str,
model_name: str,
queue_manager: AppQueueManager,
session_scope_snapshot_id: str | None | _DefaultSessionScopeSnapshotId = _DEFAULT_SESSION_SCOPE_SNAPSHOT_ID,
) -> None:
if isinstance(session_scope_snapshot_id, _DefaultSessionScopeSnapshotId):
effective_session_scope_snapshot_id: str | None = agent_config_snapshot_id
else:
effective_session_scope_snapshot_id = session_scope_snapshot_id
scope = AgentAppSessionScope(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
conversation_id=conversation_id,
agent_id=agent_id,
agent_config_snapshot_id=effective_session_scope_snapshot_id,
)
# ENG-638: if a prior turn paused on ask_human and the form is now answered,
# resume by threading the human's reply into this run as deferred_tool_results.
stored = self._session_store.load_active_session(scope)
session_snapshot = stored.session_snapshot if stored is not None else None
deferred_tool_results = self._resolve_pending_ask_human(
stored=stored, dify_context=dify_context, message_id=message_id
agent_config_snapshot_id=agent_config_snapshot_id,
)
session_snapshot = self._session_store.load_active_snapshot(scope)
runtime = self._request_builder.build(
AgentAppRuntimeBuildContext(
@ -193,47 +116,18 @@ class AgentAppRunner:
user_query=query,
idempotency_key=message_id,
session_snapshot=session_snapshot,
deferred_tool_results=deferred_tool_results,
)
)
create_response = self._agent_backend_client.create_run(runtime.request)
terminal, streamed_answer = self._consume_stream(
create_response.run_id,
queue_manager=queue_manager,
model_name=model_name,
query=query,
)
if isinstance(terminal, AgentBackendDeferredToolCallInternalEvent):
# ENG-635: the agent asked a human. End this turn with the question and
# a conversation-owned HITL form; a form submission resumes the run.
self._pause_for_ask_human(
terminal=terminal,
scope=scope,
dify_context=dify_context,
agent_soul=agent_soul,
conversation_id=conversation_id,
message_id=message_id,
model_name=model_name,
runtime=runtime,
queue_manager=queue_manager,
query=query,
)
return
terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager)
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
error = getattr(terminal, "error", None) or "Agent backend run did not complete successfully."
raise AgentBackendError(str(error))
answer = self._extract_answer(terminal.output)
self._publish_terminal_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=answer,
query=query,
streamed_answer=streamed_answer,
)
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
@ -241,105 +135,8 @@ class AgentAppRunner:
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
)
def _pause_for_ask_human(
self,
*,
terminal: AgentBackendDeferredToolCallInternalEvent,
scope: AgentAppSessionScope,
dify_context: DifyRunContext,
agent_soul: AgentSoulConfig,
conversation_id: str,
message_id: str,
model_name: str,
runtime: AgentAppRuntimeRequest,
queue_manager: AppQueueManager,
query: str,
) -> None:
"""End the chat turn on a dify.ask_human call: create a conversation-owned
HITL form, persist the pause correlation, and surface the question."""
try:
created = create_ask_human_form(
deferred_tool_call=terminal.deferred_tool_call,
# Chat forms have no workflow node; key by the turn's message id.
node_id=message_id,
default_node_title="Agent",
contacts=agent_soul.human.contacts,
repository=self._build_form_repository(dify_context),
conversation_id=conversation_id,
)
except AskHumanFormBuildError as error:
raise AgentBackendError(f"Failed to build ask_human form for Agent App chat: {error}") from error
# Persist the snapshot + correlation so a form submission can start the
# second run with the human's answer (ENG-637/638 columns, conversation owner).
self._save_session(
scope=scope,
backend_run_id=terminal.run_id,
snapshot=terminal.session_snapshot,
runtime_layer_specs=extract_runtime_layer_specs(runtime.request.composition),
pending_form_id=created.form_id,
pending_tool_call_id=terminal.deferred_tool_call.tool_call_id,
)
# The structured form is delivered via the HITL surface(s); the chat turn
# ends by echoing the agent's question so the conversation reflects the ask.
self._publish_answer(
queue_manager=queue_manager,
model_name=model_name,
answer=self._ask_human_message(created.args),
query=query,
)
def _resolve_pending_ask_human(
self,
*,
stored: StoredAgentAppSession | None,
dify_context: DifyRunContext,
message_id: str,
) -> DeferredToolResultsPayload | None:
"""Build deferred_tool_results when a pending ask_human form is answered."""
if stored is None or stored.pending_form_id is None or stored.pending_tool_call_id is None:
return None
outcome = resolve_ask_human_form(
form_id=stored.pending_form_id,
tenant_id=dify_context.tenant_id,
node_id=message_id,
)
if outcome is None or outcome.deferred_result is None:
# Form missing or still waiting — run a normal turn, no resume.
return None
return build_deferred_tool_results(
tool_call_id=stored.pending_tool_call_id,
result=outcome.deferred_result,
)
def _build_form_repository(self, dify_context: DifyRunContext) -> HumanInputFormRepository:
invoke_source = dify_context.invoke_from.value
return HumanInputFormRepositoryImpl(
tenant_id=dify_context.tenant_id,
app_id=dify_context.app_id,
workflow_execution_id=None,
invoke_source=invoke_source,
submission_actor_id=dify_context.user_id if invoke_source in {"debugger", "explore"} else None,
)
@staticmethod
def _ask_human_message(args: AskHumanToolArgs) -> str:
parts = [args.question]
if args.markdown:
parts.append(args.markdown)
return "\n\n".join(parts)
def _consume_stream(
self,
run_id: str,
*,
queue_manager: AppQueueManager,
model_name: str,
query: str | None,
):
def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager):
terminal = None
streamed_answer_parts: list[str] = []
for public_event in self._agent_backend_client.stream_events(run_id):
if queue_manager.is_stopped():
self._cancel_run(run_id)
@ -352,23 +149,16 @@ class AgentAppRunner:
AgentBackendInternalEventType.RUN_STARTED,
AgentBackendInternalEventType.STREAM_EVENT,
):
# Stream deltas are accumulated by the backend into the
# terminal output; token-level forwarding is an S3 refinement.
if isinstance(internal_event, AgentBackendStreamInternalEvent):
text_delta = self._extract_stream_text_delta(internal_event)
if text_delta:
streamed_answer_parts.append(text_delta)
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=text_delta,
user_query=query,
)
continue
continue
terminal = internal_event
break
if terminal is not None:
break
return terminal, "".join(streamed_answer_parts)
return terminal
def _cancel_run(self, run_id: str) -> None:
try:
@ -376,41 +166,10 @@ class AgentAppRunner:
except Exception:
logger.warning("Failed to cancel stopped Agent App backend run: run_id=%s", run_id, exc_info=True)
def _publish_answer(
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None
) -> None:
def _publish_answer(self, *, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
# MVP: emit the full answer as a single chunk + message-end. The chat
# task pipeline streams the chunk over SSE and persists the message.
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query)
def _publish_terminal_answer(
self,
*,
queue_manager: AppQueueManager,
model_name: str,
answer: str,
query: str | None,
streamed_answer: str,
) -> None:
"""Finish a successful streamed turn without duplicating the final text."""
if not streamed_answer:
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer, query=query)
return
if answer.startswith(streamed_answer):
publish_text_delta(
queue_manager=queue_manager,
model_name=model_name,
delta=answer[len(streamed_answer) :],
user_query=query,
)
elif answer != streamed_answer:
logger.warning(
"Agent App streamed answer does not match terminal output; "
"using terminal output for message persistence."
)
publish_message_end(queue_manager=queue_manager, model_name=model_name, answer=answer, user_query=query)
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
def _save_session(
self,
@ -419,8 +178,6 @@ class AgentAppRunner:
backend_run_id: str,
snapshot: Any,
runtime_layer_specs: Any,
pending_form_id: str | None = None,
pending_tool_call_id: str | None = None,
) -> None:
try:
self._session_store.save_active_snapshot(
@ -428,8 +185,6 @@ class AgentAppRunner:
backend_run_id=backend_run_id,
snapshot=snapshot,
runtime_layer_specs=runtime_layer_specs,
pending_form_id=pending_form_id,
pending_tool_call_id=pending_tool_call_id,
)
except Exception:
logger.warning(
@ -459,27 +214,5 @@ class AgentAppRunner:
return json.dumps(output, ensure_ascii=False)
return json.dumps(output, ensure_ascii=False)
@staticmethod
def _extract_stream_text_delta(event: AgentBackendStreamInternalEvent) -> str | None:
data = event.data
if not isinstance(data, dict):
return None
if data.get("event_kind") == "part_delta":
delta = data.get("delta")
if isinstance(delta, dict) and delta.get("part_delta_kind") == "text":
content_delta = delta.get("content_delta")
if isinstance(content_delta, str):
return content_delta
if data.get("event_kind") == "part_start":
part = data.get("part")
if isinstance(part, dict) and part.get("part_kind") == "text":
content = part.get("content")
if isinstance(content, str):
return content
return None
__all__ = ["AgentAppRunner", "publish_message_end", "publish_text_answer", "publish_text_delta"]
__all__ = ["AgentAppRunner", "publish_text_answer"]

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