mirror of
https://github.com/langgenius/dify.git
synced 2026-06-20 14:46:01 +08:00
Compare commits
50 Commits
test/cli-e
...
feat/difyc
| Author | SHA1 | Date | |
|---|---|---|---|
| 633c1b4152 | |||
| a9c5e52940 | |||
| 933df2f490 | |||
| c52eafe2ca | |||
| 2f72b576f0 | |||
| 2604c33e54 | |||
| 694bf3754c | |||
| 762321751c | |||
| 7bfcf9185c | |||
| 26b0137c83 | |||
| 5873acc433 | |||
| de2548b714 | |||
| 3f2d22ec0f | |||
| 79ab6c2ecd | |||
| 4304044905 | |||
| 0fa43973b8 | |||
| 43192036fa | |||
| 0dd966f7b8 | |||
| af99414fc1 | |||
| f0b34bdeb4 | |||
| 19838972dc | |||
| bc825f94b5 | |||
| 59f8f2e7b3 | |||
| baf775134e | |||
| 9021b3f5be | |||
| c71f03f590 | |||
| ad5bade45f | |||
| 1065fe519c | |||
| 48452aefbc | |||
| 0ea0647dd0 | |||
| 8782da42c8 | |||
| e6a91bfcde | |||
| 912c0fa8d1 | |||
| 872b5a081f | |||
| 758bea1a91 | |||
| 3b0f6aef8e | |||
| f203ab7f1d | |||
| e970cbde0f | |||
| f992ede836 | |||
| 6ab5cf109b | |||
| 8ca8b3d59a | |||
| 3f81ec1212 | |||
| e189ceb397 | |||
| bacc48d16e | |||
| 7cb4a30040 | |||
| 56dce93524 | |||
| 813a1677b2 | |||
| 1427b0b098 | |||
| 2893adf5e4 | |||
| eb2aaf2ac1 |
@ -1,440 +0,0 @@
|
||||
---
|
||||
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
|
||||
@ -1,495 +0,0 @@
|
||||
# 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 |
|
||||
@ -1,477 +0,0 @@
|
||||
# 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" />}
|
||||
/>
|
||||
```
|
||||
@ -1,281 +0,0 @@
|
||||
# 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')
|
||||
})
|
||||
})
|
||||
```
|
||||
@ -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 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 abstraction choices, 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,26 +12,79 @@ 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 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.
|
||||
- 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.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
@ -39,7 +92,8 @@ 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 missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
|
||||
- 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.
|
||||
- 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`.
|
||||
@ -48,12 +102,13 @@ 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 and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
- 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.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
@ -68,4 +123,6 @@ 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.
|
||||
|
||||
14
.github/workflows/api-tests.yml
vendored
14
.github/workflows/api-tests.yml
vendored
@ -29,13 +29,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -91,13 +91,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -142,13 +142,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
disable_search: true
|
||||
|
||||
4
.github/workflows/autofix.yml
vendored
4
.github/workflows/autofix.yml
vendored
@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- 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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
35
.github/workflows/build-push.yml
vendored
35
.github/workflows/build-push.yml
vendored
@ -21,6 +21,7 @@ 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:
|
||||
@ -60,6 +61,20 @@ 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
|
||||
@ -68,7 +83,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@ -78,13 +93,13 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
|
||||
- name: Build Docker image
|
||||
id: build
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -122,12 +137,15 @@ 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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -147,6 +165,9 @@ 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
|
||||
@ -156,14 +177,14 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
tags: |
|
||||
|
||||
28
.github/workflows/cli-e2e.yml
vendored
28
.github/workflows/cli-e2e.yml
vendored
@ -79,7 +79,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # 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@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
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@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
2
.github/workflows/cli-edge.yml
vendored
2
.github/workflows/cli-edge.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
4
.github/workflows/cli-release.yml
vendored
4
.github/workflows/cli-release.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
4
.github/workflows/cli-tests.yml
vendored
4
.github/workflows/cli-tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
directory: cli/coverage
|
||||
flags: cli
|
||||
|
||||
12
.github/workflows/db-migration-test.yml
vendored
12
.github/workflows/db-migration-test.yml
vendored
@ -13,13 +13,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@ -63,13 +63,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
6
.github/workflows/docker-build.yml
vendored
6
.github/workflows/docker-build.yml
vendored
@ -53,7 +53,7 @@ jobs:
|
||||
uses: depot/setup-action@15c09a5f77a0840ad4bce955686522a257853461 # v1.7.1
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.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@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.context }}
|
||||
|
||||
2
.github/workflows/hotfix-cherry-pick.yml
vendored
2
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
4
.github/workflows/pyrefly-diff.yml
vendored
4
.github/workflows/pyrefly-diff.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
4
.github/workflows/pyrefly-type-coverage.yml
vendored
4
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -17,12 +17,12 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
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@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
||||
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
4
.github/workflows/vdb-tests-full.yml
vendored
4
.github/workflows/vdb-tests-full.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/vdb-tests.yml
vendored
4
.github/workflows/vdb-tests.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
4
.github/workflows/web-e2e.yml
vendored
4
.github/workflows/web-e2e.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
12
.github/workflows/web-tests.yml
vendored
12
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -117,7 +117,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
directory: packages/dify-ui/coverage
|
||||
flags: dify-ui
|
||||
@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@ -33,6 +33,7 @@ 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,
|
||||
@ -47,6 +48,7 @@ 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",
|
||||
|
||||
@ -32,6 +32,7 @@ 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 (
|
||||
@ -55,6 +56,7 @@ 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"
|
||||
|
||||
@ -139,6 +141,7 @@ 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
|
||||
@ -185,6 +188,7 @@ 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
|
||||
@ -221,7 +225,7 @@ class AgentBackendRunRequestBuilder:
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional history (multi-turn) → LLM → optional
|
||||
plugin tools → optional structured output. Mirrors the workflow-node
|
||||
plugin tools / knowledge search → optional structured output. Mirrors the workflow-node
|
||||
layer ordering minus the workflow-job / previous-node prompt.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
@ -300,6 +304,17 @@ 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
|
||||
@ -398,7 +413,12 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema."""
|
||||
"""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.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
if run_input.agent_soul_prompt:
|
||||
layers.append(
|
||||
@ -483,6 +503,17 @@ 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
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
from typing import Any, Literal
|
||||
from copy import deepcopy
|
||||
from typing import Annotated, Any, Literal, override
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, model_validator
|
||||
|
||||
from libs.helper import UUIDStrOrEmpty
|
||||
|
||||
@ -9,8 +10,53 @@ from libs.helper import UUIDStrOrEmpty
|
||||
|
||||
|
||||
class ConversationRenamePayload(BaseModel):
|
||||
name: str | None = None
|
||||
auto_generate: bool = False
|
||||
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",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_name_requirement(self):
|
||||
@ -24,14 +70,28 @@ class ConversationRenamePayload(BaseModel):
|
||||
|
||||
|
||||
class MessageListQuery(BaseModel):
|
||||
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
|
||||
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class MessageFeedbackPayload(BaseModel):
|
||||
rating: Literal["like", "dislike"] | None = None
|
||||
content: str | None = None
|
||||
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.")
|
||||
|
||||
|
||||
# --- Saved message schemas ---
|
||||
@ -48,6 +108,39 @@ 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
|
||||
@ -61,8 +154,22 @@ class WorkflowListQuery(BaseModel):
|
||||
|
||||
|
||||
class WorkflowRunPayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
files: list[dict[str, Any]] | None = Field(default=None)
|
||||
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`."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkflowUpdatePayload(BaseModel):
|
||||
@ -77,28 +184,49 @@ DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
|
||||
|
||||
|
||||
class ChildChunkCreatePayload(BaseModel):
|
||||
content: str
|
||||
content: str = Field(description="Child chunk text content.")
|
||||
|
||||
|
||||
class ChildChunkUpdatePayload(BaseModel):
|
||||
content: str
|
||||
content: str = Field(description="Child chunk text content.")
|
||||
|
||||
|
||||
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)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class MetadataUpdatePayload(BaseModel):
|
||||
name: str
|
||||
name: str = Field(description="New metadata field name.")
|
||||
|
||||
|
||||
# --- Audio schemas ---
|
||||
|
||||
|
||||
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
|
||||
|
||||
|
||||
class TextToAudioPayload(BaseModel):
|
||||
message_id: str | None = Field(default=None, description="Message ID")
|
||||
voice: str | None = Field(default=None, description="Voice to use for TTS")
|
||||
text: str | None = Field(default=None, description="Text to convert to audio")
|
||||
streaming: bool | None = Field(default=None, description="Enable streaming response")
|
||||
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.",
|
||||
)
|
||||
|
||||
@ -35,17 +35,23 @@ class HumanInputFormSubmitPayload(BaseModel):
|
||||
),
|
||||
examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE],
|
||||
)
|
||||
action: str
|
||||
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."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]:
|
||||
"""Serialize default values into strings expected by human-input form clients."""
|
||||
result: dict[str, str] = {}
|
||||
for key, value in values.items():
|
||||
if value is None:
|
||||
result[key] = ""
|
||||
elif isinstance(value, (dict, list)):
|
||||
result[key] = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
result[key] = str(value)
|
||||
match value:
|
||||
case None:
|
||||
result[key] = ""
|
||||
case dict() | list():
|
||||
result[key] = json.dumps(value, ensure_ascii=False)
|
||||
case _:
|
||||
result[key] = str(value)
|
||||
return result
|
||||
|
||||
@ -1,21 +1,31 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
|
||||
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 (
|
||||
AppDetailWithSite,
|
||||
AppListQuery,
|
||||
AppPagination,
|
||||
UpdateAppPayload,
|
||||
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,
|
||||
cloud_edition_billing_resource_check,
|
||||
edit_permission_required,
|
||||
enterprise_license_required,
|
||||
setup_required,
|
||||
@ -27,14 +37,24 @@ from fields.agent_fields import (
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentLogMessageListResponse,
|
||||
AgentLogSourceListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
)
|
||||
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
|
||||
@ -53,35 +73,126 @@ class AgentIdPath(BaseModel):
|
||||
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(default="", description="Agent role", max_length=255)
|
||||
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
|
||||
|
||||
class AgentAppUpdatePayload(UpdateAppPayload):
|
||||
role: str | None = Field(default=None, description="Agent role", max_length=255)
|
||||
|
||||
# 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,
|
||||
UpdateAppPayload,
|
||||
RosterListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
AgentAppPagination,
|
||||
AgentAppPublishedReferenceResponse,
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentLogMessageListResponse,
|
||||
AgentLogSourceListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
)
|
||||
|
||||
|
||||
@ -90,29 +201,60 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
|
||||
|
||||
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]
|
||||
|
||||
agent = _agent_roster_service().get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=app_model.id)
|
||||
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 = AppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
|
||||
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]
|
||||
agents_by_app_id = _agent_roster_service().load_app_backing_agents_by_app_id(
|
||||
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,
|
||||
)
|
||||
payload = AppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
|
||||
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)
|
||||
@ -121,17 +263,45 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
item["app_id"] = app_id
|
||||
item["id"] = agent.id
|
||||
item["role"] = agent.role or ""
|
||||
return payload
|
||||
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 _agent_roster_service().get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
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[AppPagination.__name__])
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -150,21 +320,20 @@ class AgentAppListApi(Resource):
|
||||
status="normal",
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params)
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session)
|
||||
if app_pagination is None:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
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[AppDetailWithSite.__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")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@cloud_edition_billing_resource_check("apps")
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
@ -186,7 +355,7 @@ class AgentAppListApi(Resource):
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>")
|
||||
class AgentAppApi(Resource):
|
||||
@console_ns.response(200, "Agent app detail", console_ns.models[AppDetailWithSite.__name__])
|
||||
@console_ns.response(200, "Agent app detail", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -197,7 +366,7 @@ class AgentAppApi(Resource):
|
||||
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[AppDetailWithSite.__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
|
||||
@ -234,6 +403,33 @@ class AgentAppApi(Resource):
|
||||
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")
|
||||
class AgentInviteOptionsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
|
||||
@ -256,6 +452,114 @@ 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__])
|
||||
@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)
|
||||
|
||||
|
||||
@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__])
|
||||
@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, 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)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/log-sources")
|
||||
class AgentLogSourcesApi(Resource):
|
||||
@console_ns.response(200, "Agent log sources", console_ns.models[AgentLogSourceListResponse.__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)
|
||||
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
|
||||
return dump_response(AgentLogSourceListResponse, payload)
|
||||
|
||||
|
||||
@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")
|
||||
class AgentRosterVersionsApi(Resource):
|
||||
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
|
||||
|
||||
@ -30,7 +30,7 @@ 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, SkillPackageService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
@ -45,11 +45,18 @@ from services.agent_drive_service import (
|
||||
normalize_drive_key,
|
||||
)
|
||||
from services.agent_service import AgentService
|
||||
from services.file_service import FileService
|
||||
|
||||
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).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
@ -125,11 +132,6 @@ class AgentSkillUploadResponse(ResponseModel):
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentSkillStandardizeResponse(ResponseModel):
|
||||
skill: AgentSkillRefConfig
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
@ -156,7 +158,6 @@ register_response_schema_models(
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentSkillStandardizeResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
@ -174,30 +175,9 @@ 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):
|
||||
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
|
||||
def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
def _standardize_skill_for_app(*, current_user: Account, app_model: App):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
@ -382,51 +362,9 @@ class AgentLogApi(Resource):
|
||||
@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 + validate a Skill package for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent 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
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(current_user=current_user)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@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=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@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.
|
||||
"""
|
||||
return _upload_skill_for_app(current_user=current_user)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/standardize")
|
||||
class AgentSkillStandardizeByAgentApi(Resource):
|
||||
@console_ns.doc("standardize_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Validate + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.response(
|
||||
201,
|
||||
"Skill standardized into drive",
|
||||
console_ns.models[AgentSkillStandardizeResponse.__name__],
|
||||
)
|
||||
@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
|
||||
@ -435,19 +373,22 @@ class AgentSkillStandardizeByAgentApi(Resource):
|
||||
@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 _standardize_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@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", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.response(
|
||||
201,
|
||||
"Skill standardized into drive",
|
||||
console_ns.models[AgentSkillStandardizeResponse.__name__],
|
||||
@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.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
|
||||
@ -455,8 +396,8 @@ class AgentSkillStandardizeApi(Resource):
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and standardize it into the app agent's drive."""
|
||||
return _standardize_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
|
||||
@ -402,7 +402,6 @@ class AppPartial(ResponseModel):
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
app_id: str | None = None
|
||||
role: str | None = None
|
||||
is_starred: bool = False
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@ -456,7 +455,6 @@ class AppDetailWithSite(AppDetail):
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
app_id: str | None = None
|
||||
role: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
@ -539,10 +537,7 @@ register_schema_models(
|
||||
ModelConfig,
|
||||
Site,
|
||||
DeletedTool,
|
||||
AppPartial,
|
||||
AppDetail,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
AppExportResponse,
|
||||
Segmentation,
|
||||
PreProcessingRule,
|
||||
@ -562,6 +557,13 @@ register_schema_models(
|
||||
LoadBalancingPayload,
|
||||
)
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppPartial,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@ -592,7 +594,7 @@ class AppListApi(Resource):
|
||||
|
||||
# get app list
|
||||
app_service = AppService()
|
||||
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params)
|
||||
app_pagination = app_service.get_paginate_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
|
||||
@ -659,7 +661,7 @@ class StarredAppListApi(Resource):
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params)
|
||||
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
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
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
|
||||
@ -11,7 +12,8 @@ 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 services.account_service import RegisterService
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
|
||||
|
||||
@ -25,18 +27,22 @@ class ActivatePayload(BaseModel):
|
||||
workspace_id: str | None = Field(default=None)
|
||||
email: EmailStr | None = Field(default=None)
|
||||
token: str
|
||||
name: str = Field(..., max_length=30)
|
||||
interface_language: str = Field(...)
|
||||
timezone: str = Field(...)
|
||||
name: str | None = Field(default=None, max_length=30)
|
||||
interface_language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
|
||||
@field_validator("interface_language")
|
||||
@classmethod
|
||||
def validate_lang(cls, value: str) -> str:
|
||||
def validate_lang(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return supported_language(value)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_tz(cls, value: str) -> str:
|
||||
def validate_tz(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
return timezone(value)
|
||||
|
||||
|
||||
@ -48,6 +54,8 @@ 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):
|
||||
@ -95,9 +103,20 @@ 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},
|
||||
"data": {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_id": workspace_id,
|
||||
"email": invitee_email,
|
||||
"account_status": account_status,
|
||||
"requires_setup": requires_setup,
|
||||
},
|
||||
}
|
||||
else:
|
||||
return {"is_valid": False}
|
||||
@ -126,15 +145,45 @@ 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)
|
||||
|
||||
account.name = args.name
|
||||
if membership_id is None:
|
||||
TenantService.create_tenant_member(tenant, account, str(role))
|
||||
|
||||
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()
|
||||
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)
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
@ -409,6 +409,7 @@ class DatasetListApi(Resource):
|
||||
datasets, total = DatasetService.get_datasets(
|
||||
query.page,
|
||||
query.limit,
|
||||
db.session,
|
||||
current_tenant_id,
|
||||
current_user,
|
||||
query.keyword,
|
||||
|
||||
@ -23,17 +23,26 @@ 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 RetrievalModel
|
||||
from services.entities.knowledge_entities.knowledge_entities import ExternalRetrievalModel, RetrievalModel
|
||||
from services.hit_testing_service import HitTestingService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HitTestingPayload(BaseModel):
|
||||
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
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class DatasetsHitTestingBase:
|
||||
|
||||
@ -73,9 +73,12 @@ 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 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 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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -122,7 +122,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))
|
||||
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=payload.type), db.session)
|
||||
|
||||
response = TagResponse.model_validate(
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": 0}
|
||||
@ -146,9 +146,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)
|
||||
tag = TagService.update_tags(UpdateTagPayload(name=payload.name), tag_id_str, db.session)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str)
|
||||
binding_count = TagService.get_tag_binding_count(tag_id_str, db.session)
|
||||
|
||||
response = TagResponse.model_validate(
|
||||
{"id": tag.id, "name": tag.name, "type": tag.type, "binding_count": binding_count}
|
||||
@ -164,7 +164,7 @@ class TagUpdateDeleteApi(Resource):
|
||||
def delete(self, tag_id: UUID):
|
||||
tag_id_str = str(tag_id)
|
||||
|
||||
TagService.delete_tag(tag_id_str)
|
||||
TagService.delete_tag(tag_id_str, db.session)
|
||||
|
||||
return "", 204
|
||||
|
||||
@ -189,7 +189,8 @@ 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
|
||||
|
||||
@ -203,7 +204,8 @@ 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
|
||||
|
||||
|
||||
@ -232,7 +232,11 @@ class MemberInviteEmailApi(Resource):
|
||||
)
|
||||
except AccountAlreadyInTenantError:
|
||||
invitation_results.append(
|
||||
{"status": "success", "email": invitee_email, "url": f"{console_web_url}/signin"}
|
||||
{
|
||||
"status": "already_member",
|
||||
"email": invitee_email,
|
||||
"message": "Account already in workspace.",
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
invitation_results.append({"status": "failed", "email": invitee_email, "message": str(e)})
|
||||
|
||||
@ -126,6 +126,7 @@ 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,
|
||||
|
||||
@ -9,14 +9,16 @@ api = ExternalApi(
|
||||
bp,
|
||||
version="1.0",
|
||||
title="Inner API",
|
||||
description="Internal APIs for enterprise features, billing, and plugin communication",
|
||||
description="Internal APIs for enterprise features, billing, knowledge retrieval, 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
|
||||
@ -26,8 +28,10 @@ api.add_namespace(inner_api_ns)
|
||||
__all__ = [
|
||||
"_agent_drive",
|
||||
"_app_dsl",
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
"bp",
|
||||
|
||||
1
api/controllers/inner_api/knowledge/__init__.py
Normal file
1
api/controllers/inner_api/knowledge/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Inner knowledge retrieval endpoints."""
|
||||
110
api/controllers/inner_api/knowledge/retrieval.py
Normal file
110
api/controllers/inner_api/knowledge/retrieval.py
Normal file
@ -0,0 +1,110 @@
|
||||
"""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)
|
||||
205
api/controllers/inner_api/runtime_credentials.py
Normal file
205
api/controllers/inner_api/runtime_credentials.py
Normal file
@ -0,0 +1,205 @@
|
||||
"""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
|
||||
@ -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
|
||||
|
||||
|
||||
def billing_inner_api_only[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
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."""
|
||||
|
||||
@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)
|
||||
raise InnerApiUnauthorizedError()
|
||||
|
||||
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]:
|
||||
@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
|
||||
return inner_api_only(view)
|
||||
|
||||
|
||||
def enterprise_inner_api_user_auth[**P, R](view: Callable[P, R]) -> Callable[P, R]:
|
||||
|
||||
@ -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)
|
||||
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
|
||||
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)
|
||||
pagination = AppService().get_paginate_apps(str(auth_data.account_id), workspace_id, params, db.session)
|
||||
if pagination is None:
|
||||
return empty
|
||||
|
||||
|
||||
@ -23,20 +23,25 @@ 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="Score threshold for annotation matching")
|
||||
embedding_provider_name: str = Field(description="Embedding provider name")
|
||||
embedding_model_name: str = Field(description="Embedding model name")
|
||||
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.")
|
||||
|
||||
|
||||
class AnnotationListQuery(BaseModel):
|
||||
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")
|
||||
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.")
|
||||
|
||||
|
||||
class AnnotationJobStatusResponse(ResponseModel):
|
||||
@ -45,6 +50,13 @@ 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,
|
||||
@ -58,10 +70,22 @@ 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": "Action to perform: 'enable' or 'disable'"})
|
||||
@service_api_ns.doc(params={"action": ANNOTATION_REPLY_ACTION_PARAM})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Action completed successfully",
|
||||
@ -92,9 +116,29 @@ 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": "Action type", "job_id": "Job ID"})
|
||||
@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(
|
||||
responses={
|
||||
200: "Job status retrieved successfully",
|
||||
@ -127,6 +171,14 @@ 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))
|
||||
@ -159,6 +211,17 @@ 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")
|
||||
@ -185,10 +248,20 @@ 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": "Annotation ID"})
|
||||
@service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to update."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Annotation updated successfully",
|
||||
@ -212,9 +285,19 @@ 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": "Annotation ID"})
|
||||
@service_api_ns.doc(params={"annotation_id": "The unique identifier of the annotation to delete."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
204: "Annotation deleted successfully",
|
||||
|
||||
@ -33,6 +33,18 @@ 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(
|
||||
@ -71,6 +83,14 @@ 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(
|
||||
@ -92,6 +112,14 @@ 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(
|
||||
|
||||
@ -20,6 +20,7 @@ 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
|
||||
@ -39,8 +40,40 @@ 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",
|
||||
@ -99,7 +132,27 @@ register_schema_model(service_api_ns, TextToAudioPayload)
|
||||
|
||||
@service_api_ns.route("/text-to-audio")
|
||||
class TextApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[TextToAudioPayload.__name__])
|
||||
@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.doc("text_to_audio")
|
||||
@service_api_ns.doc(description="Convert text to audio using text-to-speech")
|
||||
@service_api_ns.doc(
|
||||
@ -110,11 +163,7 @@ class TextApi(Resource):
|
||||
500: "Internal server error",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Text successfully converted to audio",
|
||||
service_api_ns.models[AudioBinaryResponse.__name__],
|
||||
)
|
||||
@service_api_ns.response(200, "Text successfully converted to audio")
|
||||
@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.
|
||||
|
||||
@ -5,6 +5,7 @@ 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
|
||||
@ -20,6 +21,12 @@ 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
|
||||
@ -51,24 +58,84 @@ def _resolve_agent_app_streaming(*, app_mode: AppMode, response_mode: str | None
|
||||
|
||||
|
||||
class CompletionRequestPayload(BaseModel):
|
||||
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")
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class ChatRequestPayload(BaseModel):
|
||||
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")
|
||||
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"
|
||||
)
|
||||
|
||||
@field_validator("conversation_id", mode="before")
|
||||
@classmethod
|
||||
@ -92,7 +159,33 @@ register_response_schema_models(service_api_ns, GeneratedAppResponse, SimpleResu
|
||||
|
||||
@service_api_ns.route("/completion-messages")
|
||||
class CompletionApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[CompletionRequestPayload.__name__])
|
||||
@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.doc("create_completion")
|
||||
@service_api_ns.doc(description="Create a completion for the given prompt")
|
||||
@service_api_ns.doc(
|
||||
@ -168,9 +261,20 @@ 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": "The ID of the task to stop"})
|
||||
@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(
|
||||
responses={
|
||||
200: "Task stopped successfully",
|
||||
@ -197,7 +301,39 @@ class CompletionStopApi(Resource):
|
||||
|
||||
@service_api_ns.route("/chat-messages")
|
||||
class ChatApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[ChatRequestPayload.__name__])
|
||||
@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.doc("create_chat_message")
|
||||
@service_api_ns.doc(description="Send a message in a chat conversation")
|
||||
@service_api_ns.doc(
|
||||
@ -276,9 +412,20 @@ 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": "The ID of the task to stop"})
|
||||
@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(
|
||||
responses={
|
||||
200: "Task stopped successfully",
|
||||
|
||||
@ -13,6 +13,7 @@ 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
|
||||
@ -29,18 +30,28 @@ from services.conversation_service import ConversationService
|
||||
|
||||
|
||||
class ConversationListQuery(BaseModel):
|
||||
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")
|
||||
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.")
|
||||
sort_by: Literal["created_at", "-created_at", "updated_at", "-updated_at"] = Field(
|
||||
default="-updated_at", description="Sort order for conversations"
|
||||
default="-updated_at",
|
||||
description="Sorting field. Use the `-` prefix for descending order.",
|
||||
)
|
||||
|
||||
|
||||
class ConversationVariablesQuery(BaseModel):
|
||||
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")
|
||||
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.")
|
||||
variable_name: str | None = Field(
|
||||
default=None, description="Filter variables by name", min_length=1, max_length=255
|
||||
default=None,
|
||||
description="Filter variables by a specific name.",
|
||||
min_length=1,
|
||||
max_length=255,
|
||||
)
|
||||
|
||||
@field_validator("variable_name", mode="before")
|
||||
@ -68,7 +79,7 @@ class ConversationVariablesQuery(BaseModel):
|
||||
|
||||
|
||||
class ConversationVariableUpdatePayload(BaseModel):
|
||||
value: Any
|
||||
value: Any = Field(description="The new value for the variable. Must match the variable's expected type.")
|
||||
|
||||
|
||||
class ConversationVariableResponse(ResponseModel):
|
||||
@ -145,6 +156,16 @@ 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")
|
||||
@ -197,9 +218,20 @@ 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",
|
||||
@ -225,10 +257,23 @@ class ConversationDetailApi(Resource):
|
||||
|
||||
@service_api_ns.route("/conversations/<uuid:c_id>/name")
|
||||
class ConversationRenameApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[ConversationRenamePayload.__name__])
|
||||
@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.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",
|
||||
@ -267,10 +312,20 @@ 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",
|
||||
@ -312,10 +367,25 @@ class ConversationVariablesApi(Resource):
|
||||
|
||||
@service_api_ns.route("/conversations/<uuid:c_id>/variables/<uuid:variable_id>")
|
||||
class ConversationVariableDetailApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[ConversationVariableUpdatePayload.__name__])
|
||||
@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.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",
|
||||
|
||||
@ -12,6 +12,7 @@ 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
|
||||
@ -23,8 +24,27 @@ 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",
|
||||
|
||||
@ -15,6 +15,7 @@ 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
|
||||
@ -24,12 +25,35 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FilePreviewQuery(BaseModel):
|
||||
as_attachment: bool = Field(default=False, description="Download as attachment")
|
||||
as_attachment: bool = Field(
|
||||
default=False,
|
||||
description="If `true`, forces the file to download as an attachment instead of previewing in browser.",
|
||||
)
|
||||
|
||||
|
||||
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):
|
||||
@ -40,10 +64,36 @@ 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": "UUID of the file to preview"})
|
||||
@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(
|
||||
responses={
|
||||
200: "File retrieved successfully",
|
||||
@ -52,11 +102,7 @@ class FilePreviewApi(Resource):
|
||||
404: "File not found",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"File retrieved successfully",
|
||||
service_api_ns.models[BinaryFileResponse.__name__],
|
||||
)
|
||||
@service_api_ns.response(200, "File retrieved successfully")
|
||||
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY))
|
||||
def get(self, app_model: App, end_user: EndUser, file_id: UUID):
|
||||
"""
|
||||
|
||||
@ -18,6 +18,7 @@ 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
|
||||
@ -72,6 +73,23 @@ 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"})
|
||||
@ -101,7 +119,29 @@ class WorkflowHumanInputFormApi(Resource):
|
||||
inputs = service.resolve_form_inputs(form)
|
||||
return _jsonify_form_definition(form, inputs=inputs)
|
||||
|
||||
@service_api_ns.expect(service_api_ns.models[HumanInputFormSubmitPayload.__name__])
|
||||
@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.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"})
|
||||
|
||||
@ -12,6 +12,7 @@ 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
|
||||
@ -30,8 +31,8 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FeedbackListQuery(BaseModel):
|
||||
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")
|
||||
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.")
|
||||
|
||||
|
||||
class AppFeedbackResponse(ResponseModel):
|
||||
@ -64,6 +65,19 @@ 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")
|
||||
@ -112,11 +126,23 @@ class MessageListApi(Resource):
|
||||
|
||||
@service_api_ns.route("/messages/<uuid:message_id>/feedbacks")
|
||||
class MessageFeedbackApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[MessageFeedbackPayload.__name__])
|
||||
@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.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",
|
||||
@ -150,6 +176,17 @@ 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")
|
||||
@ -177,6 +214,20 @@ 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",
|
||||
|
||||
@ -17,6 +17,18 @@ 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(
|
||||
|
||||
@ -7,6 +7,7 @@ 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
|
||||
|
||||
@ -21,6 +22,11 @@ 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
|
||||
@ -53,19 +59,41 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowRunPayload(WorkflowRunPayloadBase):
|
||||
response_mode: Literal["blocking", "streaming"] | None = None
|
||||
trace_session_id: str | None = Field(default=None, description="Trace session ID for observability grouping")
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class WorkflowLogQuery(BaseModel):
|
||||
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)
|
||||
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.")
|
||||
|
||||
|
||||
register_schema_models(service_api_ns, WorkflowRunPayload, WorkflowLogQuery)
|
||||
@ -177,14 +205,15 @@ register_response_schema_models(
|
||||
def _serialize_workflow_run(workflow_run: WorkflowRun) -> dict:
|
||||
status = _enum_value(workflow_run.status)
|
||||
raw_outputs = workflow_run.outputs_dict
|
||||
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 = {}
|
||||
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 = {}
|
||||
return WorkflowRunResponse.model_validate(
|
||||
{
|
||||
"id": workflow_run.id,
|
||||
@ -208,9 +237,23 @@ 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"})
|
||||
@service_api_ns.doc(
|
||||
params={
|
||||
"workflow_run_id": "Workflow run ID, obtained from the workflow execution response or streaming events."
|
||||
}
|
||||
)
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Workflow run details retrieved successfully",
|
||||
@ -249,7 +292,37 @@ class WorkflowRunDetailApi(Resource):
|
||||
|
||||
@service_api_ns.route("/workflows/run")
|
||||
class WorkflowRunApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__])
|
||||
@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.doc("run_workflow")
|
||||
@service_api_ns.doc(description="Execute a workflow")
|
||||
@service_api_ns.doc(
|
||||
@ -313,10 +386,52 @@ class WorkflowRunApi(Resource):
|
||||
|
||||
@service_api_ns.route("/workflows/<string:workflow_id>/run")
|
||||
class WorkflowRunByIdApi(Resource):
|
||||
@service_api_ns.expect(service_api_ns.models[WorkflowRunPayload.__name__])
|
||||
@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.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 to execute"})
|
||||
@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(
|
||||
responses={
|
||||
200: "Workflow executed successfully",
|
||||
@ -387,9 +502,23 @@ 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 to stop"})
|
||||
@service_api_ns.doc(
|
||||
params={"task_id": "Task ID, obtained from the streaming chunk returned by the Run Workflow API."}
|
||||
)
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Task stopped successfully",
|
||||
@ -417,6 +546,14 @@ 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")
|
||||
|
||||
@ -15,6 +15,7 @@ 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
|
||||
@ -31,9 +32,25 @@ from services.workflow_event_snapshot_service import build_workflow_event_stream
|
||||
|
||||
|
||||
class WorkflowEventsQuery(BaseModel):
|
||||
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")
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
register_schema_models(service_api_ns, WorkflowEventsQuery)
|
||||
@ -44,9 +61,27 @@ 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"})
|
||||
@service_api_ns.doc(params={"task_id": "Workflow run ID returned by the original workflow run request."})
|
||||
@service_api_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
from typing import Any, Literal
|
||||
from typing import Annotated, Literal, override
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, field_validator, model_validator
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
GetJsonSchemaHandler,
|
||||
RootModel,
|
||||
WithJsonSchema,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
import services
|
||||
@ -33,7 +42,12 @@ 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 RetrievalModel
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
ExternalRetrievalModel,
|
||||
KnowledgeProvider,
|
||||
RetrievalModel,
|
||||
SummaryIndexSetting,
|
||||
)
|
||||
from services.tag_service import (
|
||||
SaveTagPayload,
|
||||
TagBindingCreatePayload,
|
||||
@ -46,41 +60,133 @@ 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: 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)
|
||||
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.",
|
||||
)
|
||||
|
||||
|
||||
class DatasetUpdatePayload(BaseModel):
|
||||
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
|
||||
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.")
|
||||
|
||||
|
||||
class DocumentStatusPayload(BaseModel):
|
||||
document_ids: list[str] = Field(default_factory=list, description="Document IDs to update")
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
class TagNamePayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=50)
|
||||
name: str = Field(..., min_length=1, max_length=50, description="Tag name.")
|
||||
|
||||
|
||||
class TagCreatePayload(TagNamePayload):
|
||||
@ -88,16 +194,16 @@ class TagCreatePayload(TagNamePayload):
|
||||
|
||||
|
||||
class TagUpdatePayload(TagNamePayload):
|
||||
tag_id: str
|
||||
tag_id: str = Field(description="Tag ID to update.")
|
||||
|
||||
|
||||
class TagDeletePayload(BaseModel):
|
||||
tag_id: str
|
||||
tag_id: str = Field(description="Tag ID to delete.")
|
||||
|
||||
|
||||
class TagBindingPayload(BaseModel):
|
||||
tag_ids: list[str]
|
||||
target_id: str
|
||||
tag_ids: list[str] = Field(description="Tag IDs to bind.")
|
||||
target_id: str = Field(description="Knowledge base ID to bind the tags to.")
|
||||
|
||||
@field_validator("tag_ids")
|
||||
@classmethod
|
||||
@ -112,7 +218,46 @@ class TagUnbindingPayload(BaseModel):
|
||||
|
||||
tag_ids: list[str] = Field(default_factory=list)
|
||||
tag_id: str | None = None
|
||||
target_id: str
|
||||
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__,
|
||||
}
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
@ -146,11 +291,14 @@ class KnowledgeTagListResponse(RootModel[list[KnowledgeTagResponse]]):
|
||||
|
||||
|
||||
class DatasetListQuery(BaseModel):
|
||||
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")
|
||||
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.")
|
||||
|
||||
|
||||
class DatasetDetailWithPartialMembersResponse(DatasetDetailResponse):
|
||||
@ -204,6 +352,14 @@ 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(
|
||||
@ -262,6 +418,19 @@ 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")
|
||||
@ -327,9 +496,22 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Dataset retrieved successfully",
|
||||
@ -392,10 +574,23 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Dataset updated successfully",
|
||||
@ -474,9 +669,25 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
204: "Dataset deleted successfully",
|
||||
@ -519,6 +730,17 @@ 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",
|
||||
@ -528,8 +750,8 @@ class DocumentStatusApi(DatasetApiResource):
|
||||
@service_api_ns.doc(description="Batch update document status")
|
||||
@service_api_ns.doc(
|
||||
params={
|
||||
"dataset_id": "Dataset ID",
|
||||
"action": "Action to perform: 'enable', 'disable', 'archive', or 'un_archive'",
|
||||
"dataset_id": "Knowledge base ID.",
|
||||
"action": DOCUMENT_STATUS_ACTION_PARAM,
|
||||
}
|
||||
)
|
||||
@service_api_ns.doc(
|
||||
@ -591,6 +813,14 @@ 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(
|
||||
@ -612,6 +842,14 @@ class DatasetTagsApi(DatasetApiResource):
|
||||
tags = TagService.get_tags(db.session(), "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")
|
||||
@ -634,7 +872,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))
|
||||
tag = TagService.save_tags(SaveTagPayload(name=payload.name, type=TagType.KNOWLEDGE), db.session)
|
||||
|
||||
response = dump_response(
|
||||
KnowledgeTagResponse,
|
||||
@ -642,6 +880,14 @@ 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")
|
||||
@ -664,9 +910,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)
|
||||
tag = TagService.update_tags(UpdateTagServicePayload(name=payload.name), tag_id, db.session)
|
||||
|
||||
binding_count = TagService.get_tag_binding_count(tag_id)
|
||||
binding_count = TagService.get_tag_binding_count(tag_id, db.session)
|
||||
|
||||
response = dump_response(
|
||||
KnowledgeTagResponse,
|
||||
@ -674,6 +920,14 @@ 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")
|
||||
@ -688,13 +942,21 @@ 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)
|
||||
TagService.delete_tag(payload.tag_id, db.session)
|
||||
|
||||
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")
|
||||
@ -713,7 +975,8 @@ 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)
|
||||
TagBindingCreatePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
|
||||
db.session,
|
||||
)
|
||||
|
||||
return "", 204
|
||||
@ -721,6 +984,14 @@ 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")
|
||||
@ -739,7 +1010,8 @@ 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)
|
||||
TagBindingDeletePayload(tag_ids=payload.tag_ids, target_id=payload.target_id, type=TagType.KNOWLEDGE),
|
||||
db.session,
|
||||
)
|
||||
|
||||
return "", 204
|
||||
@ -747,9 +1019,17 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Tags retrieved successfully",
|
||||
@ -766,6 +1046,8 @@ 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))
|
||||
tags = TagService.get_tags_by_target_id(
|
||||
"knowledge", current_user.current_tenant_id, str(dataset_id), db.session
|
||||
)
|
||||
tags_list = [{"id": tag.id, "name": tag.name} for tag in tags]
|
||||
return dump_response(DatasetBoundTagListResponse, {"data": tags_list, "total": len(tags)}), 200
|
||||
|
||||
@ -8,11 +8,12 @@ deprecated in generated API docs so clients migrate toward the canonical paths.
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from contextlib import ExitStack
|
||||
from typing import Any, Literal, Self
|
||||
from copy import deepcopy
|
||||
from typing import Annotated, Any, Literal, Self, override
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request, send_file
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, field_validator, model_validator
|
||||
from sqlalchemy import desc, func, select
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
|
||||
@ -39,6 +40,7 @@ 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,
|
||||
@ -61,6 +63,8 @@ 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,
|
||||
@ -70,16 +74,44 @@ from services.summary_index_service import SummaryIndexService
|
||||
|
||||
|
||||
class DocumentTextCreatePayload(BaseModel):
|
||||
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
|
||||
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`."
|
||||
),
|
||||
)
|
||||
|
||||
@field_validator("doc_form")
|
||||
@classmethod
|
||||
@ -90,12 +122,21 @@ class DocumentTextCreatePayload(BaseModel):
|
||||
|
||||
|
||||
class DocumentTextUpdate(BaseModel):
|
||||
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
|
||||
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.",
|
||||
)
|
||||
|
||||
@field_validator("doc_form")
|
||||
@classmethod
|
||||
@ -104,6 +145,36 @@ 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:
|
||||
@ -111,19 +182,59 @@ 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")
|
||||
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")
|
||||
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.")
|
||||
|
||||
|
||||
class DocumentGetQuery(BaseModel):
|
||||
metadata: Literal["all", "only", "without"] = Field(default="all", description="Metadata response mode")
|
||||
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`."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
DOCUMENT_CREATE_BY_FILE_PARAMS = {
|
||||
"dataset_id": "Dataset ID",
|
||||
"dataset_id": "Knowledge base ID.",
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
@ -134,23 +245,32 @@ DOCUMENT_CREATE_BY_FILE_PARAMS = {
|
||||
"in": "formData",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Optional JSON string with document creation settings.",
|
||||
"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`."
|
||||
),
|
||||
},
|
||||
}
|
||||
DOCUMENT_UPDATE_BY_FILE_PARAMS = {
|
||||
"dataset_id": "Dataset ID",
|
||||
"document_id": "Document ID",
|
||||
"dataset_id": "Knowledge base ID.",
|
||||
"document_id": "Document ID.",
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": False,
|
||||
"description": "Replacement document file.",
|
||||
"description": "Replacement document file to upload.",
|
||||
},
|
||||
"data": {
|
||||
"in": "formData",
|
||||
"type": "string",
|
||||
"required": False,
|
||||
"description": "Optional JSON string with document update settings.",
|
||||
"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."
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@ -351,10 +471,28 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document created successfully",
|
||||
@ -386,7 +524,7 @@ class DeprecatedDocumentAddByTextApi(DatasetApiResource):
|
||||
"Use /datasets/{dataset_id}/document/create-by-text instead."
|
||||
)
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document created successfully",
|
||||
@ -409,10 +547,29 @@ 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": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document updated successfully",
|
||||
@ -443,7 +600,7 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
|
||||
"Use /datasets/{dataset_id}/documents/{document_id}/update-by-text instead."
|
||||
)
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Document updated successfully",
|
||||
@ -463,11 +620,42 @@ class DeprecatedDocumentUpdateByTextApi(DatasetApiResource):
|
||||
|
||||
@service_api_ns.route(
|
||||
"/datasets/<uuid:dataset_id>/document/create_by_file",
|
||||
"/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."
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
@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)
|
||||
@ -658,6 +846,27 @@ 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(
|
||||
@ -686,9 +895,21 @@ 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": "Dataset ID", **query_params_from_model(DocumentListQuery)})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", **query_params_from_model(DocumentListQuery)})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Documents retrieved successfully",
|
||||
@ -746,10 +967,23 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "ZIP archive generated successfully",
|
||||
@ -758,11 +992,7 @@ class DocumentBatchDownloadZipApi(DatasetApiResource):
|
||||
404: "Document or dataset not found",
|
||||
}
|
||||
)
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"ZIP archive generated successfully",
|
||||
service_api_ns.models[BinaryFileResponse.__name__],
|
||||
)
|
||||
@service_api_ns.response(200, "ZIP archive generated successfully")
|
||||
@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 {})
|
||||
@ -789,9 +1019,23 @@ 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": "Dataset ID", "batch": "Batch ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "batch": "Batch ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Indexing status retrieved successfully",
|
||||
@ -861,9 +1105,19 @@ 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": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Download URL generated successfully",
|
||||
@ -895,9 +1149,27 @@ 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": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
|
||||
@service_api_ns.doc(params=query_params_from_model(DocumentGetQuery))
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
@ -1036,9 +1308,20 @@ 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": "Dataset ID", "document_id": "Document ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "document_id": "Document ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
204: "Document deleted successfully",
|
||||
|
||||
@ -13,9 +13,35 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.response(
|
||||
200,
|
||||
"Hit testing results",
|
||||
|
||||
@ -24,6 +24,12 @@ 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,
|
||||
@ -43,10 +49,21 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
201: "Metadata created successfully",
|
||||
@ -71,9 +88,20 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Metadata retrieved successfully",
|
||||
@ -96,10 +124,18 @@ 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": "Dataset ID", "metadata_id": "Metadata ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Metadata updated successfully",
|
||||
@ -125,9 +161,20 @@ 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": "Dataset ID", "metadata_id": "Metadata ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "metadata_id": "Metadata field ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
204: "Metadata deleted successfully",
|
||||
@ -152,8 +199,19 @@ 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",
|
||||
@ -173,9 +231,17 @@ 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": "Dataset ID", "action": "Action to perform: 'enable' or 'disable'"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "action": BUILT_IN_METADATA_ACTION_PARAM})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Action completed successfully",
|
||||
@ -205,10 +271,21 @@ 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": "Dataset ID"})
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Documents metadata updated successfully",
|
||||
|
||||
@ -19,6 +19,11 @@ 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
|
||||
@ -32,6 +37,7 @@ 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
|
||||
@ -39,14 +45,27 @@ from services.rag_pipeline.rag_pipeline import RagPipelineService
|
||||
|
||||
|
||||
class DatasourceNodeRunPayload(BaseModel):
|
||||
inputs: dict[str, Any]
|
||||
datasource_type: str
|
||||
credential_id: str | None = None
|
||||
is_published: bool
|
||||
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."
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class DatasourcePluginsQuery(BaseModel):
|
||||
is_published: bool = True
|
||||
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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class DatasourceCredentialInfoResponse(ResponseModel):
|
||||
@ -95,13 +114,21 @@ 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(
|
||||
path={
|
||||
"dataset_id": "Dataset ID",
|
||||
}
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(params=query_params_from_model(DatasourcePluginsQuery))
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
@ -137,13 +164,22 @@ 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(
|
||||
path={
|
||||
"dataset_id": "Dataset ID",
|
||||
}
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID.", "node_id": "ID of the datasource node to execute."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Datasource node run successfully",
|
||||
@ -195,13 +231,27 @@ 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(
|
||||
path={
|
||||
"dataset_id": "Dataset ID",
|
||||
}
|
||||
)
|
||||
@service_api_ns.doc(params={"dataset_id": "Knowledge base ID."})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Pipeline run successfully",
|
||||
@ -248,8 +298,24 @@ 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",
|
||||
|
||||
@ -47,10 +47,10 @@ from services.summary_index_service import SummaryIndexService
|
||||
|
||||
|
||||
class SegmentCreateItemPayload(BaseModel):
|
||||
content: str = Field(min_length=1)
|
||||
answer: str | None = None
|
||||
keywords: list[str] | None = None
|
||||
attachment_ids: list[str] | None = None
|
||||
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.")
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
@ -61,31 +61,34 @@ class SegmentCreateItemPayload(BaseModel):
|
||||
|
||||
|
||||
class SegmentCreatePayload(BaseModel):
|
||||
segments: list[SegmentCreateItemPayload] = Field(min_length=1)
|
||||
segments: list[SegmentCreateItemPayload] = Field(min_length=1, description="Array of chunk objects to create.")
|
||||
|
||||
|
||||
class SegmentListQuery(BaseModel):
|
||||
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
|
||||
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.")
|
||||
|
||||
|
||||
class SegmentUpdatePayload(BaseModel):
|
||||
segment: SegmentUpdateArgs
|
||||
segment: SegmentUpdateArgs = Field(description="Chunk update payload.")
|
||||
|
||||
|
||||
class ChildChunkListQuery(BaseModel):
|
||||
limit: int = Field(default=20, ge=1)
|
||||
keyword: str | None = None
|
||||
page: int = Field(default=1, ge=1)
|
||||
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.")
|
||||
|
||||
|
||||
class SegmentDocParams:
|
||||
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"}
|
||||
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."}
|
||||
|
||||
|
||||
class SegmentCreateListResponse(ResponseModel):
|
||||
@ -128,6 +131,18 @@ 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")
|
||||
@ -209,6 +224,14 @@ 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)
|
||||
@ -294,6 +317,14 @@ 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)
|
||||
@ -329,6 +360,14 @@ 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")
|
||||
@ -391,6 +430,17 @@ 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)
|
||||
@ -442,6 +492,15 @@ 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")
|
||||
@ -511,6 +570,14 @@ 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)
|
||||
@ -576,6 +643,15 @@ 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)
|
||||
@ -634,6 +710,15 @@ 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")
|
||||
|
||||
@ -17,6 +17,18 @@ 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(
|
||||
|
||||
167
api/controllers/service_api/schema.py
Normal file
167
api/controllers/service_api/schema.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""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)
|
||||
@ -9,6 +9,12 @@ 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]
|
||||
@ -19,9 +25,20 @@ 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": "Type of model to retrieve"})
|
||||
@service_api_ns.doc(params={"model_type": MODEL_TYPE_PARAM})
|
||||
@service_api_ns.doc(
|
||||
responses={
|
||||
200: "Models retrieved successfully",
|
||||
|
||||
@ -4,16 +4,23 @@ import time
|
||||
from collections.abc import Callable
|
||||
from enum import StrEnum, auto
|
||||
from functools import wraps
|
||||
from typing import cast, overload
|
||||
from typing import Protocol, 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
|
||||
@ -28,6 +35,12 @@ 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.
|
||||
@ -43,6 +56,35 @@ 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]: ...
|
||||
|
||||
@ -126,6 +168,7 @@ 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:
|
||||
@ -343,6 +386,8 @@ 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:
|
||||
|
||||
@ -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 | None = ""
|
||||
self.query: str = ""
|
||||
self._current_thoughts: list[PromptMessage] = []
|
||||
|
||||
def _repack_app_generate_entity(
|
||||
|
||||
@ -72,8 +72,12 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
query = query.replace("\x00", "")
|
||||
inputs = args["inputs"]
|
||||
|
||||
# Resolve the bound roster Agent + its published Agent Soul snapshot.
|
||||
# Resolve the bound roster Agent + its current 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")
|
||||
@ -120,6 +124,7 @@ 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)
|
||||
@ -341,6 +346,7 @@ 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
|
||||
@ -373,7 +379,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
|
||||
app_config = application_generate_entity.app_config
|
||||
model_name = application_generate_entity.model_conf.model
|
||||
query = application_generate_entity.query
|
||||
query = application_generate_entity.query or ""
|
||||
|
||||
# content moderation (sensitive_word_avoidance); a blocked input yields a
|
||||
# preset answer, an "overridden" action returns a sanitized query.
|
||||
@ -388,7 +394,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))
|
||||
publish_text_answer(queue_manager=queue_manager, model_name=model_name, answer=str(e), user_query=query)
|
||||
return True, query
|
||||
|
||||
# annotation reply: a matching annotation answers the turn deterministically.
|
||||
@ -405,7 +411,12 @@ 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)
|
||||
publish_text_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=annotation_reply.content,
|
||||
user_query=query,
|
||||
)
|
||||
return True, query
|
||||
|
||||
return False, query
|
||||
@ -425,6 +436,21 @@ 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
|
||||
|
||||
@ -46,13 +46,32 @@ from core.repositories.human_input_repository import HumanInputFormRepository, H
|
||||
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
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage, UserPromptMessage
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answer: str) -> None:
|
||||
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:
|
||||
"""Publish a complete assistant answer as one chunk + message-end.
|
||||
|
||||
The EasyUI chat task pipeline consumes a QueueLLMChunkEvent stream followed
|
||||
@ -60,17 +79,53 @@ def publish_text_answer(*, queue_manager: AppQueueManager, model_name: str, answ
|
||||
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=[],
|
||||
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=answer)),
|
||||
prompt_messages=prompt_messages,
|
||||
delta=LLMResultChunkDelta(index=0, message=AssistantPromptMessage(content=delta)),
|
||||
)
|
||||
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(),
|
||||
),
|
||||
@ -107,13 +162,18 @@ 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=agent_config_snapshot_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.
|
||||
@ -138,7 +198,12 @@ class AgentAppRunner:
|
||||
)
|
||||
|
||||
create_response = self._agent_backend_client.create_run(runtime.request)
|
||||
terminal = self._consume_stream(create_response.run_id, queue_manager=queue_manager)
|
||||
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
|
||||
@ -153,6 +218,7 @@ class AgentAppRunner:
|
||||
model_name=model_name,
|
||||
runtime=runtime,
|
||||
queue_manager=queue_manager,
|
||||
query=query,
|
||||
)
|
||||
return
|
||||
|
||||
@ -161,7 +227,13 @@ class AgentAppRunner:
|
||||
raise AgentBackendError(str(error))
|
||||
|
||||
answer = self._extract_answer(terminal.output)
|
||||
self._publish_answer(queue_manager=queue_manager, model_name=model_name, answer=answer)
|
||||
self._publish_terminal_answer(
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=answer,
|
||||
query=query,
|
||||
streamed_answer=streamed_answer,
|
||||
)
|
||||
self._save_session(
|
||||
scope=scope,
|
||||
backend_run_id=terminal.run_id,
|
||||
@ -181,6 +253,7 @@ class AgentAppRunner:
|
||||
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."""
|
||||
@ -214,6 +287,7 @@ class AgentAppRunner:
|
||||
queue_manager=queue_manager,
|
||||
model_name=model_name,
|
||||
answer=self._ask_human_message(created.args),
|
||||
query=query,
|
||||
)
|
||||
|
||||
def _resolve_pending_ask_human(
|
||||
@ -256,8 +330,16 @@ class AgentAppRunner:
|
||||
parts.append(args.markdown)
|
||||
return "\n\n".join(parts)
|
||||
|
||||
def _consume_stream(self, run_id: str, *, queue_manager: AppQueueManager):
|
||||
def _consume_stream(
|
||||
self,
|
||||
run_id: str,
|
||||
*,
|
||||
queue_manager: AppQueueManager,
|
||||
model_name: str,
|
||||
query: str | None,
|
||||
):
|
||||
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)
|
||||
@ -270,16 +352,23 @@ 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
|
||||
return terminal, "".join(streamed_answer_parts)
|
||||
|
||||
def _cancel_run(self, run_id: str) -> None:
|
||||
try:
|
||||
@ -287,10 +376,41 @@ 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) -> None:
|
||||
def _publish_answer(
|
||||
self, *, queue_manager: AppQueueManager, model_name: str, answer: str, query: str | None
|
||||
) -> 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)
|
||||
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)
|
||||
|
||||
def _save_session(
|
||||
self,
|
||||
@ -339,5 +459,27 @@ 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
|
||||
|
||||
__all__ = ["AgentAppRunner", "publish_text_answer"]
|
||||
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"]
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
Mirrors the workflow ``WorkflowAgentRuntimeRequestBuilder`` but for the Agent
|
||||
App surface: the user prompt is the chat message (no workflow-node job / no
|
||||
previous-node context), and multi-turn continuity flows through the
|
||||
conversation-keyed ``session_snapshot`` plus the history layer.
|
||||
previous-node context), multi-turn continuity flows through the
|
||||
conversation-keyed ``session_snapshot`` plus the history layer, and Agent Soul
|
||||
knowledge config is mapped into the same fixed ``dify.knowledge_base`` layer
|
||||
used by workflow runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -36,6 +38,7 @@ from core.workflow.nodes.agent_v2.runtime_request_builder import (
|
||||
append_runtime_warnings,
|
||||
build_ask_human_layer_config,
|
||||
build_drive_layer_config,
|
||||
build_knowledge_layer_config,
|
||||
build_shell_layer_config,
|
||||
)
|
||||
from models.agent_config_entities import AgentSoulConfig
|
||||
@ -123,6 +126,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
|
||||
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
|
||||
append_runtime_warnings(metadata, drive_warnings)
|
||||
knowledge_config = build_knowledge_layer_config(agent_soul)
|
||||
|
||||
request = self._request_builder.build_for_agent_app(
|
||||
AgentBackendAgentAppRunInput(
|
||||
@ -156,6 +160,7 @@ class AgentAppRuntimeRequestBuilder:
|
||||
or None,
|
||||
user_prompt=context.user_query,
|
||||
tools=tools_layer,
|
||||
knowledge=knowledge_config,
|
||||
drive_config=drive_config,
|
||||
ask_human_config=build_ask_human_layer_config(agent_soul),
|
||||
include_shell=dify_config.AGENT_SHELL_ENABLED,
|
||||
|
||||
@ -45,7 +45,7 @@ class AgentAppSessionScope:
|
||||
app_id: str
|
||||
conversation_id: str
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
agent_config_snapshot_id: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -194,13 +194,15 @@ class AgentAppRuntimeSessionStore:
|
||||
|
||||
@staticmethod
|
||||
def _scope_stmt(scope: AgentAppSessionScope):
|
||||
return select(AgentRuntimeSession).where(
|
||||
stmt = select(AgentRuntimeSession).where(
|
||||
AgentRuntimeSession.owner_type == AgentRuntimeSessionOwnerType.CONVERSATION,
|
||||
AgentRuntimeSession.tenant_id == scope.tenant_id,
|
||||
AgentRuntimeSession.conversation_id == scope.conversation_id,
|
||||
AgentRuntimeSession.agent_id == scope.agent_id,
|
||||
AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id,
|
||||
)
|
||||
if scope.agent_config_snapshot_id is None:
|
||||
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id.is_(None))
|
||||
return stmt.where(AgentRuntimeSession.agent_config_snapshot_id == scope.agent_config_snapshot_id)
|
||||
|
||||
@classmethod
|
||||
def _active_stmt(cls, scope: AgentAppSessionScope):
|
||||
|
||||
@ -231,22 +231,23 @@ class AppRunner:
|
||||
:param tenant_id: tenant id for multimodal output
|
||||
:return:
|
||||
"""
|
||||
if not stream and isinstance(invoke_result, LLMResult):
|
||||
self._handle_invoke_result_direct(
|
||||
invoke_result=invoke_result,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
elif stream and isinstance(invoke_result, Generator):
|
||||
self._handle_invoke_result_stream(
|
||||
invoke_result=invoke_result,
|
||||
queue_manager=queue_manager,
|
||||
agent=agent,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
else:
|
||||
raise NotImplementedError(f"unsupported invoke result type: {type(invoke_result)}")
|
||||
match invoke_result:
|
||||
case LLMResult() if not stream:
|
||||
self._handle_invoke_result_direct(
|
||||
invoke_result=invoke_result,
|
||||
queue_manager=queue_manager,
|
||||
)
|
||||
case _ if stream and isinstance(invoke_result, Generator):
|
||||
self._handle_invoke_result_stream(
|
||||
invoke_result=invoke_result,
|
||||
queue_manager=queue_manager,
|
||||
agent=agent,
|
||||
message_id=message_id,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
)
|
||||
case _:
|
||||
raise NotImplementedError(f"unsupported invoke result type: {type(invoke_result)}")
|
||||
|
||||
def _handle_invoke_result_direct(
|
||||
self,
|
||||
|
||||
@ -882,7 +882,7 @@ class WorkflowResponseConverter:
|
||||
return files
|
||||
|
||||
@classmethod
|
||||
def _get_file_var_from_value(cls, value: Union[dict, list]) -> Mapping[str, Any] | None:
|
||||
def _get_file_var_from_value(cls, value: object) -> Mapping[str, Any] | None:
|
||||
"""
|
||||
Get file var from value
|
||||
:param value: variable value
|
||||
@ -891,10 +891,11 @@ class WorkflowResponseConverter:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
return value
|
||||
elif isinstance(value, File):
|
||||
return value.to_dict()
|
||||
match value:
|
||||
case dict() if value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
return value
|
||||
case File():
|
||||
return value.to_dict()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@ -138,6 +138,10 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
documents: list[Document] = []
|
||||
if invoke_from == InvokeFrom.PUBLISHED_PIPELINE and not is_retry and not args.get("original_document_id"):
|
||||
from services.dataset_service import DocumentService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
features = FeatureService.get_features(pipeline.tenant_id)
|
||||
DocumentService.check_document_creation_limits(len(datasource_info_list), features)
|
||||
|
||||
for datasource_info in datasource_info_list:
|
||||
position = DocumentService.get_documents_position(dataset.id)
|
||||
|
||||
@ -224,6 +224,7 @@ class AgentAppGenerateEntity(ChatAppGenerateEntity):
|
||||
|
||||
agent_id: str
|
||||
agent_config_snapshot_id: str
|
||||
agent_runtime_session_snapshot_id: str | None = None
|
||||
|
||||
|
||||
class AdvancedChatAppGenerateEntity(ConversationAppGenerateEntity):
|
||||
|
||||
@ -241,7 +241,7 @@ class WorkflowFinishStreamResponse(StreamResponse):
|
||||
created_by: Mapping[str, object] = Field(default_factory=dict)
|
||||
created_at: int
|
||||
finished_at: int | None
|
||||
exceptions_count: int | None = 0
|
||||
exceptions_count: int = 0
|
||||
files: Sequence[Mapping[str, Any]] | None = []
|
||||
|
||||
event: StreamEvent = StreamEvent.WORKFLOW_FINISHED
|
||||
|
||||
@ -113,7 +113,7 @@ class CustomModelConfiguration(BaseModel):
|
||||
current_credential_id: str | None = None
|
||||
current_credential_name: str | None = None
|
||||
available_model_credentials: list[CredentialConfiguration] = []
|
||||
unadded_to_model_list: bool | None = False
|
||||
unadded_to_model_list: bool = False
|
||||
|
||||
# pydantic configs
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
@ -209,7 +209,7 @@ class ProviderConfig(BasicProviderConfig):
|
||||
required: bool = False
|
||||
default: Union[int, str, float, bool] | None = None
|
||||
options: list[Option] | None = None
|
||||
multiple: bool | None = False
|
||||
multiple: bool = False
|
||||
label: I18nObject | None = None
|
||||
help: I18nObject | None = None
|
||||
url: str | None = None
|
||||
|
||||
@ -144,15 +144,16 @@ def extract_parent_trace_context_from_args(args: Mapping[str, Any]) -> dict[str,
|
||||
Returns an empty dict if the context is missing or incomplete.
|
||||
"""
|
||||
parent_trace_context = args.get("parent_trace_context")
|
||||
if isinstance(parent_trace_context, ParentTraceContext):
|
||||
context = parent_trace_context
|
||||
elif isinstance(parent_trace_context, Mapping):
|
||||
try:
|
||||
context = ParentTraceContext.model_validate(parent_trace_context)
|
||||
except ValidationError:
|
||||
match parent_trace_context:
|
||||
case ParentTraceContext():
|
||||
context = parent_trace_context
|
||||
case Mapping():
|
||||
try:
|
||||
context = ParentTraceContext.model_validate(parent_trace_context)
|
||||
except ValidationError:
|
||||
return {}
|
||||
case _:
|
||||
return {}
|
||||
else:
|
||||
return {}
|
||||
|
||||
if context.parent_node_execution_id is None:
|
||||
return {}
|
||||
|
||||
@ -116,20 +116,21 @@ def cast_parameter_value(typ: StrEnum, value: Any, /):
|
||||
return value if isinstance(value, str) else str(value)
|
||||
|
||||
case PluginParameterType.BOOLEAN:
|
||||
if value is None:
|
||||
return False
|
||||
elif isinstance(value, str):
|
||||
# Allowed YAML boolean value strings: https://yaml.org/type/bool.html
|
||||
# and also '0' for False and '1' for True
|
||||
match value.lower():
|
||||
case "true" | "yes" | "y" | "1":
|
||||
return True
|
||||
case "false" | "no" | "n" | "0":
|
||||
return False
|
||||
case _:
|
||||
return bool(value)
|
||||
else:
|
||||
return value if isinstance(value, bool) else bool(value)
|
||||
match value:
|
||||
case None:
|
||||
return False
|
||||
case str():
|
||||
# Allowed YAML boolean value strings: https://yaml.org/type/bool.html
|
||||
# and also '0' for False and '1' for True
|
||||
match value.lower():
|
||||
case "true" | "yes" | "y" | "1":
|
||||
return True
|
||||
case "false" | "no" | "n" | "0":
|
||||
return False
|
||||
case _:
|
||||
return bool(value)
|
||||
case _:
|
||||
return value if isinstance(value, bool) else bool(value)
|
||||
|
||||
case PluginParameterType.NUMBER:
|
||||
match value:
|
||||
|
||||
@ -73,7 +73,7 @@ class RequestInvokeLLM(BaseRequestInvokeModel):
|
||||
prompt_messages: list[PromptMessage] = Field(default_factory=list)
|
||||
tools: list[PromptMessageTool] | None = Field(default_factory=list[PromptMessageTool])
|
||||
stop: list[str] | None = Field(default_factory=list[str])
|
||||
stream: bool | None = False
|
||||
stream: bool = False
|
||||
|
||||
model_config = ConfigDict(protected_namespaces=())
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ from core.plugin.impl.exc import (
|
||||
PluginDaemonNotFoundError,
|
||||
PluginDaemonUnauthorizedError,
|
||||
PluginInvokeError,
|
||||
PluginLLMPollingUnsupportedError,
|
||||
PluginNotFoundError,
|
||||
PluginPermissionDeniedError,
|
||||
PluginUniqueIdentifierError,
|
||||
@ -370,6 +371,10 @@ class BasePluginClient:
|
||||
raise TriggerInvokeError(error_object.get("message"))
|
||||
case EventIgnoreError.__name__:
|
||||
raise EventIgnoreError(description=error_object.get("message"))
|
||||
# NOTE: current plugin sdk / plugin daemon does not raise exception with
|
||||
# type `PluginLLMPollingUnsupportedError`.
|
||||
case PluginLLMPollingUnsupportedError.__name__:
|
||||
raise PluginLLMPollingUnsupportedError(description=error_object.get("message"))
|
||||
case _:
|
||||
raise PluginInvokeError(description=message)
|
||||
case PluginDaemonInternalServerError.__name__:
|
||||
|
||||
@ -5,6 +5,13 @@ from pydantic import TypeAdapter
|
||||
|
||||
from extensions.ext_logging import get_request_id
|
||||
|
||||
# NOTE: Avoid renaming exception classes in this file, since
|
||||
# the `_handle_plugin_daemon_error` in api/core/plugin/impl/base.py
|
||||
# build exception instances based on the class name.
|
||||
#
|
||||
# Renaming of exception classes could result in incorrect exception
|
||||
# being raised.
|
||||
|
||||
|
||||
class PluginDaemonError(Exception):
|
||||
"""Base class for all plugin daemon errors."""
|
||||
@ -75,6 +82,10 @@ class PluginInvokeError(PluginDaemonClientSideError, ValueError):
|
||||
)
|
||||
|
||||
|
||||
class PluginLLMPollingUnsupportedError(PluginInvokeError):
|
||||
"""Plugin-backed LLM polling is unavailable for the requested model."""
|
||||
|
||||
|
||||
class PluginUniqueIdentifierError(PluginDaemonClientSideError):
|
||||
description: str = "Unique Identifier Error"
|
||||
|
||||
|
||||
@ -13,13 +13,17 @@ from core.plugin.entities.plugin_daemon import (
|
||||
PluginVoicesResponse,
|
||||
)
|
||||
from core.plugin.impl.base import BasePluginClient
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResultChunk
|
||||
from core.plugin.impl.exc import PluginInvokeError, PluginLLMPollingUnsupportedError
|
||||
from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMResultChunk
|
||||
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
|
||||
from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult
|
||||
from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
|
||||
_POLLING_UNSUPPORTED_INVOKE_ERROR_TYPES = frozenset((NotImplementedError.__name__,))
|
||||
_POLLING_UNSUPPORTED_ERROR_MESSAGE = "does not support polling"
|
||||
|
||||
|
||||
class PluginModelClient(BasePluginClient):
|
||||
@staticmethod
|
||||
@ -197,6 +201,103 @@ class PluginModelClient(BasePluginClient):
|
||||
except PluginDaemonInnerError as e:
|
||||
raise ValueError(e.message + str(e.code))
|
||||
|
||||
def start_llm_polling(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
plugin_id: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
credentials: dict[str, Any],
|
||||
prompt_messages: list[PromptMessage],
|
||||
model_parameters: dict[str, Any] | None = None,
|
||||
tools: list[PromptMessageTool] | None = None,
|
||||
stop: list[str] | None = None,
|
||||
json_schema: dict[str, Any] | None = None,
|
||||
) -> LLMPollingResult:
|
||||
"""Start an LLM polling request for plugin-backed long-running jobs."""
|
||||
try:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
method="POST",
|
||||
path=f"plugin/{tenant_id}/dispatch/model/polling/start",
|
||||
type_=LLMPollingResult,
|
||||
data=jsonable_encoder(
|
||||
self._dispatch_payload(
|
||||
user_id=user_id,
|
||||
data={
|
||||
"provider": provider,
|
||||
"model_type": ModelType.LLM.value,
|
||||
"model": model,
|
||||
"credentials": credentials,
|
||||
"prompt_messages": prompt_messages,
|
||||
"model_parameters": model_parameters,
|
||||
"tools": tools,
|
||||
"stop": stop,
|
||||
"stream": False,
|
||||
"json_schema": json_schema,
|
||||
},
|
||||
)
|
||||
),
|
||||
headers={
|
||||
"X-Plugin-ID": plugin_id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
except PluginInvokeError as error:
|
||||
self._raise_typed_polling_unsupported_error(error)
|
||||
raise
|
||||
|
||||
def check_llm_polling(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
plugin_id: str,
|
||||
provider: str,
|
||||
model: str,
|
||||
credentials: dict[str, Any],
|
||||
plugin_state: dict[str, Any],
|
||||
) -> LLMPollingResult:
|
||||
"""Check the latest state for a plugin-backed LLM polling job."""
|
||||
try:
|
||||
return self._request_with_plugin_daemon_response(
|
||||
method="POST",
|
||||
path=f"plugin/{tenant_id}/dispatch/model/polling/check",
|
||||
type_=LLMPollingResult,
|
||||
data=jsonable_encoder(
|
||||
self._dispatch_payload(
|
||||
user_id=user_id,
|
||||
data={
|
||||
"provider": provider,
|
||||
"model_type": ModelType.LLM.value,
|
||||
"model": model,
|
||||
"credentials": credentials,
|
||||
"plugin_state": plugin_state,
|
||||
},
|
||||
)
|
||||
),
|
||||
headers={
|
||||
"X-Plugin-ID": plugin_id,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
except PluginInvokeError as error:
|
||||
self._raise_typed_polling_unsupported_error(error)
|
||||
raise
|
||||
|
||||
@staticmethod
|
||||
def _raise_typed_polling_unsupported_error(error: PluginInvokeError) -> None:
|
||||
"""Convert plugin polling capability failures into a dedicated Dify exception."""
|
||||
if error.get_error_type() == PluginLLMPollingUnsupportedError.__name__:
|
||||
raise PluginLLMPollingUnsupportedError(description=error.description) from error
|
||||
|
||||
if (
|
||||
error.get_error_type() in _POLLING_UNSUPPORTED_INVOKE_ERROR_TYPES
|
||||
# This is ugly, we should not rely on error messages while checking
|
||||
# error types.
|
||||
and _POLLING_UNSUPPORTED_ERROR_MESSAGE in error.get_error_message().lower()
|
||||
):
|
||||
raise PluginLLMPollingUnsupportedError(description=error.description) from error
|
||||
|
||||
def get_llm_num_tokens(
|
||||
self,
|
||||
tenant_id: str,
|
||||
|
||||
@ -6,6 +6,7 @@ from collections.abc import Generator, Iterable, Sequence
|
||||
from typing import IO, Any, Literal, cast, overload, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
from pydantic.json_schema import JsonValue
|
||||
from redis import RedisError
|
||||
|
||||
from configs import dify_config
|
||||
@ -17,6 +18,7 @@ from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.llm_entities import (
|
||||
LLMPollingResult,
|
||||
LLMResult,
|
||||
LLMResultChunk,
|
||||
LLMResultChunkWithStructuredOutput,
|
||||
@ -430,6 +432,54 @@ class PluginModelRuntime(ModelRuntime):
|
||||
tools=list(tools) if tools else None,
|
||||
)
|
||||
|
||||
def start_llm_polling(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
credentials: dict[str, Any],
|
||||
model_parameters: dict[str, Any],
|
||||
prompt_messages: Sequence[PromptMessage],
|
||||
tools: Sequence[PromptMessageTool] | None,
|
||||
stop: Sequence[str] | None,
|
||||
json_schema: dict[str, Any] | None,
|
||||
) -> LLMPollingResult:
|
||||
"""Start a plugin-side polling job for long-running LLM invocations."""
|
||||
plugin_id, provider_name = self._split_provider(provider)
|
||||
return self.client.start_llm_polling(
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
credentials=credentials,
|
||||
prompt_messages=list(prompt_messages),
|
||||
model_parameters=model_parameters,
|
||||
tools=list(tools) if tools else None,
|
||||
stop=list(stop) if stop else None,
|
||||
json_schema=json_schema,
|
||||
)
|
||||
|
||||
def check_llm_polling(
|
||||
self,
|
||||
*,
|
||||
provider: str,
|
||||
model: str,
|
||||
credentials: dict[str, Any],
|
||||
plugin_state: dict[str, JsonValue],
|
||||
) -> LLMPollingResult:
|
||||
"""Check the latest plugin-side polling state for an LLM invocation."""
|
||||
plugin_id, provider_name = self._split_provider(provider)
|
||||
return self.client.check_llm_polling(
|
||||
tenant_id=self.tenant_id,
|
||||
user_id=self.user_id,
|
||||
plugin_id=plugin_id,
|
||||
provider=provider_name,
|
||||
model=model,
|
||||
credentials=credentials,
|
||||
plugin_state=plugin_state,
|
||||
)
|
||||
|
||||
@override
|
||||
def invoke_text_embedding(
|
||||
self,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel, Field, WithJsonSchema
|
||||
|
||||
SupportedComparisonOperator = Literal[
|
||||
# for string or array
|
||||
@ -26,6 +26,19 @@ SupportedComparisonOperator = Literal[
|
||||
"before",
|
||||
"after",
|
||||
]
|
||||
ConditionValue = Annotated[
|
||||
str | Sequence[str] | None | int | float,
|
||||
WithJsonSchema(
|
||||
{
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"items": {"type": "string"}, "type": "array"},
|
||||
{"type": "number"},
|
||||
{"type": "null"},
|
||||
]
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class Condition(BaseModel):
|
||||
@ -33,9 +46,23 @@ class Condition(BaseModel):
|
||||
Condition detail
|
||||
"""
|
||||
|
||||
name: str
|
||||
comparison_operator: SupportedComparisonOperator
|
||||
value: str | Sequence[str] | None | int | float = None
|
||||
name: str = Field(description="Metadata field name to compare against.")
|
||||
comparison_operator: SupportedComparisonOperator = Field(
|
||||
description=(
|
||||
"Comparison to apply. String operators (`contains`, `not contains`, `start with`, `end with`, `is`, "
|
||||
"`is not`, `empty`, `not empty`, `in`, `not in`) act on string or array metadata; numeric operators "
|
||||
"(`=`, `≠`, `>`, `<`, `≥`, `≤`) act on numeric metadata; time operators (`before`, `after`) act on "
|
||||
"time metadata."
|
||||
)
|
||||
)
|
||||
value: ConditionValue = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Value to compare against. Type depends on `comparison_operator`: string for most string operators, "
|
||||
"array of strings for `in` and `not in`, number for numeric operators, and omit or use `null` for "
|
||||
"`empty` and `not empty`."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class MetadataFilteringCondition(BaseModel):
|
||||
@ -43,5 +70,12 @@ class MetadataFilteringCondition(BaseModel):
|
||||
Metadata Filtering Condition.
|
||||
"""
|
||||
|
||||
logical_operator: Literal["and", "or"] | None = "and"
|
||||
conditions: list[Condition] | None = Field(default=None, deprecated=True)
|
||||
logical_operator: Literal["and", "or"] | None = Field(
|
||||
default="and",
|
||||
description="How to combine multiple conditions.",
|
||||
)
|
||||
conditions: list[Condition] | None = Field(
|
||||
default=None,
|
||||
deprecated=True,
|
||||
description="List of metadata conditions to evaluate.",
|
||||
)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field, WithJsonSchema
|
||||
|
||||
|
||||
class ParentMode(StrEnum):
|
||||
@ -9,19 +9,39 @@ class ParentMode(StrEnum):
|
||||
PARAGRAPH = "paragraph"
|
||||
|
||||
|
||||
PreProcessingRuleID = Annotated[
|
||||
str,
|
||||
WithJsonSchema(
|
||||
{
|
||||
"enum": ["remove_stopwords", "remove_extra_spaces", "remove_urls_emails"],
|
||||
"type": "string",
|
||||
}
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class PreProcessingRule(BaseModel):
|
||||
id: str
|
||||
enabled: bool
|
||||
id: PreProcessingRuleID = Field(description="Rule identifier.")
|
||||
enabled: bool = Field(description="Whether this preprocessing rule is enabled.")
|
||||
|
||||
|
||||
class Segmentation(BaseModel):
|
||||
separator: str = "\n"
|
||||
max_tokens: int
|
||||
chunk_overlap: int = 0
|
||||
separator: str = Field(default="\n", description="Custom separator for splitting text.")
|
||||
max_tokens: int = Field(description="Maximum token count per chunk.")
|
||||
chunk_overlap: int = Field(default=0, description="Token overlap between chunks.")
|
||||
|
||||
|
||||
class Rule(BaseModel):
|
||||
pre_processing_rules: list[PreProcessingRule] | None = None
|
||||
segmentation: Segmentation | None = None
|
||||
parent_mode: Literal["full-doc", "paragraph"] | None = None
|
||||
subchunk_segmentation: Segmentation | None = None
|
||||
pre_processing_rules: list[PreProcessingRule] | None = Field(
|
||||
default=None,
|
||||
description="Pre-processing rules to apply before segmentation.",
|
||||
)
|
||||
segmentation: Segmentation | None = Field(default=None, description="Parent chunk segmentation settings.")
|
||||
parent_mode: Literal["full-doc", "paragraph"] | None = Field(
|
||||
default=None,
|
||||
description="Parent-child segmentation mode.",
|
||||
)
|
||||
subchunk_segmentation: Segmentation | None = Field(
|
||||
default=None,
|
||||
description="Child chunk segmentation settings.",
|
||||
)
|
||||
|
||||
@ -123,7 +123,7 @@ class DatasetRetrieval:
|
||||
if not available_datasets_ids:
|
||||
return []
|
||||
|
||||
if not request.query:
|
||||
if not request.query and not request.attachment_ids:
|
||||
return []
|
||||
|
||||
metadata_filter_document_ids, metadata_condition = None, None
|
||||
|
||||
@ -304,22 +304,23 @@ def _has_dify_refs_recursive(schema: SchemaType) -> bool:
|
||||
Returns:
|
||||
True if any Dify $ref is found, False otherwise
|
||||
"""
|
||||
if isinstance(schema, dict):
|
||||
# Check if this dict has a $ref field
|
||||
ref_uri = schema.get("$ref")
|
||||
if ref_uri and _is_dify_schema_ref(ref_uri):
|
||||
return True
|
||||
|
||||
# Check nested values
|
||||
for value in schema.values():
|
||||
if _has_dify_refs_recursive(value):
|
||||
match schema:
|
||||
case dict():
|
||||
# Check if this dict has a $ref field
|
||||
ref_uri = schema.get("$ref")
|
||||
if ref_uri and _is_dify_schema_ref(ref_uri):
|
||||
return True
|
||||
|
||||
elif isinstance(schema, list):
|
||||
# Check each item in the list
|
||||
for item in schema:
|
||||
if _has_dify_refs_recursive(item):
|
||||
return True
|
||||
# Check nested values
|
||||
for value in schema.values():
|
||||
if _has_dify_refs_recursive(value):
|
||||
return True
|
||||
|
||||
case list():
|
||||
# Check each item in the list
|
||||
for item in schema:
|
||||
if _has_dify_refs_recursive(item):
|
||||
return True
|
||||
|
||||
# Primitive types don't contain refs
|
||||
return False
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
from typing import Any, override
|
||||
from datetime import datetime, tzinfo
|
||||
from typing import Any, cast, override
|
||||
|
||||
import pytz # type: ignore[import-untyped]
|
||||
|
||||
@ -35,17 +35,26 @@ class LocaltimeToTimestampTool(BuiltinTool):
|
||||
|
||||
yield self.create_text_message(f"{timestamp}")
|
||||
|
||||
# TODO: this method's type is messy
|
||||
@staticmethod
|
||||
def localtime_to_timestamp(localtime: str, time_format: str, local_tz=None) -> int | None:
|
||||
def localtime_to_timestamp(localtime: str, time_format: str, local_tz: str | tzinfo | None = None) -> int | None:
|
||||
try:
|
||||
local_time = datetime.strptime(localtime, time_format)
|
||||
if local_tz is None:
|
||||
localtime = local_time.astimezone() # type: ignore
|
||||
elif isinstance(local_tz, str):
|
||||
local_tz = pytz.timezone(local_tz)
|
||||
localtime = local_tz.localize(local_time) # type: ignore
|
||||
timestamp = int(localtime.timestamp()) # type: ignore
|
||||
converted_localtime: datetime
|
||||
match local_tz:
|
||||
case None:
|
||||
converted_localtime = local_time.astimezone()
|
||||
case str() as timezone_name:
|
||||
timezone = pytz.timezone(timezone_name)
|
||||
converted_localtime = timezone.localize(local_time)
|
||||
case tzinfo():
|
||||
localize = getattr(local_tz, "localize", None)
|
||||
if callable(localize):
|
||||
converted_localtime = cast(datetime, localize(local_time))
|
||||
else:
|
||||
converted_localtime = local_time.replace(tzinfo=local_tz)
|
||||
case _:
|
||||
raise ValueError("local_tz must be None, a timezone name, or a tzinfo instance")
|
||||
timestamp = int(converted_localtime.timestamp())
|
||||
return timestamp
|
||||
except Exception as e:
|
||||
raise ToolInvokeError(str(e))
|
||||
|
||||
@ -122,13 +122,14 @@ class MCPTool(Tool):
|
||||
|
||||
def _process_json_content(self, content_json: Any) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process JSON content based on its type."""
|
||||
if isinstance(content_json, dict):
|
||||
yield self.create_json_message(content_json)
|
||||
elif isinstance(content_json, list):
|
||||
yield from self._process_json_list(content_json)
|
||||
else:
|
||||
# For primitive types (str, int, bool, etc.), convert to string
|
||||
yield self.create_text_message(str(content_json))
|
||||
match content_json:
|
||||
case dict():
|
||||
yield self.create_json_message(content_json)
|
||||
case list():
|
||||
yield from self._process_json_list(content_json)
|
||||
case _:
|
||||
# For primitive types (str, int, bool, etc.), convert to string
|
||||
yield self.create_text_message(str(content_json))
|
||||
|
||||
def _process_json_list(self, json_list: list) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process a list of JSON items."""
|
||||
@ -222,16 +223,17 @@ class MCPTool(Tool):
|
||||
|
||||
# Recursively search through nested structures
|
||||
for value in payload.values():
|
||||
if isinstance(value, Mapping):
|
||||
found = cls._extract_usage_dict(value)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(value, list) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for item in value:
|
||||
if isinstance(item, Mapping):
|
||||
found = cls._extract_usage_dict(item)
|
||||
if found is not None:
|
||||
return found
|
||||
match value:
|
||||
case _ if isinstance(value, Mapping):
|
||||
found = cls._extract_usage_dict(value)
|
||||
if found is not None:
|
||||
return found
|
||||
case list() if not isinstance(value, (str, bytes, bytearray)):
|
||||
for item in value:
|
||||
if isinstance(item, Mapping):
|
||||
found = cls._extract_usage_dict(item)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
@override
|
||||
|
||||
@ -398,6 +398,8 @@ class ToolManager:
|
||||
user_id: str | None = None,
|
||||
invoke_from: InvokeFrom = InvokeFrom.DEBUGGER,
|
||||
variable_pool: "VariablePool | None" = None,
|
||||
allow_file_parameters: bool = False,
|
||||
use_default_for_missing_form_parameters: bool = False,
|
||||
) -> Tool:
|
||||
"""
|
||||
get the agent tool runtime
|
||||
@ -415,7 +417,12 @@ class ToolManager:
|
||||
runtime_parameters: dict[str, Any] = {}
|
||||
parameters = tool_entity.get_merged_runtime_parameters()
|
||||
runtime_parameters = cls._convert_tool_parameters_type(
|
||||
parameters, variable_pool, agent_tool.tool_parameters, typ="agent"
|
||||
parameters,
|
||||
variable_pool,
|
||||
agent_tool.tool_parameters,
|
||||
typ="agent",
|
||||
allow_file_parameters=allow_file_parameters,
|
||||
use_default_for_missing_form_parameters=use_default_for_missing_form_parameters,
|
||||
)
|
||||
# decrypt runtime parameters
|
||||
encryption_manager = ToolParameterConfigurationManager(
|
||||
@ -1063,6 +1070,8 @@ class ToolManager:
|
||||
variable_pool: "VariablePool | None",
|
||||
tool_configurations: Mapping[str, Any],
|
||||
typ: Literal["agent", "workflow", "tool"] = "workflow",
|
||||
allow_file_parameters: bool = False,
|
||||
use_default_for_missing_form_parameters: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Convert tool parameters type
|
||||
@ -1081,6 +1090,7 @@ class ToolManager:
|
||||
}
|
||||
and parameter.required
|
||||
and typ == "agent"
|
||||
and not allow_file_parameters
|
||||
):
|
||||
raise ValueError(f"file type parameter {parameter.name} not supported in agent")
|
||||
# save tool parameter to tool entity memory
|
||||
@ -1117,7 +1127,19 @@ class ToolManager:
|
||||
runtime_parameters[parameter.name] = parameter_value
|
||||
|
||||
else:
|
||||
value = parameter.init_frontend_parameter(tool_configurations.get(parameter.name))
|
||||
parameter_value = tool_configurations.get(parameter.name)
|
||||
if use_default_for_missing_form_parameters and parameter_value is None:
|
||||
if parameter.default is not None:
|
||||
parameter_value = parameter.default
|
||||
elif (
|
||||
parameter.required
|
||||
and parameter.type == ToolParameter.ToolParameterType.SELECT
|
||||
and parameter.options
|
||||
):
|
||||
parameter_value = parameter.options[0].value
|
||||
else:
|
||||
continue
|
||||
value = parameter.init_frontend_parameter(parameter_value)
|
||||
runtime_parameters[parameter.name] = value
|
||||
return runtime_parameters
|
||||
|
||||
|
||||
@ -196,16 +196,17 @@ class WorkflowTool(Tool):
|
||||
return usage_candidate
|
||||
|
||||
for value in payload.values():
|
||||
if isinstance(value, Mapping):
|
||||
found = cls._extract_usage_dict(value)
|
||||
if found is not None:
|
||||
return found
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for item in value:
|
||||
if isinstance(item, Mapping):
|
||||
found = cls._extract_usage_dict(item)
|
||||
if found is not None:
|
||||
return found
|
||||
match value:
|
||||
case _ if isinstance(value, Mapping):
|
||||
found = cls._extract_usage_dict(value)
|
||||
if found is not None:
|
||||
return found
|
||||
case _ if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for item in value:
|
||||
if isinstance(item, Mapping):
|
||||
found = cls._extract_usage_dict(item)
|
||||
if found is not None:
|
||||
return found
|
||||
return None
|
||||
|
||||
@override
|
||||
@ -393,24 +394,25 @@ class WorkflowTool(Tool):
|
||||
files: list[File] = []
|
||||
result = {}
|
||||
for key, value in outputs.items():
|
||||
if isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
item = self._update_file_mapping(item)
|
||||
file = build_from_mapping(
|
||||
mapping=item,
|
||||
tenant_id=str(self.runtime.tenant_id),
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
files.append(file)
|
||||
elif isinstance(value, dict) and value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
value = self._update_file_mapping(value)
|
||||
file = build_from_mapping(
|
||||
mapping=value,
|
||||
tenant_id=str(self.runtime.tenant_id),
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
files.append(file)
|
||||
match value:
|
||||
case list():
|
||||
for item in value:
|
||||
if isinstance(item, dict) and item.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
item = self._update_file_mapping(item)
|
||||
file = build_from_mapping(
|
||||
mapping=item,
|
||||
tenant_id=str(self.runtime.tenant_id),
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
files.append(file)
|
||||
case dict() if value.get("dify_model_identity") == FILE_MODEL_IDENTITY:
|
||||
value = self._update_file_mapping(value)
|
||||
file = build_from_mapping(
|
||||
mapping=value,
|
||||
tenant_id=str(self.runtime.tenant_id),
|
||||
access_controller=_file_access_controller,
|
||||
)
|
||||
files.append(file)
|
||||
|
||||
result[key] = value
|
||||
|
||||
|
||||
@ -46,8 +46,8 @@ class EventParameter(BaseModel):
|
||||
)
|
||||
template: PluginParameterTemplate | None = Field(default=None, description="The template of the parameter")
|
||||
scope: str | None = None
|
||||
required: bool | None = False
|
||||
multiple: bool | None = Field(
|
||||
required: bool = False
|
||||
multiple: bool = Field(
|
||||
default=False,
|
||||
description="Whether the parameter is multiple select, only valid for select or dynamic-select type",
|
||||
)
|
||||
|
||||
@ -26,6 +26,7 @@ from core.workflow.node_runtime import (
|
||||
DifyFileReferenceFactory,
|
||||
DifyHumanInputNodeRuntime,
|
||||
DifyPreparedLLM,
|
||||
DifyPreparedPollingLLM,
|
||||
DifyPromptMessageSerializer,
|
||||
DifyRetrieverAttachmentLoader,
|
||||
DifyToolFileManager,
|
||||
@ -531,7 +532,11 @@ class DifyNodeFactory(NodeFactory):
|
||||
node_init_kwargs: dict[str, object] = {
|
||||
"credentials_provider": self._llm_credentials_provider,
|
||||
"model_factory": self._llm_model_factory,
|
||||
"model_instance": DifyPreparedLLM(model_instance) if wrap_model_instance else model_instance,
|
||||
"model_instance": (
|
||||
self._wrap_model_instance_for_node(node_data=validated_node_data, model_instance=model_instance)
|
||||
if wrap_model_instance
|
||||
else model_instance
|
||||
),
|
||||
"memory": self._build_memory_for_llm_node(
|
||||
node_data=validated_node_data,
|
||||
model_instance=model_instance,
|
||||
@ -555,6 +560,23 @@ class DifyNodeFactory(NodeFactory):
|
||||
node_init_kwargs["default_query_selector"] = system_variable_selector(SystemVariableKey.QUERY)
|
||||
return node_init_kwargs
|
||||
|
||||
@staticmethod
|
||||
def _wrap_model_instance_for_node(
|
||||
*,
|
||||
node_data: LLMCompatibleNodeData,
|
||||
model_instance: ModelInstance,
|
||||
) -> DifyPreparedLLM:
|
||||
# Only graphon's LLM node consumes the polling protocol. Keep classifier
|
||||
# and extractor nodes on the existing wrapper even if the same model
|
||||
# advertises polling support.
|
||||
if node_data.type == BuiltinNodeTypes.LLM and DifyNodeFactory._supports_plugin_llm_polling(model_instance):
|
||||
return DifyPreparedPollingLLM(model_instance)
|
||||
return DifyPreparedLLM(model_instance)
|
||||
|
||||
@staticmethod
|
||||
def _supports_plugin_llm_polling(model_instance: ModelInstance) -> bool:
|
||||
return model_instance.get_model_schema().support_polling
|
||||
|
||||
def _build_retriever_attachment_loader(self, node_data: LLMNodeData) -> DifyRetrieverAttachmentLoader:
|
||||
return DifyRetrieverAttachmentLoader(
|
||||
file_reference_factory=self._file_reference_factory,
|
||||
|
||||
@ -4,6 +4,7 @@ from collections.abc import Callable, Generator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast, overload, override
|
||||
|
||||
from pydantic import JsonValue
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@ -38,6 +39,7 @@ from factories import file_factory
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities import LLMMode
|
||||
from graphon.model_runtime.entities.llm_entities import (
|
||||
LLMPollingResult,
|
||||
LLMResult,
|
||||
LLMResultChunk,
|
||||
LLMResultChunkWithStructuredOutput,
|
||||
@ -54,6 +56,7 @@ from graphon.nodes.human_input.entities import (
|
||||
HumanInputNodeData,
|
||||
)
|
||||
from graphon.nodes.llm.runtime_protocols import (
|
||||
LLMPollingCapableProtocol,
|
||||
LLMProtocol,
|
||||
PromptMessageSerializerProtocol,
|
||||
RetrieverAttachmentLoaderProtocol,
|
||||
@ -278,6 +281,58 @@ class DifyPreparedLLM(LLMProtocol):
|
||||
return isinstance(error, OutputParserError)
|
||||
|
||||
|
||||
class DifyPreparedPollingLLM(DifyPreparedLLM, LLMPollingCapableProtocol):
|
||||
"""Prepared workflow LLM adapter that exposes Graphon's polling protocol."""
|
||||
|
||||
def __init__(self, model_instance: ModelInstance) -> None:
|
||||
from core.plugin.impl.model_runtime import PluginModelRuntime
|
||||
|
||||
super().__init__(model_instance)
|
||||
model_type_instance = model_instance.model_type_instance
|
||||
if not isinstance(model_type_instance, LargeLanguageModel):
|
||||
raise TypeError("Polling wrapper requires a large-language-model instance.")
|
||||
|
||||
plugin_model_runtime = model_type_instance.model_runtime
|
||||
if not isinstance(plugin_model_runtime, PluginModelRuntime):
|
||||
raise TypeError("Polling wrapper requires a plugin-backed model runtime.")
|
||||
|
||||
self._plugin_model_runtime = plugin_model_runtime
|
||||
|
||||
@override
|
||||
def start_llm_polling(
|
||||
self,
|
||||
*,
|
||||
prompt_messages: Sequence[PromptMessage],
|
||||
model_parameters: Mapping[str, Any],
|
||||
tools: Sequence[PromptMessageTool] | None,
|
||||
stop: Sequence[str] | None,
|
||||
json_schema: Mapping[str, Any] | None,
|
||||
) -> LLMPollingResult:
|
||||
return self._plugin_model_runtime.start_llm_polling(
|
||||
provider=self.provider,
|
||||
model=self.model_name,
|
||||
credentials=self._model_instance.credentials,
|
||||
prompt_messages=prompt_messages,
|
||||
model_parameters=dict(model_parameters),
|
||||
tools=tools,
|
||||
stop=stop,
|
||||
json_schema=dict(json_schema) if json_schema is not None else None,
|
||||
)
|
||||
|
||||
@override
|
||||
def check_llm_polling(
|
||||
self,
|
||||
*,
|
||||
plugin_state: Mapping[str, JsonValue],
|
||||
) -> LLMPollingResult:
|
||||
return self._plugin_model_runtime.check_llm_polling(
|
||||
provider=self.provider,
|
||||
model=self.model_name,
|
||||
credentials=self._model_instance.credentials,
|
||||
plugin_state=dict(plugin_state),
|
||||
)
|
||||
|
||||
|
||||
class DifyPromptMessageSerializer(PromptMessageSerializerProtocol):
|
||||
@override
|
||||
def serialize(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user