mirror of
https://github.com/langgenius/dify.git
synced 2026-06-18 21:58:18 +08:00
Compare commits
36 Commits
codex/skip
...
feat/cli-e
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b55cbc780 | |||
| 1fd7cf4aca | |||
| dbefcedb7f | |||
| 11e464ddae | |||
| 971f7b964b | |||
| 33af0f55c2 | |||
| 2f60dd6ca5 | |||
| ada1da1781 | |||
| 777ba22431 | |||
| 0d66bbefc3 | |||
| 7306fa4c50 | |||
| fe91ccfe5d | |||
| 2d22d87970 | |||
| 56ba8f421a | |||
| 3133b196ad | |||
| 3cc20de830 | |||
| 363aabee73 | |||
| e61073ccd5 | |||
| 748d790a0d | |||
| 0f52c5e6f3 | |||
| d9b928577c | |||
| 400befc451 | |||
| 4649e52384 | |||
| c045e0b635 | |||
| cf7859cbf9 | |||
| 81d2c1638f | |||
| 69923a16e1 | |||
| 7114415cfd | |||
| 6c8ec0b1c8 | |||
| 5ff98b97df | |||
| 982ada6f4e | |||
| e0d5bc48d9 | |||
| e0e0ae372a | |||
| bc3b1c0c81 | |||
| b734afd609 | |||
| 5646bda88e |
440
.agents/skills/component-refactoring/SKILL.md
Normal file
440
.agents/skills/component-refactoring/SKILL.md
Normal file
@ -0,0 +1,440 @@
|
||||
---
|
||||
name: component-refactoring
|
||||
description: Refactor high-complexity React components in Dify frontend. Use when `pnpm analyze-component --json` shows complexity > 50 or lineCount > 300, when the user asks for code splitting, hook extraction, or complexity reduction, or when `pnpm analyze-component` warns to refactor before testing; avoid for simple/well-structured components, third-party wrappers, or when the user explicitly wants testing without refactoring.
|
||||
---
|
||||
|
||||
# Dify Component Refactoring Skill
|
||||
|
||||
Refactor high-complexity React components in the Dify frontend codebase with the patterns and workflow below.
|
||||
|
||||
> **Complexity Threshold**: Components with complexity > 50 (measured by `pnpm analyze-component`) should be refactored before testing.
|
||||
|
||||
## Quick Reference
|
||||
|
||||
### Commands (run from `web/`)
|
||||
|
||||
Use paths relative to `web/` (e.g., `app/components/...`).
|
||||
Use `refactor-component` for refactoring prompts and `analyze-component` for testing prompts and metrics.
|
||||
|
||||
```bash
|
||||
cd web
|
||||
|
||||
# Generate refactoring prompt
|
||||
pnpm refactor-component <path>
|
||||
|
||||
# Output refactoring analysis as JSON
|
||||
pnpm refactor-component <path> --json
|
||||
|
||||
# Generate testing prompt (after refactoring)
|
||||
pnpm analyze-component <path>
|
||||
|
||||
# Output testing analysis as JSON
|
||||
pnpm analyze-component <path> --json
|
||||
```
|
||||
|
||||
### Complexity Analysis
|
||||
|
||||
```bash
|
||||
# Analyze component complexity
|
||||
pnpm analyze-component <path> --json
|
||||
|
||||
# Key metrics to check:
|
||||
# - complexity: normalized score 0-100 (target < 50)
|
||||
# - maxComplexity: highest single function complexity
|
||||
# - lineCount: total lines (target < 300)
|
||||
```
|
||||
|
||||
### Complexity Score Interpretation
|
||||
|
||||
| Score | Level | Action |
|
||||
|-------|-------|--------|
|
||||
| 0-25 | 🟢 Simple | Ready for testing |
|
||||
| 26-50 | 🟡 Medium | Consider minor refactoring |
|
||||
| 51-75 | 🟠 Complex | **Refactor before testing** |
|
||||
| 76-100 | 🔴 Very Complex | **Must refactor** |
|
||||
|
||||
## Core Refactoring Patterns
|
||||
|
||||
### Pattern 1: Extract Custom Hooks
|
||||
|
||||
**When**: Component has complex state management, multiple `useState`/`useEffect`, or business logic mixed with UI.
|
||||
|
||||
**Dify Convention**: Place hooks in a `hooks/` subdirectory or alongside the component as `use-<feature>.ts`.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Complex state logic in component
|
||||
function Configuration() {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [datasetConfigs, setDatasetConfigs] = useState<DatasetConfigs>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
// 50+ lines of state management logic...
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
|
||||
// ✅ After: Extract to custom hook
|
||||
// hooks/use-model-config.ts
|
||||
export const useModelConfig = (appId: string) => {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
// Related state management logic here
|
||||
|
||||
return { modelConfig, setModelConfig, completionParams, setCompletionParams }
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
function Configuration() {
|
||||
const { modelConfig, setModelConfig } = useModelConfig(appId)
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
**Dify Examples**:
|
||||
- `web/app/components/app/configuration/hooks/use-advanced-prompt-config.ts`
|
||||
- `web/app/components/app/configuration/debug/hooks.tsx`
|
||||
- `web/app/components/workflow/hooks/use-workflow.ts`
|
||||
|
||||
### Pattern 2: Extract Sub-Components
|
||||
|
||||
**When**: Single component has multiple UI sections, conditional rendering blocks, or repeated patterns.
|
||||
|
||||
**Dify Convention**: Place sub-components in subdirectories or as separate files in the same directory.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Monolithic JSX with multiple sections
|
||||
const AppInfo = () => {
|
||||
return (
|
||||
<div>
|
||||
{/* 100 lines of header UI */}
|
||||
{/* 100 lines of operations UI */}
|
||||
{/* 100 lines of modals */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ After: Split into focused components
|
||||
// app-info/
|
||||
// ├── index.tsx (orchestration only)
|
||||
// ├── app-header.tsx (header UI)
|
||||
// ├── app-operations.tsx (operations UI)
|
||||
// └── app-modals.tsx (modal management)
|
||||
|
||||
const AppInfo = () => {
|
||||
const { showModal, setShowModal } = useAppInfoModals()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AppHeader appDetail={appDetail} />
|
||||
<AppOperations onAction={handleAction} />
|
||||
<AppModals show={showModal} onClose={() => setShowModal(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
**Dify Examples**:
|
||||
- `web/app/components/app/configuration/` directory structure
|
||||
- `web/app/components/workflow/nodes/` per-node organization
|
||||
|
||||
### Pattern 3: Simplify Conditional Logic
|
||||
|
||||
**When**: Deep nesting (> 3 levels), complex ternaries, or multiple `if/else` chains.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Deeply nested conditionals
|
||||
const Template = useMemo(() => {
|
||||
if (appDetail?.mode === AppModeEnum.CHAT) {
|
||||
switch (locale) {
|
||||
case LanguagesSupported[1]:
|
||||
return <TemplateChatZh />
|
||||
case LanguagesSupported[7]:
|
||||
return <TemplateChatJa />
|
||||
default:
|
||||
return <TemplateChatEn />
|
||||
}
|
||||
}
|
||||
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
|
||||
// Another 15 lines...
|
||||
}
|
||||
// More conditions...
|
||||
}, [appDetail, locale])
|
||||
|
||||
// ✅ After: Use lookup tables + early returns
|
||||
const TEMPLATE_MAP = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
[LanguagesSupported[1]]: TemplateChatZh,
|
||||
[LanguagesSupported[7]]: TemplateChatJa,
|
||||
default: TemplateChatEn,
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
|
||||
// ...
|
||||
},
|
||||
}
|
||||
|
||||
const Template = useMemo(() => {
|
||||
const modeTemplates = TEMPLATE_MAP[appDetail?.mode]
|
||||
if (!modeTemplates) return null
|
||||
|
||||
const TemplateComponent = modeTemplates[locale] || modeTemplates.default
|
||||
return <TemplateComponent appDetail={appDetail} />
|
||||
}, [appDetail, locale])
|
||||
```
|
||||
|
||||
### Pattern 4: Extract API/Data Logic
|
||||
|
||||
**When**: Component directly handles API calls, data transformation, or complex async operations.
|
||||
|
||||
**Dify Convention**:
|
||||
- This skill is for component decomposition, not query/mutation design.
|
||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||
- Do not add thin passthrough `useQuery` wrappers during refactoring; only extract a custom hook when it truly orchestrates multiple queries/mutations or shared derived state.
|
||||
|
||||
**Dify Examples**:
|
||||
- `web/service/use-workflow.ts`
|
||||
- `web/service/use-common.ts`
|
||||
- `web/service/knowledge/use-dataset.ts`
|
||||
- `web/service/knowledge/use-document.ts`
|
||||
|
||||
### Pattern 5: Extract Modal/Dialog Management
|
||||
|
||||
**When**: Component manages multiple modals with complex open/close states.
|
||||
|
||||
**Dify Convention**: Modals should be extracted with their state management.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Multiple modal states in component
|
||||
const AppInfo = () => {
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
const [showSwitchModal, setShowSwitchModal] = useState(false)
|
||||
const [showImportDSLModal, setShowImportDSLModal] = useState(false)
|
||||
// 5+ more modal states...
|
||||
}
|
||||
|
||||
// ✅ After: Extract to modal management hook
|
||||
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | 'import' | null
|
||||
|
||||
const useAppInfoModals = () => {
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
|
||||
const openModal = useCallback((type: ModalType) => setActiveModal(type), [])
|
||||
const closeModal = useCallback(() => setActiveModal(null), [])
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
openModal,
|
||||
closeModal,
|
||||
isOpen: (type: ModalType) => activeModal === type,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 6: Extract Form Logic
|
||||
|
||||
**When**: Complex form validation, submission handling, or field transformation.
|
||||
|
||||
**Dify Convention**: Use `@tanstack/react-form` patterns from `web/app/components/base/form/`.
|
||||
|
||||
```typescript
|
||||
// ✅ Use existing form infrastructure
|
||||
import { useAppForm } from '@/app/components/base/form'
|
||||
|
||||
const ConfigForm = () => {
|
||||
const form = useAppForm({
|
||||
defaultValues: { name: '', description: '' },
|
||||
onSubmit: handleSubmit,
|
||||
})
|
||||
|
||||
return <form.Provider>...</form.Provider>
|
||||
}
|
||||
```
|
||||
|
||||
## Dify-Specific Refactoring Guidelines
|
||||
|
||||
### 1. Context Provider Extraction
|
||||
|
||||
**When**: Component provides complex context values with multiple states.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Large context value object
|
||||
const value = {
|
||||
appId, isAPIKeySet, isTrailFinished, mode, modelModeType,
|
||||
promptMode, isAdvancedMode, isAgent, isOpenAI, isFunctionCall,
|
||||
// 50+ more properties...
|
||||
}
|
||||
return <ConfigContext.Provider value={value}>...</ConfigContext.Provider>
|
||||
|
||||
// ✅ After: Split into domain-specific contexts
|
||||
<ModelConfigProvider value={modelConfigValue}>
|
||||
<DatasetConfigProvider value={datasetConfigValue}>
|
||||
<UIConfigProvider value={uiConfigValue}>
|
||||
{children}
|
||||
</UIConfigProvider>
|
||||
</DatasetConfigProvider>
|
||||
</ModelConfigProvider>
|
||||
```
|
||||
|
||||
**Dify Reference**: `web/context/` directory structure
|
||||
|
||||
### 2. Workflow Node Components
|
||||
|
||||
**When**: Refactoring workflow node components (`web/app/components/workflow/nodes/`).
|
||||
|
||||
**Conventions**:
|
||||
- Keep node logic in `use-interactions.ts`
|
||||
- Extract panel UI to separate files
|
||||
- Use `_base` components for common patterns
|
||||
|
||||
```
|
||||
nodes/<node-type>/
|
||||
├── index.tsx # Node registration
|
||||
├── node.tsx # Node visual component
|
||||
├── panel.tsx # Configuration panel
|
||||
├── use-interactions.ts # Node-specific hooks
|
||||
└── types.ts # Type definitions
|
||||
```
|
||||
|
||||
### 3. Configuration Components
|
||||
|
||||
**When**: Refactoring app configuration components.
|
||||
|
||||
**Conventions**:
|
||||
- Separate config sections into subdirectories
|
||||
- Use existing patterns from `web/app/components/app/configuration/`
|
||||
- Keep feature toggles in dedicated components
|
||||
|
||||
### 4. Tool/Plugin Components
|
||||
|
||||
**When**: Refactoring tool-related components (`web/app/components/tools/`).
|
||||
|
||||
**Conventions**:
|
||||
- Follow existing modal patterns
|
||||
- Use service hooks from `web/service/use-tools.ts`
|
||||
- Keep provider-specific logic isolated
|
||||
|
||||
## Refactoring Workflow
|
||||
|
||||
### Step 1: Generate Refactoring Prompt
|
||||
|
||||
```bash
|
||||
pnpm refactor-component <path>
|
||||
```
|
||||
|
||||
This command will:
|
||||
- Analyze component complexity and features
|
||||
- Identify specific refactoring actions needed
|
||||
- Generate a prompt for AI assistant (auto-copied to clipboard on macOS)
|
||||
- Provide detailed requirements based on detected patterns
|
||||
|
||||
### Step 2: Analyze Details
|
||||
|
||||
```bash
|
||||
pnpm analyze-component <path> --json
|
||||
```
|
||||
|
||||
Identify:
|
||||
- Total complexity score
|
||||
- Max function complexity
|
||||
- Line count
|
||||
- Features detected (state, effects, API, etc.)
|
||||
|
||||
### Step 3: Plan
|
||||
|
||||
Create a refactoring plan based on detected features:
|
||||
|
||||
| Detected Feature | Refactoring Action |
|
||||
|------------------|-------------------|
|
||||
| `hasState: true` + `hasEffects: true` | Extract custom hook |
|
||||
| `hasAPI: true` | Extract data/service hook |
|
||||
| `hasEvents: true` (many) | Extract event handlers |
|
||||
| `lineCount > 300` | Split into sub-components |
|
||||
| `maxComplexity > 50` | Simplify conditional logic |
|
||||
|
||||
### Step 4: Execute Incrementally
|
||||
|
||||
1. **Extract one piece at a time**
|
||||
2. **Run lint, type-check, and tests after each extraction**
|
||||
3. **Verify functionality before next step**
|
||||
|
||||
```
|
||||
For each extraction:
|
||||
┌────────────────────────────────────────┐
|
||||
│ 1. Extract code │
|
||||
│ 2. Run: pnpm lint:fix │
|
||||
│ 3. Run: pnpm type-check │
|
||||
│ 4. Run: pnpm test │
|
||||
│ 5. Test functionality manually │
|
||||
│ 6. PASS? → Next extraction │
|
||||
│ FAIL? → Fix before continuing │
|
||||
└────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Step 5: Verify
|
||||
|
||||
After refactoring:
|
||||
|
||||
```bash
|
||||
# Re-run refactor command to verify improvements
|
||||
pnpm refactor-component <path>
|
||||
|
||||
# If complexity < 25 and lines < 200, you'll see:
|
||||
# ✅ COMPONENT IS WELL-STRUCTURED
|
||||
|
||||
# For detailed metrics:
|
||||
pnpm analyze-component <path> --json
|
||||
|
||||
# Target metrics:
|
||||
# - complexity < 50
|
||||
# - lineCount < 300
|
||||
# - maxComplexity < 30
|
||||
```
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### ❌ Over-Engineering
|
||||
|
||||
```typescript
|
||||
// ❌ Too many tiny hooks
|
||||
const useButtonText = () => useState('Click')
|
||||
const useButtonDisabled = () => useState(false)
|
||||
const useButtonLoading = () => useState(false)
|
||||
|
||||
// ✅ Cohesive hook with related state
|
||||
const useButtonState = () => {
|
||||
const [text, setText] = useState('Click')
|
||||
const [disabled, setDisabled] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
return { text, setText, disabled, setDisabled, loading, setLoading }
|
||||
}
|
||||
```
|
||||
|
||||
### ❌ Breaking Existing Patterns
|
||||
|
||||
- Follow existing directory structures
|
||||
- Maintain naming conventions
|
||||
- Preserve export patterns for compatibility
|
||||
|
||||
### ❌ Premature Abstraction
|
||||
|
||||
- Only extract when there's clear complexity benefit
|
||||
- Don't create abstractions for single-use code
|
||||
- Keep refactored code in the same domain area
|
||||
|
||||
## References
|
||||
|
||||
### Dify Codebase Examples
|
||||
|
||||
- **Hook extraction**: `web/app/components/app/configuration/hooks/`
|
||||
- **Component splitting**: `web/app/components/app/configuration/`
|
||||
- **Service hooks**: `web/service/use-*.ts`
|
||||
- **Workflow patterns**: `web/app/components/workflow/hooks/`
|
||||
- **Form patterns**: `web/app/components/base/form/`
|
||||
|
||||
### Related Skills
|
||||
|
||||
- `frontend-testing` - For testing refactored components
|
||||
- `web/docs/test.md` - Testing specification
|
||||
@ -0,0 +1,495 @@
|
||||
# Complexity Reduction Patterns
|
||||
|
||||
This document provides patterns for reducing cognitive complexity in Dify React components.
|
||||
|
||||
## Understanding Complexity
|
||||
|
||||
### SonarJS Cognitive Complexity
|
||||
|
||||
The `pnpm analyze-component` tool uses SonarJS cognitive complexity metrics:
|
||||
|
||||
- **Total Complexity**: Sum of all functions' complexity in the file
|
||||
- **Max Complexity**: Highest single function complexity
|
||||
|
||||
### What Increases Complexity
|
||||
|
||||
| Pattern | Complexity Impact |
|
||||
|---------|-------------------|
|
||||
| `if/else` | +1 per branch |
|
||||
| Nested conditions | +1 per nesting level |
|
||||
| `switch/case` | +1 per case |
|
||||
| `for/while/do` | +1 per loop |
|
||||
| `&&`/`||` chains | +1 per operator |
|
||||
| Nested callbacks | +1 per nesting level |
|
||||
| `try/catch` | +1 per catch |
|
||||
| Ternary expressions | +1 per nesting |
|
||||
|
||||
## Pattern 1: Replace Conditionals with Lookup Tables
|
||||
|
||||
**Before** (complexity: ~15):
|
||||
|
||||
```typescript
|
||||
const Template = useMemo(() => {
|
||||
if (appDetail?.mode === AppModeEnum.CHAT) {
|
||||
switch (locale) {
|
||||
case LanguagesSupported[1]:
|
||||
return <TemplateChatZh appDetail={appDetail} />
|
||||
case LanguagesSupported[7]:
|
||||
return <TemplateChatJa appDetail={appDetail} />
|
||||
default:
|
||||
return <TemplateChatEn appDetail={appDetail} />
|
||||
}
|
||||
}
|
||||
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
|
||||
switch (locale) {
|
||||
case LanguagesSupported[1]:
|
||||
return <TemplateAdvancedChatZh appDetail={appDetail} />
|
||||
case LanguagesSupported[7]:
|
||||
return <TemplateAdvancedChatJa appDetail={appDetail} />
|
||||
default:
|
||||
return <TemplateAdvancedChatEn appDetail={appDetail} />
|
||||
}
|
||||
}
|
||||
if (appDetail?.mode === AppModeEnum.WORKFLOW) {
|
||||
// Similar pattern...
|
||||
}
|
||||
return null
|
||||
}, [appDetail, locale])
|
||||
```
|
||||
|
||||
**After** (complexity: ~3):
|
||||
|
||||
```typescript
|
||||
import type { ComponentType } from 'react'
|
||||
|
||||
// Define lookup table outside component
|
||||
const TEMPLATE_MAP: Record<AppModeEnum, Record<string, ComponentType<TemplateProps>>> = {
|
||||
[AppModeEnum.CHAT]: {
|
||||
[LanguagesSupported[1]]: TemplateChatZh,
|
||||
[LanguagesSupported[7]]: TemplateChatJa,
|
||||
default: TemplateChatEn,
|
||||
},
|
||||
[AppModeEnum.ADVANCED_CHAT]: {
|
||||
[LanguagesSupported[1]]: TemplateAdvancedChatZh,
|
||||
[LanguagesSupported[7]]: TemplateAdvancedChatJa,
|
||||
default: TemplateAdvancedChatEn,
|
||||
},
|
||||
[AppModeEnum.WORKFLOW]: {
|
||||
[LanguagesSupported[1]]: TemplateWorkflowZh,
|
||||
[LanguagesSupported[7]]: TemplateWorkflowJa,
|
||||
default: TemplateWorkflowEn,
|
||||
},
|
||||
// ...
|
||||
}
|
||||
|
||||
// Clean component logic
|
||||
const Template = useMemo(() => {
|
||||
if (!appDetail?.mode) return null
|
||||
|
||||
const templates = TEMPLATE_MAP[appDetail.mode]
|
||||
if (!templates) return null
|
||||
|
||||
const TemplateComponent = templates[locale] ?? templates.default
|
||||
return <TemplateComponent appDetail={appDetail} />
|
||||
}, [appDetail, locale])
|
||||
```
|
||||
|
||||
## Pattern 2: Use Early Returns
|
||||
|
||||
**Before** (complexity: ~10):
|
||||
|
||||
```typescript
|
||||
const handleSubmit = () => {
|
||||
if (isValid) {
|
||||
if (hasChanges) {
|
||||
if (isConnected) {
|
||||
submitData()
|
||||
} else {
|
||||
showConnectionError()
|
||||
}
|
||||
} else {
|
||||
showNoChangesMessage()
|
||||
}
|
||||
} else {
|
||||
showValidationError()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: ~4):
|
||||
|
||||
```typescript
|
||||
const handleSubmit = () => {
|
||||
if (!isValid) {
|
||||
showValidationError()
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasChanges) {
|
||||
showNoChangesMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isConnected) {
|
||||
showConnectionError()
|
||||
return
|
||||
}
|
||||
|
||||
submitData()
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 3: Extract Complex Conditions
|
||||
|
||||
**Before** (complexity: high):
|
||||
|
||||
```typescript
|
||||
const canPublish = (() => {
|
||||
if (mode !== AppModeEnum.COMPLETION) {
|
||||
if (!isAdvancedMode)
|
||||
return true
|
||||
|
||||
if (modelModeType === ModelModeType.completion) {
|
||||
if (!hasSetBlockStatus.history || !hasSetBlockStatus.query)
|
||||
return false
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
return !promptEmpty
|
||||
})()
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Extract to named functions
|
||||
const canPublishInCompletionMode = () => !promptEmpty
|
||||
|
||||
const canPublishInChatMode = () => {
|
||||
if (!isAdvancedMode) return true
|
||||
if (modelModeType !== ModelModeType.completion) return true
|
||||
return hasSetBlockStatus.history && hasSetBlockStatus.query
|
||||
}
|
||||
|
||||
// Clean main logic
|
||||
const canPublish = mode === AppModeEnum.COMPLETION
|
||||
? canPublishInCompletionMode()
|
||||
: canPublishInChatMode()
|
||||
```
|
||||
|
||||
## Pattern 4: Replace Chained Ternaries
|
||||
|
||||
**Before** (complexity: ~5):
|
||||
|
||||
```typescript
|
||||
const statusText = serverActivated
|
||||
? t('status.running')
|
||||
: serverPublished
|
||||
? t('status.inactive')
|
||||
: appUnpublished
|
||||
? t('status.unpublished')
|
||||
: t('status.notConfigured')
|
||||
```
|
||||
|
||||
**After** (complexity: ~2):
|
||||
|
||||
```typescript
|
||||
const getStatusText = () => {
|
||||
if (serverActivated) return t('status.running')
|
||||
if (serverPublished) return t('status.inactive')
|
||||
if (appUnpublished) return t('status.unpublished')
|
||||
return t('status.notConfigured')
|
||||
}
|
||||
|
||||
const statusText = getStatusText()
|
||||
```
|
||||
|
||||
Or use lookup:
|
||||
|
||||
```typescript
|
||||
const STATUS_TEXT_MAP = {
|
||||
running: 'status.running',
|
||||
inactive: 'status.inactive',
|
||||
unpublished: 'status.unpublished',
|
||||
notConfigured: 'status.notConfigured',
|
||||
} as const
|
||||
|
||||
const getStatusKey = (): keyof typeof STATUS_TEXT_MAP => {
|
||||
if (serverActivated) return 'running'
|
||||
if (serverPublished) return 'inactive'
|
||||
if (appUnpublished) return 'unpublished'
|
||||
return 'notConfigured'
|
||||
}
|
||||
|
||||
const statusText = t(STATUS_TEXT_MAP[getStatusKey()])
|
||||
```
|
||||
|
||||
## Pattern 5: Flatten Nested Loops
|
||||
|
||||
**Before** (complexity: high):
|
||||
|
||||
```typescript
|
||||
const processData = (items: Item[]) => {
|
||||
const results: ProcessedItem[] = []
|
||||
|
||||
for (const item of items) {
|
||||
if (item.isValid) {
|
||||
for (const child of item.children) {
|
||||
if (child.isActive) {
|
||||
for (const prop of child.properties) {
|
||||
if (prop.value !== null) {
|
||||
results.push({
|
||||
itemId: item.id,
|
||||
childId: child.id,
|
||||
propValue: prop.value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Use functional approach
|
||||
const processData = (items: Item[]) => {
|
||||
return items
|
||||
.filter(item => item.isValid)
|
||||
.flatMap(item =>
|
||||
item.children
|
||||
.filter(child => child.isActive)
|
||||
.flatMap(child =>
|
||||
child.properties
|
||||
.filter(prop => prop.value !== null)
|
||||
.map(prop => ({
|
||||
itemId: item.id,
|
||||
childId: child.id,
|
||||
propValue: prop.value,
|
||||
}))
|
||||
)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 6: Extract Event Handler Logic
|
||||
|
||||
**Before** (complexity: high in component):
|
||||
|
||||
```typescript
|
||||
const Component = () => {
|
||||
const handleSelect = (data: DataSet[]) => {
|
||||
if (isEqual(data.map(item => item.id), dataSets.map(item => item.id))) {
|
||||
hideSelectDataSet()
|
||||
return
|
||||
}
|
||||
|
||||
formattingChangedDispatcher()
|
||||
let newDatasets = data
|
||||
if (data.find(item => !item.name)) {
|
||||
const newSelected = produce(data, (draft) => {
|
||||
data.forEach((item, index) => {
|
||||
if (!item.name) {
|
||||
const newItem = dataSets.find(i => i.id === item.id)
|
||||
if (newItem)
|
||||
draft[index] = newItem
|
||||
}
|
||||
})
|
||||
})
|
||||
setDataSets(newSelected)
|
||||
newDatasets = newSelected
|
||||
}
|
||||
else {
|
||||
setDataSets(data)
|
||||
}
|
||||
hideSelectDataSet()
|
||||
|
||||
// 40 more lines of logic...
|
||||
}
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
**After** (complexity: lower):
|
||||
|
||||
```typescript
|
||||
// Extract to hook or utility
|
||||
const useDatasetSelection = (dataSets: DataSet[], setDataSets: SetState<DataSet[]>) => {
|
||||
const normalizeSelection = (data: DataSet[]) => {
|
||||
const hasUnloadedItem = data.some(item => !item.name)
|
||||
if (!hasUnloadedItem) return data
|
||||
|
||||
return produce(data, (draft) => {
|
||||
data.forEach((item, index) => {
|
||||
if (!item.name) {
|
||||
const existing = dataSets.find(i => i.id === item.id)
|
||||
if (existing) draft[index] = existing
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const hasSelectionChanged = (newData: DataSet[]) => {
|
||||
return !isEqual(
|
||||
newData.map(item => item.id),
|
||||
dataSets.map(item => item.id)
|
||||
)
|
||||
}
|
||||
|
||||
return { normalizeSelection, hasSelectionChanged }
|
||||
}
|
||||
|
||||
// Component becomes cleaner
|
||||
const Component = () => {
|
||||
const { normalizeSelection, hasSelectionChanged } = useDatasetSelection(dataSets, setDataSets)
|
||||
|
||||
const handleSelect = (data: DataSet[]) => {
|
||||
if (!hasSelectionChanged(data)) {
|
||||
hideSelectDataSet()
|
||||
return
|
||||
}
|
||||
|
||||
formattingChangedDispatcher()
|
||||
const normalized = normalizeSelection(data)
|
||||
setDataSets(normalized)
|
||||
hideSelectDataSet()
|
||||
}
|
||||
|
||||
return <div>...</div>
|
||||
}
|
||||
```
|
||||
|
||||
## Pattern 7: Reduce Boolean Logic Complexity
|
||||
|
||||
**Before** (complexity: ~8):
|
||||
|
||||
```typescript
|
||||
const toggleDisabled = hasInsufficientPermissions
|
||||
|| appUnpublished
|
||||
|| missingStartNode
|
||||
|| triggerModeDisabled
|
||||
|| (isAdvancedApp && !currentWorkflow?.graph)
|
||||
|| (isBasicApp && !basicAppConfig.updated_at)
|
||||
```
|
||||
|
||||
**After** (complexity: ~3):
|
||||
|
||||
```typescript
|
||||
// Extract meaningful boolean functions
|
||||
const isAppReady = () => {
|
||||
if (isAdvancedApp) return !!currentWorkflow?.graph
|
||||
return !!basicAppConfig.updated_at
|
||||
}
|
||||
|
||||
const hasRequiredPermissions = () => {
|
||||
return isCurrentWorkspaceEditor && !hasInsufficientPermissions
|
||||
}
|
||||
|
||||
const canToggle = () => {
|
||||
if (!hasRequiredPermissions()) return false
|
||||
if (!isAppReady()) return false
|
||||
if (missingStartNode) return false
|
||||
if (triggerModeDisabled) return false
|
||||
return true
|
||||
}
|
||||
|
||||
const toggleDisabled = !canToggle()
|
||||
```
|
||||
|
||||
## Pattern 8: Simplify useMemo/useCallback Dependencies
|
||||
|
||||
**Before** (complexity: multiple recalculations):
|
||||
|
||||
```typescript
|
||||
const payload = useMemo(() => {
|
||||
let parameters: Parameter[] = []
|
||||
let outputParameters: OutputParameter[] = []
|
||||
|
||||
if (!published) {
|
||||
parameters = (inputs || []).map((item) => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
form: 'llm',
|
||||
required: item.required,
|
||||
type: item.type,
|
||||
}))
|
||||
outputParameters = (outputs || []).map((item) => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
type: item.value_type,
|
||||
}))
|
||||
}
|
||||
else if (detail && detail.tool) {
|
||||
parameters = (inputs || []).map((item) => ({
|
||||
// Complex transformation...
|
||||
}))
|
||||
outputParameters = (outputs || []).map((item) => ({
|
||||
// Complex transformation...
|
||||
}))
|
||||
}
|
||||
|
||||
return {
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
// ...more fields
|
||||
}
|
||||
}, [detail, published, workflowAppId, icon, name, description, inputs, outputs])
|
||||
```
|
||||
|
||||
**After** (complexity: separated concerns):
|
||||
|
||||
```typescript
|
||||
// Separate transformations
|
||||
const useParameterTransform = (inputs: InputVar[], detail?: ToolDetail, published?: boolean) => {
|
||||
return useMemo(() => {
|
||||
if (!published) {
|
||||
return inputs.map(item => ({
|
||||
name: item.variable,
|
||||
description: '',
|
||||
form: 'llm',
|
||||
required: item.required,
|
||||
type: item.type,
|
||||
}))
|
||||
}
|
||||
|
||||
if (!detail?.tool) return []
|
||||
|
||||
return inputs.map(item => ({
|
||||
name: item.variable,
|
||||
required: item.required,
|
||||
type: item.type === 'paragraph' ? 'string' : item.type,
|
||||
description: detail.tool.parameters.find(p => p.name === item.variable)?.llm_description || '',
|
||||
form: detail.tool.parameters.find(p => p.name === item.variable)?.form || 'llm',
|
||||
}))
|
||||
}, [inputs, detail, published])
|
||||
}
|
||||
|
||||
// Component uses hook
|
||||
const parameters = useParameterTransform(inputs, detail, published)
|
||||
const outputParameters = useOutputTransform(outputs, detail, published)
|
||||
|
||||
const payload = useMemo(() => ({
|
||||
icon: detail?.icon || icon,
|
||||
label: detail?.label || name,
|
||||
parameters,
|
||||
outputParameters,
|
||||
// ...
|
||||
}), [detail, icon, name, parameters, outputParameters])
|
||||
```
|
||||
|
||||
## Target Metrics After Refactoring
|
||||
|
||||
| Metric | Target |
|
||||
|--------|--------|
|
||||
| Total Complexity | < 50 |
|
||||
| Max Function Complexity | < 30 |
|
||||
| Function Length | < 30 lines |
|
||||
| Nesting Depth | ≤ 3 levels |
|
||||
| Conditional Chains | ≤ 3 conditions |
|
||||
@ -0,0 +1,477 @@
|
||||
# Component Splitting Patterns
|
||||
|
||||
This document provides detailed guidance on splitting large components into smaller, focused components in Dify.
|
||||
|
||||
## When to Split Components
|
||||
|
||||
Split a component when you identify:
|
||||
|
||||
1. **Multiple UI sections** - Distinct visual areas with minimal coupling that can be composed independently
|
||||
1. **Conditional rendering blocks** - Large `{condition && <JSX />}` blocks
|
||||
1. **Repeated patterns** - Similar UI structures used multiple times
|
||||
1. **300+ lines** - Component exceeds manageable size
|
||||
1. **Modal clusters** - Multiple modals rendered in one component
|
||||
|
||||
## Splitting Strategies
|
||||
|
||||
### Strategy 1: Section-Based Splitting
|
||||
|
||||
Identify visual sections and extract each as a component.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Monolithic component (500+ lines)
|
||||
const ConfigurationPage = () => {
|
||||
return (
|
||||
<div>
|
||||
{/* Header Section - 50 lines */}
|
||||
<div className="header">
|
||||
<h1>{t('configuration.title')}</h1>
|
||||
<div className="actions">
|
||||
{isAdvancedMode && <Badge>Advanced</Badge>}
|
||||
<ModelParameterModal ... />
|
||||
<AppPublisher ... />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Config Section - 200 lines */}
|
||||
<div className="config">
|
||||
<Config />
|
||||
</div>
|
||||
|
||||
{/* Debug Section - 150 lines */}
|
||||
<div className="debug">
|
||||
<Debug ... />
|
||||
</div>
|
||||
|
||||
{/* Modals Section - 100 lines */}
|
||||
{showSelectDataSet && <SelectDataSet ... />}
|
||||
{showHistoryModal && <EditHistoryModal ... />}
|
||||
{showUseGPT4Confirm && <Confirm ... />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ After: Split into focused components
|
||||
// configuration/
|
||||
// ├── index.tsx (orchestration)
|
||||
// ├── configuration-header.tsx
|
||||
// ├── configuration-content.tsx
|
||||
// ├── configuration-debug.tsx
|
||||
// └── configuration-modals.tsx
|
||||
|
||||
// configuration-header.tsx
|
||||
interface ConfigurationHeaderProps {
|
||||
isAdvancedMode: boolean
|
||||
onPublish: () => void
|
||||
}
|
||||
|
||||
function ConfigurationHeader({
|
||||
isAdvancedMode,
|
||||
onPublish,
|
||||
}: ConfigurationHeaderProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="header">
|
||||
<h1>{t('configuration.title')}</h1>
|
||||
<div className="actions">
|
||||
{isAdvancedMode && <Badge>Advanced</Badge>}
|
||||
<ModelParameterModal ... />
|
||||
<AppPublisher onPublish={onPublish} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// index.tsx (orchestration only)
|
||||
const ConfigurationPage = () => {
|
||||
const { modelConfig, setModelConfig } = useModelConfig()
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ConfigurationHeader
|
||||
isAdvancedMode={isAdvancedMode}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
<ConfigurationContent
|
||||
modelConfig={modelConfig}
|
||||
onConfigChange={setModelConfig}
|
||||
/>
|
||||
{!isMobile && (
|
||||
<ConfigurationDebug
|
||||
inputs={inputs}
|
||||
onSetting={handleSetting}
|
||||
/>
|
||||
)}
|
||||
<ConfigurationModals
|
||||
activeModal={activeModal}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 2: Conditional Block Extraction
|
||||
|
||||
Extract large conditional rendering blocks.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Large conditional blocks
|
||||
const AppInfo = () => {
|
||||
return (
|
||||
<div>
|
||||
{expand ? (
|
||||
<div className="expanded">
|
||||
{/* 100 lines of expanded view */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="collapsed">
|
||||
{/* 50 lines of collapsed view */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ After: Separate view components
|
||||
function AppInfoExpanded({ appDetail, onAction }: AppInfoViewProps) {
|
||||
return (
|
||||
<div className="expanded">
|
||||
{/* Clean, focused expanded view */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AppInfoCollapsed({ appDetail, onAction }: AppInfoViewProps) {
|
||||
return (
|
||||
<div className="collapsed">
|
||||
{/* Clean, focused collapsed view */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AppInfo = () => {
|
||||
return (
|
||||
<div>
|
||||
{expand
|
||||
? <AppInfoExpanded appDetail={appDetail} onAction={handleAction} />
|
||||
: <AppInfoCollapsed appDetail={appDetail} onAction={handleAction} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 3: Modal Extraction
|
||||
|
||||
Extract modals with their trigger logic.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Multiple modals in one component
|
||||
const AppInfo = () => {
|
||||
const [showEdit, setShowEdit] = useState(false)
|
||||
const [showDuplicate, setShowDuplicate] = useState(false)
|
||||
const [showDelete, setShowDelete] = useState(false)
|
||||
const [showSwitch, setShowSwitch] = useState(false)
|
||||
|
||||
const onEdit = async (data) => { /* 20 lines */ }
|
||||
const onDuplicate = async (data) => { /* 20 lines */ }
|
||||
const onDelete = async () => { /* 15 lines */ }
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main content */}
|
||||
|
||||
{showEdit && <EditModal onConfirm={onEdit} onClose={() => setShowEdit(false)} />}
|
||||
{showDuplicate && <DuplicateModal onConfirm={onDuplicate} onClose={() => setShowDuplicate(false)} />}
|
||||
{showDelete && <DeleteConfirm onConfirm={onDelete} onClose={() => setShowDelete(false)} />}
|
||||
{showSwitch && <SwitchModal ... />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ After: Modal manager component
|
||||
// app-info-modals.tsx
|
||||
type ModalType = 'edit' | 'duplicate' | 'delete' | 'switch' | null
|
||||
|
||||
interface AppInfoModalsProps {
|
||||
appDetail: AppDetail
|
||||
activeModal: ModalType
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
function AppInfoModals({
|
||||
appDetail,
|
||||
activeModal,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: AppInfoModalsProps) {
|
||||
const handleEdit = async (data) => { /* logic */ }
|
||||
const handleDuplicate = async (data) => { /* logic */ }
|
||||
const handleDelete = async () => { /* logic */ }
|
||||
|
||||
return (
|
||||
<>
|
||||
{activeModal === 'edit' && (
|
||||
<EditModal
|
||||
appDetail={appDetail}
|
||||
onConfirm={handleEdit}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'duplicate' && (
|
||||
<DuplicateModal
|
||||
appDetail={appDetail}
|
||||
onConfirm={handleDuplicate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'delete' && (
|
||||
<DeleteConfirm
|
||||
onConfirm={handleDelete}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
{activeModal === 'switch' && (
|
||||
<SwitchModal
|
||||
appDetail={appDetail}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Parent component
|
||||
const AppInfo = () => {
|
||||
const { activeModal, openModal, closeModal } = useModalState()
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Main content with openModal triggers */}
|
||||
<Button onClick={() => openModal('edit')}>Edit</Button>
|
||||
|
||||
<AppInfoModals
|
||||
appDetail={appDetail}
|
||||
activeModal={activeModal}
|
||||
onClose={closeModal}
|
||||
onSuccess={handleSuccess}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Strategy 4: List Item Extraction
|
||||
|
||||
Extract repeated item rendering.
|
||||
|
||||
```typescript
|
||||
// ❌ Before: Inline item rendering
|
||||
const OperationsList = () => {
|
||||
return (
|
||||
<div>
|
||||
{operations.map(op => (
|
||||
<div key={op.id} className="operation-item">
|
||||
<span className="icon">{op.icon}</span>
|
||||
<span className="title">{op.title}</span>
|
||||
<span className="description">{op.description}</span>
|
||||
<button onClick={() => op.onClick()}>
|
||||
{op.actionLabel}
|
||||
</button>
|
||||
{op.badge && <Badge>{op.badge}</Badge>}
|
||||
{/* More complex rendering... */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ✅ After: Extracted item component
|
||||
interface OperationItemProps {
|
||||
operation: Operation
|
||||
onAction: (id: string) => void
|
||||
}
|
||||
|
||||
function OperationItem({ operation, onAction }: OperationItemProps) {
|
||||
return (
|
||||
<div className="operation-item">
|
||||
<span className="icon">{operation.icon}</span>
|
||||
<span className="title">{operation.title}</span>
|
||||
<span className="description">{operation.description}</span>
|
||||
<button onClick={() => onAction(operation.id)}>
|
||||
{operation.actionLabel}
|
||||
</button>
|
||||
{operation.badge && <Badge>{operation.badge}</Badge>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const OperationsList = () => {
|
||||
const handleAction = useCallback((id: string) => {
|
||||
const op = operations.find(o => o.id === id)
|
||||
op?.onClick()
|
||||
}, [operations])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{operations.map(op => (
|
||||
<OperationItem
|
||||
key={op.id}
|
||||
operation={op}
|
||||
onAction={handleAction}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Structure Patterns
|
||||
|
||||
### Pattern A: Flat Structure (Simple Components)
|
||||
|
||||
For components with 2-3 sub-components:
|
||||
|
||||
```
|
||||
component-name/
|
||||
├── index.tsx # Main component
|
||||
├── sub-component-a.tsx
|
||||
├── sub-component-b.tsx
|
||||
└── types.ts # Shared types
|
||||
```
|
||||
|
||||
### Pattern B: Nested Structure (Complex Components)
|
||||
|
||||
For components with many sub-components:
|
||||
|
||||
```
|
||||
component-name/
|
||||
├── index.tsx # Main orchestration
|
||||
├── types.ts # Shared types
|
||||
├── hooks/
|
||||
│ ├── use-feature-a.ts
|
||||
│ └── use-feature-b.ts
|
||||
├── components/
|
||||
│ ├── header/
|
||||
│ │ └── index.tsx
|
||||
│ ├── content/
|
||||
│ │ └── index.tsx
|
||||
│ └── modals/
|
||||
│ └── index.tsx
|
||||
└── utils/
|
||||
└── helpers.ts
|
||||
```
|
||||
|
||||
### Pattern C: Feature-Based Structure (Dify Standard)
|
||||
|
||||
Following Dify's existing patterns:
|
||||
|
||||
```
|
||||
configuration/
|
||||
├── index.tsx # Main page component
|
||||
├── base/ # Base/shared components
|
||||
│ ├── feature-panel/
|
||||
│ ├── group-name/
|
||||
│ └── operation-btn/
|
||||
├── config/ # Config section
|
||||
│ ├── index.tsx
|
||||
│ ├── agent/
|
||||
│ └── automatic/
|
||||
├── dataset-config/ # Dataset section
|
||||
│ ├── index.tsx
|
||||
│ ├── card-item/
|
||||
│ └── params-config/
|
||||
├── debug/ # Debug section
|
||||
│ ├── index.tsx
|
||||
│ └── hooks.tsx
|
||||
└── hooks/ # Shared hooks
|
||||
└── use-advanced-prompt-config.ts
|
||||
```
|
||||
|
||||
## Props Design
|
||||
|
||||
### Minimal Props Principle
|
||||
|
||||
Pass only what's needed:
|
||||
|
||||
```typescript
|
||||
// ❌ Bad: Passing entire objects when only some fields needed
|
||||
<ConfigHeader appDetail={appDetail} modelConfig={modelConfig} />
|
||||
|
||||
// ✅ Good: Destructure to minimum required
|
||||
<ConfigHeader
|
||||
appName={appDetail.name}
|
||||
isAdvancedMode={modelConfig.isAdvanced}
|
||||
onPublish={handlePublish}
|
||||
/>
|
||||
```
|
||||
|
||||
### Callback Props Pattern
|
||||
|
||||
Use callbacks for child-to-parent communication:
|
||||
|
||||
```typescript
|
||||
// Parent
|
||||
const Parent = () => {
|
||||
const [value, setValue] = useState('')
|
||||
|
||||
return (
|
||||
<Child
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Child
|
||||
interface ChildProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
}
|
||||
|
||||
function Child({ value, onChange, onSubmit }: ChildProps) {
|
||||
return (
|
||||
<div>
|
||||
<input value={value} onChange={e => onChange(e.target.value)} />
|
||||
<button onClick={onSubmit}>Submit</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Render Props for Flexibility
|
||||
|
||||
When sub-components need parent context:
|
||||
|
||||
```typescript
|
||||
interface ListProps<T> {
|
||||
items: T[]
|
||||
renderItem: (item: T, index: number) => React.ReactNode
|
||||
renderEmpty?: () => React.ReactNode
|
||||
}
|
||||
|
||||
function List<T>({ items, renderItem, renderEmpty }: ListProps<T>) {
|
||||
if (items.length === 0 && renderEmpty) {
|
||||
return <>{renderEmpty()}</>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{items.map((item, index) => renderItem(item, index))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Usage
|
||||
<List
|
||||
items={operations}
|
||||
renderItem={(op, i) => <OperationItem key={i} operation={op} />}
|
||||
renderEmpty={() => <EmptyState message="No operations" />}
|
||||
/>
|
||||
```
|
||||
@ -0,0 +1,281 @@
|
||||
# Hook Extraction Patterns
|
||||
|
||||
This document provides detailed guidance on extracting custom hooks from complex components in Dify.
|
||||
|
||||
## When to Extract Hooks
|
||||
|
||||
Extract a custom hook when you identify:
|
||||
|
||||
1. **Coupled state groups** - Multiple `useState` hooks that are always used together
|
||||
1. **Complex effects** - `useEffect` with multiple dependencies or cleanup logic
|
||||
1. **Business logic** - Data transformations, validations, or calculations
|
||||
1. **Reusable patterns** - Logic that appears in multiple components
|
||||
|
||||
## Extraction Process
|
||||
|
||||
### Step 1: Identify State Groups
|
||||
|
||||
Look for state variables that are logically related:
|
||||
|
||||
```typescript
|
||||
// ❌ These belong together - extract to hook
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
const [modelModeType, setModelModeType] = useState<ModelModeType>(...)
|
||||
|
||||
// These are model-related state that should be in useModelConfig()
|
||||
```
|
||||
|
||||
### Step 2: Identify Related Effects
|
||||
|
||||
Find effects that modify the grouped state:
|
||||
|
||||
```typescript
|
||||
// ❌ These effects belong with the state above
|
||||
useEffect(() => {
|
||||
if (hasFetchedDetail && !modelModeType) {
|
||||
const mode = currModel?.model_properties.mode
|
||||
if (mode) {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.mode = mode
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}
|
||||
}
|
||||
}, [textGenerationModelList, hasFetchedDetail, modelModeType, currModel])
|
||||
```
|
||||
|
||||
### Step 3: Create the Hook
|
||||
|
||||
```typescript
|
||||
// hooks/use-model-config.ts
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { ModelConfig } from '@/models/debug'
|
||||
import { produce } from 'immer'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
|
||||
interface UseModelConfigParams {
|
||||
initialConfig?: Partial<ModelConfig>
|
||||
currModel?: { model_properties?: { mode?: ModelModeType } }
|
||||
hasFetchedDetail: boolean
|
||||
}
|
||||
|
||||
interface UseModelConfigReturn {
|
||||
modelConfig: ModelConfig
|
||||
setModelConfig: (config: ModelConfig) => void
|
||||
completionParams: FormValue
|
||||
setCompletionParams: (params: FormValue) => void
|
||||
modelModeType: ModelModeType
|
||||
}
|
||||
|
||||
export const useModelConfig = ({
|
||||
initialConfig,
|
||||
currModel,
|
||||
hasFetchedDetail,
|
||||
}: UseModelConfigParams): UseModelConfigReturn => {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>({
|
||||
provider: 'langgenius/openai/openai',
|
||||
model_id: 'gpt-3.5-turbo',
|
||||
mode: ModelModeType.unset,
|
||||
// ... default values
|
||||
...initialConfig,
|
||||
})
|
||||
|
||||
const [completionParams, setCompletionParams] = useState<FormValue>({})
|
||||
|
||||
const modelModeType = modelConfig.mode
|
||||
|
||||
// Fill old app data missing model mode
|
||||
useEffect(() => {
|
||||
if (hasFetchedDetail && !modelModeType) {
|
||||
const mode = currModel?.model_properties?.mode
|
||||
if (mode) {
|
||||
setModelConfig(produce(modelConfig, (draft) => {
|
||||
draft.mode = mode
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [hasFetchedDetail, modelModeType, currModel])
|
||||
|
||||
return {
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
completionParams,
|
||||
setCompletionParams,
|
||||
modelModeType,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: Update Component
|
||||
|
||||
```typescript
|
||||
// Before: 50+ lines of state management
|
||||
function Configuration() {
|
||||
const [modelConfig, setModelConfig] = useState<ModelConfig>(...)
|
||||
// ... lots of related state and effects
|
||||
}
|
||||
|
||||
// After: Clean component
|
||||
function Configuration() {
|
||||
const {
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
completionParams,
|
||||
setCompletionParams,
|
||||
modelModeType,
|
||||
} = useModelConfig({
|
||||
currModel,
|
||||
hasFetchedDetail,
|
||||
})
|
||||
|
||||
// Component now focuses on UI
|
||||
}
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Hook Names
|
||||
|
||||
- Use `use` prefix: `useModelConfig`, `useDatasetConfig`
|
||||
- Be specific: `useAdvancedPromptConfig` not `usePrompt`
|
||||
- Include domain: `useWorkflowVariables`, `useMCPServer`
|
||||
|
||||
### File Names
|
||||
|
||||
- Kebab-case: `use-model-config.ts`
|
||||
- Place in `hooks/` subdirectory when multiple hooks exist
|
||||
- Place alongside component for single-use hooks
|
||||
|
||||
### Return Type Names
|
||||
|
||||
- Suffix with `Return`: `UseModelConfigReturn`
|
||||
- Suffix params with `Params`: `UseModelConfigParams`
|
||||
|
||||
## Common Hook Patterns in Dify
|
||||
|
||||
### 1. Data Fetching / Mutation Hooks
|
||||
|
||||
When hook extraction touches query or mutation code, do not use this reference as the source of truth for data-layer patterns.
|
||||
|
||||
- Do not introduce deprecated `useInvalid` / `useReset`.
|
||||
- Do not extract thin passthrough `useQuery` hooks; only extract orchestration hooks.
|
||||
|
||||
### 2. Form State Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Form state + validation + submission
|
||||
export const useConfigForm = (initialValues: ConfigFormValues) => {
|
||||
const [values, setValues] = useState(initialValues)
|
||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const validate = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {}
|
||||
if (!values.name) newErrors.name = 'Name is required'
|
||||
setErrors(newErrors)
|
||||
return Object.keys(newErrors).length === 0
|
||||
}, [values])
|
||||
|
||||
const handleChange = useCallback((field: string, value: any) => {
|
||||
setValues(prev => ({ ...prev, [field]: value }))
|
||||
}, [])
|
||||
|
||||
const handleSubmit = useCallback(async (onSubmit: (values: ConfigFormValues) => Promise<void>) => {
|
||||
if (!validate()) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await onSubmit(values)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [values, validate])
|
||||
|
||||
return { values, errors, isSubmitting, handleChange, handleSubmit }
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Modal State Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Multiple modal management
|
||||
type ModalType = 'edit' | 'delete' | 'duplicate' | null
|
||||
|
||||
export const useModalState = () => {
|
||||
const [activeModal, setActiveModal] = useState<ModalType>(null)
|
||||
const [modalData, setModalData] = useState<any>(null)
|
||||
|
||||
const openModal = useCallback((type: ModalType, data?: any) => {
|
||||
setActiveModal(type)
|
||||
setModalData(data)
|
||||
}, [])
|
||||
|
||||
const closeModal = useCallback(() => {
|
||||
setActiveModal(null)
|
||||
setModalData(null)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
activeModal,
|
||||
modalData,
|
||||
openModal,
|
||||
closeModal,
|
||||
isOpen: useCallback((type: ModalType) => activeModal === type, [activeModal]),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Toggle/Boolean Hook
|
||||
|
||||
```typescript
|
||||
// Pattern: Boolean state with convenience methods
|
||||
export const useToggle = (initialValue = false) => {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
|
||||
const toggle = useCallback(() => setValue(v => !v), [])
|
||||
const setTrue = useCallback(() => setValue(true), [])
|
||||
const setFalse = useCallback(() => setValue(false), [])
|
||||
|
||||
return [value, { toggle, setTrue, setFalse, set: setValue }] as const
|
||||
}
|
||||
|
||||
// Usage
|
||||
const [isExpanded, { toggle, setTrue: expand, setFalse: collapse }] = useToggle()
|
||||
```
|
||||
|
||||
## Testing Extracted Hooks
|
||||
|
||||
After extraction, test hooks in isolation:
|
||||
|
||||
```typescript
|
||||
// use-model-config.spec.ts
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
import { useModelConfig } from './use-model-config'
|
||||
|
||||
describe('useModelConfig', () => {
|
||||
it('should initialize with default values', () => {
|
||||
const { result } = renderHook(() => useModelConfig({
|
||||
hasFetchedDetail: false,
|
||||
}))
|
||||
|
||||
expect(result.current.modelConfig.provider).toBe('langgenius/openai/openai')
|
||||
expect(result.current.modelModeType).toBe(ModelModeType.unset)
|
||||
})
|
||||
|
||||
it('should update model config', () => {
|
||||
const { result } = renderHook(() => useModelConfig({
|
||||
hasFetchedDetail: true,
|
||||
}))
|
||||
|
||||
act(() => {
|
||||
result.current.setModelConfig({
|
||||
...result.current.modelConfig,
|
||||
model_id: 'gpt-4',
|
||||
})
|
||||
})
|
||||
|
||||
expect(result.current.modelConfig.model_id).toBe('gpt-4')
|
||||
})
|
||||
})
|
||||
```
|
||||
@ -1,94 +1,73 @@
|
||||
---
|
||||
name: frontend-code-review
|
||||
description: Review Dify frontend code for correctness, accessibility, component design, dify-ui usage, data/query boundaries, performance, and tests. Trigger for `.tsx`, `.ts`, `.js`, UI, React, Next.js, pending-change, or focused frontend review requests.
|
||||
description: "Trigger when the user requests a review of frontend files (e.g., `.tsx`, `.ts`, `.js`). Support both pending-change reviews and focused file reviews while applying the checklist rules."
|
||||
---
|
||||
|
||||
# Frontend Code Review
|
||||
|
||||
## When To Use
|
||||
## Intent
|
||||
Use this skill whenever the user asks to review frontend code (especially `.tsx`, `.ts`, or `.js` files). Support two review modes:
|
||||
|
||||
Use this skill when the user asks to review, audit, analyze, or sanity-check frontend code under `web/`, `packages/dify-ui/`, or frontend-adjacent TypeScript files.
|
||||
1. **Pending-change review** – inspect staged/working-tree files slated for commit and flag checklist violations before submission.
|
||||
2. **File-targeted review** – review the specific file(s) the user names and report the relevant checklist findings.
|
||||
|
||||
Supported modes:
|
||||
Stick to the checklist below for every applicable file and mode.
|
||||
|
||||
- **Pending-change review**: inspect staged and working-tree changes.
|
||||
- **File-focused review**: inspect explicitly named files or paths.
|
||||
- **Diff/snippet review**: review pasted diffs or snippets using best-effort references.
|
||||
## Checklist
|
||||
See [references/code-quality.md](references/code-quality.md), [references/performance.md](references/performance.md), [references/business-logic.md](references/business-logic.md) for the living checklist split by category—treat it as the canonical set of rules to follow.
|
||||
|
||||
Do not use this skill for backend-only code under `api/`; use `backend-code-review` instead.
|
||||
|
||||
## Required Context
|
||||
|
||||
Before reviewing, read the relevant local contracts:
|
||||
|
||||
- `web/AGENTS.md` for Dify frontend workflow, overlays, design tokens, state, and tests.
|
||||
- `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md` when code uses or changes `@langgenius/dify-ui/*`.
|
||||
- `web/docs/overlay.md` when reviewing dialogs, drawers, popovers, tooltips, menus, selects, comboboxes, or other floating UI.
|
||||
- `web/docs/test.md` and the `frontend-testing` skill when reviewing tests or testability.
|
||||
- `karpathy-guidelines` for scope control and focused, verifiable changes.
|
||||
- `how-to-write-component` when reviewing React component structure, ownership, effects, query/mutation contracts, or memoization.
|
||||
|
||||
For any UI, UX, or accessibility review, fetch the latest Web Interface Guidelines before finalizing findings. Treat them as a required baseline, not the complete source of accessibility truth:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
If the review depends on a current framework, SDK, browser API, or accessibility behavior and local code does not settle it, check the current official docs first. For browser compatibility, deprecation, or behavior-sensitive frontend APIs, verify MDN or the relevant standard.
|
||||
|
||||
## Rule Packs
|
||||
|
||||
Apply every relevant rule pack:
|
||||
|
||||
- [references/accessibility-ui.md](references/accessibility-ui.md) — accessibility, semantic HTML, focus, forms, keyboard, disabled states, copy, and long-content behavior. Combines Web Interface Guidelines with Dify UI, Base UI, MDN, and local primitive contracts.
|
||||
- [references/dify-ui.md](references/dify-ui.md) — Dify UI primitive usage, Base UI semantics, overlays, forms, tokens, radius mapping, and primitive boundaries.
|
||||
- [references/component-architecture.md](references/component-architecture.md) — component ownership, props, state, effects, exports, wrappers, and feature organization.
|
||||
- [references/data-query-contracts.md](references/data-query-contracts.md) — generated contracts, TanStack Query, mutations, workspace/auth/SSR boundaries, URL/local storage state.
|
||||
- [references/performance.md](references/performance.md) — React/Next performance review rules from Vercel guidance, scoped to real risk.
|
||||
- [references/testing.md](references/testing.md) — frontend test review rules.
|
||||
- [references/dify-invariants.md](references/dify-invariants.md) — stable Dify-specific runtime invariants that generic React/a11y rules will not catch.
|
||||
- [references/code-quality.md](references/code-quality.md) — general TypeScript, styling, naming, and maintainability rules.
|
||||
Flag each rule violation with urgency metadata so future reviewers can prioritize fixes.
|
||||
|
||||
## Review Process
|
||||
1. Open the relevant component/module. Gather lines that relate to class names, React Flow hooks, prop memoization, and styling.
|
||||
2. For each rule in the review point, note where the code deviates and capture a representative snippet.
|
||||
3. Compose the review section per the template below. Group violations first by **Urgent** flag, then by category order (Code Quality, Performance, Business Logic).
|
||||
|
||||
1. Identify the review scope. For pending changes, inspect `git diff --stat`, `git diff`, and staged diff if relevant. For file-focused reviews, stay within the named files unless a referenced owner/contract must be read.
|
||||
2. Read code around the changed lines and the owning module. Do not review by isolated snippets when nearby ownership, labels, query inputs, or overlay structure decide correctness.
|
||||
3. Check user-visible regressions first: accessibility, broken interaction, auth/permission leaks, query/hydration errors, data loss, navigation mistakes, and impossible states.
|
||||
4. Then check maintainability and performance: ownership, effects, wrappers, memoization, bundle/waterfall risks, tests, and design-system drift.
|
||||
5. Report only actionable findings. Do not list speculative risks, style preferences, or broad refactors unless they are directly tied to a reproducible issue in scope.
|
||||
## Required output
|
||||
When invoked, the response must exactly follow one of the two templates:
|
||||
|
||||
## Severity
|
||||
### Template A (any findings)
|
||||
```
|
||||
# Code review
|
||||
Found <N> urgent issues need to be fixed:
|
||||
|
||||
- **P0**: security/privacy/auth leak, data loss, production crash, inaccessible critical flow, or broken primary workflow.
|
||||
- **P1**: user-visible regression, hydration/SSR failure, invalid API/query contract, broken keyboard/focus behavior, or serious design-system/a11y violation.
|
||||
- **P2**: maintainability or performance issue likely to cause bugs, duplicated state, incorrect ownership, missing tests for risky behavior, or non-critical a11y issue.
|
||||
- **P3**: minor cleanup with clear value. Omit unless the user asked for a thorough audit.
|
||||
## 1 <brief description of bug>
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
## Output Format
|
||||
|
||||
Lead with findings, ordered by severity. Use this structure:
|
||||
### Suggested fix
|
||||
<brief description of suggested fix>
|
||||
|
||||
```markdown
|
||||
## Findings
|
||||
---
|
||||
... (repeat for each urgent issue) ...
|
||||
|
||||
- [P1] Short issue title
|
||||
File: `path/to/file.tsx:123`
|
||||
Why it matters and how to reproduce or reason about it.
|
||||
Suggested fix: concrete fix direction.
|
||||
Found <M> suggestions for improvement:
|
||||
|
||||
## Open Questions
|
||||
## 1 <brief description of suggestion>
|
||||
FilePath: <path> line <line>
|
||||
<relevant code snippet or pointer>
|
||||
|
||||
- Question or assumption, if any.
|
||||
|
||||
## Summary
|
||||
### Suggested fix
|
||||
<brief description of suggested fix>
|
||||
|
||||
Brief secondary context. Mention tests not run or residual risk.
|
||||
---
|
||||
|
||||
... (repeat for each suggestion) ...
|
||||
```
|
||||
|
||||
Rules:
|
||||
If there are no urgent issues, omit that section. If there are no suggestions, omit that section.
|
||||
|
||||
If the issue number is more than 10, summarize as "10+ urgent issues" or "10+ suggestions" and just output the first 10 issues.
|
||||
|
||||
Don't compress the blank lines between sections; keep them as-is for readability.
|
||||
|
||||
If you use Template A (i.e., there are issues to fix) and at least one issue requires code changes, append a brief follow-up question after the structured output asking whether the user wants you to apply the suggested fix(es). For example: "Would you like me to use the Suggested fix section to address these issues?"
|
||||
|
||||
### Template B (no issues)
|
||||
```
|
||||
## Code review
|
||||
No issues found.
|
||||
```
|
||||
|
||||
- If there are no findings, say `No issues found.` and mention any test gaps or residual risk.
|
||||
- Always include file and line when available.
|
||||
- Keep findings concrete and reproducible.
|
||||
- Do not include praise sections by default.
|
||||
- Do not ask to apply fixes unless the user explicitly wants review plus implementation.
|
||||
|
||||
@ -1,109 +0,0 @@
|
||||
# Accessibility And UI Rules
|
||||
|
||||
Accessibility findings are first-class review findings. Treat broken keyboard access, missing accessible names, focus loss, and unreachable popup content as correctness bugs, not polish.
|
||||
|
||||
Before finalizing UI or accessibility findings, fetch the latest Web Interface Guidelines as a required baseline:
|
||||
|
||||
```text
|
||||
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
|
||||
```
|
||||
|
||||
Do not treat that document as the complete accessibility rule set. Combine it with:
|
||||
|
||||
- `packages/dify-ui/README.md`, `packages/dify-ui/AGENTS.md`, and the relevant primitive implementation when code uses `@langgenius/dify-ui/*`.
|
||||
- Base UI docs and local `.d.ts` contracts when primitive semantics, focus target, labels, or popup reachability are unclear.
|
||||
- MDN or relevant WAI-ARIA/browser standards when behavior, compatibility, or deprecation status matters.
|
||||
- The current feature's product semantics, because an accessible primitive can still be used in an inaccessible workflow.
|
||||
|
||||
## Semantic HTML
|
||||
|
||||
Flag:
|
||||
|
||||
- Clickable `div` or `span` used for actions.
|
||||
- Router navigation implemented with button or `onClick` when a `Link` / `<a>` is the real semantic element.
|
||||
- Icon-only buttons without `aria-label` or `aria-labelledby`.
|
||||
- Decorative icons missing `aria-hidden="true"`.
|
||||
- Images without `alt`; use `alt=""` only when truly decorative.
|
||||
- Heading levels that skip hierarchy in page-level content.
|
||||
|
||||
Prefer semantic HTML before ARIA.
|
||||
|
||||
## Keyboard And Focus
|
||||
|
||||
Flag:
|
||||
|
||||
- Interactive elements without visible `focus-visible` treatment.
|
||||
- `outline-none` / `outline-hidden` without an equivalent focus-visible ring or state.
|
||||
- Custom interactive elements missing keyboard handling.
|
||||
- Focus trapped, lost, or sent to the wrong surface after dialog/popover/menu close.
|
||||
- Focus ring applied to the wrong DOM node. Verify the actual focus target, especially with Base UI controls such as Slider.
|
||||
|
||||
Use `focus-visible` for keyboard focus. Use `focus-within` or `has-[:focus-visible]` when the visual wrapper is not the focused element.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Inputs, selects, switches, checkboxes, radios, comboboxes, or sliders without a label relationship.
|
||||
- Missing stable `name` on form fields that submit or validate.
|
||||
- Incorrect input `type`, `inputMode`, `autoComplete`, or `spellCheck` for email, token, URL, number, search, code, or username fields.
|
||||
- Labels that are not clickable.
|
||||
- Submit buttons disabled before a request starts, preventing normal submit behavior.
|
||||
- Non-submit buttons inside forms missing `type="button"`.
|
||||
- Errors not associated with fields or not reachable by screen readers.
|
||||
- Error recovery that does not focus or expose the first invalid field.
|
||||
- `onPaste` blocking paste.
|
||||
- Placeholder text used as the only label.
|
||||
- Password managers accidentally triggered on non-auth fields because autocomplete is missing or wrong.
|
||||
|
||||
Prefer visible labels. If visible surrounding text already labels the control, use a visually hidden label or a precise `aria-label`.
|
||||
|
||||
## Disabled, Loading, And Async States
|
||||
|
||||
Flag:
|
||||
|
||||
- Loading state without `aria-busy`, `role="status"`, or another accessible update path when it changes user interaction.
|
||||
- Spinner or decorative loading icon exposed to screen readers.
|
||||
- Disabled controls that hide the reason users cannot proceed.
|
||||
- `aria-disabled` used without manually blocking click, Space, and Enter.
|
||||
- Toasts, inline validation, or async status changes that are not announced when users need the update to continue.
|
||||
- Icon-only loading/error affordances without text or accessible status where the state matters.
|
||||
|
||||
Use native `disabled` when the control must not be interactive. Use `aria-disabled` only when the element must remain focusable and the code handles all blocked interactions.
|
||||
|
||||
For repeated shared disabled reasons, prefer a visible group message or badge plus native disabled controls. Use per-control popover/info only when the reason is item-specific.
|
||||
|
||||
## Overlays And Popup Reachability
|
||||
|
||||
Flag:
|
||||
|
||||
- Tooltip used for long, structured, interactive, or unique information.
|
||||
- Tooltip content required to understand or complete a flow.
|
||||
- PreviewCard content that touch or screen-reader users cannot reach through the trigger's click destination.
|
||||
- Popover/dialog/menu triggers without accessible names.
|
||||
- Popup content without title/description where the primitive requires them.
|
||||
|
||||
Use Popover for explanatory content, rich help, and infotips. Use Tooltip only as a short visual label for a trigger that already has an accessible name.
|
||||
|
||||
## Long Content And Layout
|
||||
|
||||
Flag:
|
||||
|
||||
- Text in flex/grid children without `min-w-0` when it can overflow.
|
||||
- Names, labels, file names, model names, workspace names, or user content lacking `truncate`, `line-clamp`, or `break-words`.
|
||||
- Right-side icons, badges, checks, or actions that shrink before the text area.
|
||||
- Empty arrays or empty strings rendering broken layout instead of an empty state.
|
||||
- Button, tab, badge, chip, menu item, or card text that can overlap sibling controls at common viewport widths.
|
||||
|
||||
The usual Dify layout chain is: container has width constraints, text region uses `min-w-0 flex-1 truncate`, adornments use `shrink-0`.
|
||||
|
||||
## Motion, Images, And Copy
|
||||
|
||||
Flag:
|
||||
|
||||
- `transition-all`.
|
||||
- Animations that do not respect reduced motion.
|
||||
- Layout-affecting animation where transform/opacity would work.
|
||||
- Images without dimensions.
|
||||
- Loading copy using `...` instead of `…`.
|
||||
- Hardcoded dates, times, numbers, or currency formats instead of `Intl.*`.
|
||||
@ -0,0 +1,15 @@
|
||||
# Rule Catalog — Business Logic
|
||||
|
||||
## Can't use workflowStore in Node components
|
||||
|
||||
IsUrgent: True
|
||||
|
||||
### Description
|
||||
|
||||
File path pattern of node components: `web/app/components/workflow/nodes/[nodeName]/node.tsx`
|
||||
|
||||
Node components are also used when creating a RAG Pipe from a template, but in that context there is no workflowStore Provider, which results in a blank screen. [This Issue](https://github.com/langgenius/dify/issues/29168) was caused by exactly this reason.
|
||||
|
||||
### Suggested Fix
|
||||
|
||||
Use `import { useNodes } from 'reactflow'` instead of `import useNodes from '@/app/components/workflow/store/workflow/use-nodes'`.
|
||||
@ -1,68 +1,44 @@
|
||||
# Code Quality Rules
|
||||
# Rule Catalog — Code Quality
|
||||
|
||||
## Scope Control
|
||||
## Conditional class names use utility function
|
||||
|
||||
Flag changes that expand beyond the requested feature or review scope:
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
- Repo-wide cleanup mixed into a targeted fix.
|
||||
- Compatibility exports, aliases, shims, or wrapper layers added without an explicit migration requirement.
|
||||
- Shared abstractions created before there is stable cross-feature reuse.
|
||||
- Business components moved into generic shared locations without a clear ownership boundary.
|
||||
### Description
|
||||
|
||||
## TypeScript
|
||||
Ensure conditional CSS is handled via the shared `classNames` instead of custom ternaries, string concatenation, or template strings. Centralizing class logic keeps components consistent and easier to maintain.
|
||||
|
||||
Flag:
|
||||
### Suggested Fix
|
||||
|
||||
- `any` or broad `Record<string, any>` where generated/API types or local domain types exist.
|
||||
- Re-declared API shapes instead of importing generated or returned types.
|
||||
- Weak route/query param typing that leaks `string | string[] | undefined` deep into components.
|
||||
- Runtime wrappers added only to satisfy TypeScript when a narrower type boundary would preserve the existing runtime shape.
|
||||
```ts
|
||||
import { cn } from '@/utils/classnames'
|
||||
const classNames = cn(isActive ? 'text-primary-600' : 'text-gray-500')
|
||||
```
|
||||
|
||||
Prefer:
|
||||
## Tailwind-first styling
|
||||
|
||||
- Explicit domain names that match the API contract.
|
||||
- Type narrowing at route/API boundaries.
|
||||
- Small conversion helpers colocated with the component that needs them.
|
||||
IsUrgent: True
|
||||
Category: Code Quality
|
||||
|
||||
## Styling
|
||||
### Description
|
||||
|
||||
Flag:
|
||||
Favor Tailwind CSS utility classes instead of adding new `.module.css` files unless a Tailwind combination cannot achieve the required styling. Keeping styles in Tailwind improves consistency and reduces maintenance overhead.
|
||||
|
||||
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
|
||||
- Component-level plain `.css` files or component CSS imported through `globals.css`; use scoped `*.module.css` only when Tailwind and component variants cannot express the style.
|
||||
- Generic color utilities where Dify semantic tokens exist.
|
||||
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
|
||||
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
|
||||
- Manual string concatenation, template strings, array `.join(' ')`, or custom ternaries for conditional or multi-line classes.
|
||||
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
|
||||
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
|
||||
- Arbitrary z-index or one-off layering fixes on overlays.
|
||||
Update this file when adding, editing, or removing Code Quality rules so the catalog remains accurate.
|
||||
|
||||
Use:
|
||||
## Classname ordering for easy overrides
|
||||
|
||||
- `cn(...)` from the local package or utility already used by the file.
|
||||
- Dify semantic tokens and Tailwind v4 utilities.
|
||||
- Existing component variants before one-off class forks.
|
||||
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
|
||||
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
|
||||
### Description
|
||||
|
||||
## Imports
|
||||
When writing components, always place the incoming `className` prop after the component’s own class values so that downstream consumers can override or extend the styling. This keeps your component’s defaults but still lets external callers change or remove specific styles.
|
||||
|
||||
Flag:
|
||||
Example:
|
||||
|
||||
- Barrel imports from `@langgenius/dify-ui`; consumers must use subpath exports.
|
||||
- New overlay imports from legacy `@/app/components/base/modal`, `dialog`, or `drawer`.
|
||||
- Cross-feature imports that bypass explicit top-level public files.
|
||||
- Direct imports from generated/internal implementation files when a feature contract already exposes the intended surface.
|
||||
```tsx
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
## Copy And i18n
|
||||
|
||||
Flag:
|
||||
|
||||
- User-facing hardcoded strings in `web/`.
|
||||
- Added or renamed i18n keys that are not present in every supported locale file for the touched namespace.
|
||||
- Translation namespace drift, especially using unrelated module namespaces for local feature copy.
|
||||
- Generic button labels like `Continue` where the action is specific.
|
||||
- Error messages that state only the failure and not the next step.
|
||||
|
||||
Use feature-local translation keys by default. Alias only when crossing namespaces. `pnpm i18n:check --file <name>` should pass for any touched translation namespace.
|
||||
const Button = ({ className }) => {
|
||||
return <div className={cn('bg-primary-600', className)}></div>
|
||||
}
|
||||
```
|
||||
|
||||
@ -1,89 +0,0 @@
|
||||
# Component Architecture Rules
|
||||
|
||||
Use these rules for React component structure, ownership, state, props, effects, and module organization.
|
||||
|
||||
## Ownership
|
||||
|
||||
Flag:
|
||||
|
||||
- State, query, mutation, or handlers hoisted above the lowest component that actually uses them.
|
||||
- Parent components owning row/item actions that do not coordinate a workflow.
|
||||
- Prop drilling through multiple pass-through layers.
|
||||
- A page/tab-level section component becoming the data owner without needing a shared snapshot or shared loading/error/empty UI.
|
||||
- Feature code promoted to shared only because it appears once or might be reused later.
|
||||
|
||||
Accept repeated TanStack Query calls in siblings when each component independently consumes the data. Cache deduplication is not a reason to hoist by itself.
|
||||
|
||||
## Component Boundaries
|
||||
|
||||
Flag:
|
||||
|
||||
- React component files over 300 lines when the file mixes multiple responsibilities that can be split into focused colocated components, hooks, or utilities.
|
||||
- Shallow wrappers that only rename props or hide the real primitive.
|
||||
- Extra DOM wrappers that do not provide layout, semantics, accessibility, state ownership, or library integration.
|
||||
- Dialog/dropdown/popover hidden surfaces that obscure the parent flow when they should be extracted into a small local component.
|
||||
- Business forms, menu bodies, or one-off helpers moved away from their owner without reuse or semantic value.
|
||||
|
||||
Prefer colocated components split by actual data and state needs.
|
||||
|
||||
## Bad Component Design Patterns
|
||||
|
||||
Flag:
|
||||
|
||||
- Refactors of existing navigation, sidebar, dropdown, webapp list, or app-switching UI that do not preserve behavior-sensitive interactions such as expand/collapse arrows, hover persistence, pin/delete controls, routing, keyboard/focus handling, or open-state ownership.
|
||||
- Components that mix data fetching, mutation side effects, popup state, form validation, layout, and row rendering without a clear owner.
|
||||
- Generic components with many boolean props that encode one feature's workflow.
|
||||
- A shared component that imports feature-specific copy, routes, or API contracts.
|
||||
- A feature component that accepts pre-rendered fragments only to avoid placing ownership correctly.
|
||||
- A child component that receives both raw server data and separately derived flags for the same concept.
|
||||
- A wrapper that changes accessible semantics of the primitive it wraps.
|
||||
- A component that exposes controlled props but still keeps a competing private state for the same value.
|
||||
- A component that cannot render empty, loading, or missing optional API fields without caller-side preprocessing.
|
||||
|
||||
When existing components already own interaction logic, prefer reusing or extending them. If a refactor is necessary, preserve the old interaction contract and add or update focused tests for changed behavior.
|
||||
|
||||
## Props And Types
|
||||
|
||||
Flag:
|
||||
|
||||
- `React.FC` / `FC`.
|
||||
- Default exports outside framework-required files.
|
||||
- Named `Props` types for trivial one-off props where inline typing is clearer.
|
||||
- Props named by UI implementation instead of domain/API role.
|
||||
- API data converted too early or under a generic name that breaks traceability.
|
||||
- Callers duplicating fallback checks that the lowest rendering component already handles.
|
||||
|
||||
Prefer top-level `function` declarations for components and module helpers. Use arrow functions for callbacks and local lambdas.
|
||||
|
||||
## Effects
|
||||
|
||||
Flag effects that:
|
||||
|
||||
- Transform props/state for rendering.
|
||||
- Copy one state value into another representing the same concept.
|
||||
- Handle user actions that belong in event handlers.
|
||||
- Reset state from props when a keyed reset, stable ID, or render-time derivation would work.
|
||||
- Fetch data that belongs in framework APIs or TanStack Query.
|
||||
|
||||
If an effect remains, it must synchronize with a named external system: browser API, subscription, timer, analytics-on-visibility, non-React widget, or imperative DOM integration.
|
||||
|
||||
## State Modeling
|
||||
|
||||
Flag:
|
||||
|
||||
- Storing derived booleans, disabled flags, default tabs, or loading labels that can be calculated from current query/feature state.
|
||||
- Local state used to fake server data or generated contract fields.
|
||||
- UI state persisted to localStorage when it is live app state.
|
||||
- Feature-local mock shells wired to unrelated existing APIs before the real API is confirmed.
|
||||
|
||||
Prefer render-time derivation. Keep true local state for user choices, transient input, controlled popups, and feature UI state that has no server source.
|
||||
|
||||
## Navigation
|
||||
|
||||
Flag:
|
||||
|
||||
- Imperative router navigation for ordinary links.
|
||||
- Button semantics used for navigation.
|
||||
- Navigation state hidden in component state when URL state is required for shareable filters, tabs, or pagination.
|
||||
|
||||
Use `Link` for normal navigation. Use router APIs for mutation success, guarded redirects, command flows, or form submission side effects.
|
||||
@ -1,74 +0,0 @@
|
||||
# Data, Query, And Contract Rules
|
||||
|
||||
Use these rules for generated contracts, TanStack Query, mutations, auth/SSR boundaries, URL state, and client persistence.
|
||||
|
||||
## Generated Contracts
|
||||
|
||||
Flag:
|
||||
|
||||
- New legacy service/helper wrappers around generated `queryOptions()` or `mutationOptions()`.
|
||||
- Continuing to use deprecated contract operations when a ready generated contract exists.
|
||||
- Assuming a generated file means an operation is ready without checking deprecated markers, schema shape, and the actual UI consumer.
|
||||
- Re-declaring API DTOs in components.
|
||||
- Adding compatibility layers instead of migrating the pointed line and deleting the old layer.
|
||||
|
||||
Use `web/contract/*` as the API shape source of truth. Follow existing `{ params, query?, body? }` input shape.
|
||||
|
||||
## Queries
|
||||
|
||||
Flag:
|
||||
|
||||
- `enabled` used to hide missing required input instead of `input: skipToken`.
|
||||
- Fake fallback IDs or placeholder inputs used to force a query to run.
|
||||
- Query results copied into local state for rendering.
|
||||
- Shared query behavior such as invalidation, stale defaults, or retry rules reimplemented at call sites.
|
||||
- `prefetchQuery` treated as a hard gate or as returning data/errors to the caller.
|
||||
|
||||
Use `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))` directly unless a feature hook performs real orchestration.
|
||||
|
||||
## Mutations
|
||||
|
||||
Flag:
|
||||
|
||||
- Deprecated `useInvalid` or `useReset`.
|
||||
- `mutateAsync` used without a need for Promise semantics.
|
||||
- Awaited mutations without `try/catch`.
|
||||
- Components owning shared cache invalidation that belongs in query defaults.
|
||||
- Optimistic updates that do not match current list/detail ownership.
|
||||
|
||||
Use generated `mutationOptions()` directly when possible. Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`.
|
||||
|
||||
## SSR, Auth, And Route Boundaries
|
||||
|
||||
Flag:
|
||||
|
||||
- Request-time auth, setup, workspace role, or tenant decisions moved into static `next.config redirects()`.
|
||||
- Dynamic role gates depending on `workspaces.current` implemented as static path redirects.
|
||||
- Authorization logic depending on soft `prefetchQuery`.
|
||||
- Removing a client fallback before server API unavailable behavior is defined.
|
||||
- Global placeholder query contracts introduced to solve a route-local Suspense issue.
|
||||
- Branding-sensitive UI reading placeholder defaults without checking pending/placeholder state.
|
||||
|
||||
Separate hard gates from soft prefetches. `fetchQuery` can be a server decision boundary; `prefetchQuery` is cache warmup.
|
||||
|
||||
## Workspace And Tenant
|
||||
|
||||
Flag:
|
||||
|
||||
- Treating workspace switch as ordinary CRUD invalidation when the current app flow performs server switch plus full reload.
|
||||
- Query keys that omit workspace/tenant identity when the query truly varies by workspace and no full reload boundary applies.
|
||||
- Mixing `workspace_id` and `tenant_id` without tracing the current backend/API contract.
|
||||
|
||||
Current Dify workspace switch should be reviewed as a tenant cache boundary first.
|
||||
|
||||
## URL State And Local Storage
|
||||
|
||||
Flag:
|
||||
|
||||
- Shareable filters, tabs, pagination, selected panels, or search state hidden only in component state.
|
||||
- One-shot navigation signals modeled as subscribed persistent state.
|
||||
- Live app state stored in localStorage.
|
||||
- Direct `window.localStorage`, `globalThis.localStorage`, or raw storage calls in app code.
|
||||
- High-frequency interaction state persisted on every change instead of on commit/settle.
|
||||
|
||||
Use URL state for shareable UI state, feature/Jotai/store state for live UI state, and `@/hooks/use-local-storage` only for low-frequency client-only preferences, dismissed notices, and UI defaults.
|
||||
@ -1,22 +0,0 @@
|
||||
# Dify Invariants
|
||||
|
||||
Use these stable Dify-specific runtime rules in addition to the generic review packs.
|
||||
|
||||
This file is not a place for active feature notes. Do not add rules for one branch, one PR, or a short-lived product decision such as a specific agent-v2, plugin, model-provider, or onboarding task. Keep a rule here only when all of these are true:
|
||||
|
||||
- It is a stable Dify runtime invariant.
|
||||
- Generic React, TypeScript, accessibility, dify-ui, query, or performance rules would not catch it.
|
||||
- The failure mode is concrete enough to produce a file-line review finding.
|
||||
- The rule is likely to remain valid across normal feature work.
|
||||
|
||||
## Workflow Nodes And RAG Pipe
|
||||
|
||||
Flag:
|
||||
|
||||
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` importing workflow store hooks that are unavailable in RAG Pipe template rendering.
|
||||
- Node UI relying on provider context that is not mounted in every rendering surface.
|
||||
- Store reads in render where React Flow `useNodes` / `useEdges` provide the actual node/edge source.
|
||||
|
||||
Known failure mode: workflow node components can also render while creating a RAG Pipe from a template. In that context there may be no workflowStore provider, causing a blank screen.
|
||||
|
||||
Prefer React Flow hooks for node/edge UI consumption. Use store APIs only where the provider is guaranteed and the code path is workflow-only.
|
||||
@ -1,134 +0,0 @@
|
||||
# Dify UI Rules
|
||||
|
||||
Use these rules whenever a review touches `packages/dify-ui/` or code consuming `@langgenius/dify-ui/*`.
|
||||
|
||||
Before finalizing findings for those files, read the current local docs that apply:
|
||||
|
||||
- `packages/dify-ui/README.md`
|
||||
- `packages/dify-ui/AGENTS.md`
|
||||
- `web/docs/overlay.md` for floating UI
|
||||
- `packages/dify-ui/src/<primitive>/index.tsx` for the primitive being changed or consumed
|
||||
|
||||
## Package Boundary
|
||||
|
||||
Flag in `packages/dify-ui`:
|
||||
|
||||
- Imports from `web/`.
|
||||
- Dependencies on Next.js, i18n, ky, Jotai, Zustand, TanStack Query, oRPC, or business APIs.
|
||||
- Business-specific component behavior that belongs in `web/`.
|
||||
- Multiple unrelated primitives in one component folder.
|
||||
|
||||
`packages/dify-ui` is a primitive layer: Base UI headless components + `cva` + `cn` + Dify design tokens.
|
||||
|
||||
## Imports And Exports
|
||||
|
||||
Flag:
|
||||
|
||||
- Consumer imports from `@langgenius/dify-ui` without a subpath.
|
||||
- Missing `package.json#exports` entry for a new primitive.
|
||||
- Internal package imports using workspace subpaths instead of relative paths.
|
||||
- Exported props using internal-only types that consumers cannot import from the component subpath.
|
||||
|
||||
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
|
||||
|
||||
## Props And State
|
||||
|
||||
Flag:
|
||||
|
||||
- Flattened props where related values need a discriminated union, such as `value` / `defaultValue`, `multiple` / `value`, or `clearable` / `onChange`.
|
||||
- React state used only to mirror Base UI state for class names.
|
||||
- JavaScript conditional class logic for visual states that the Dify UI/Base UI primitive already exposes through `data-*` attributes or CSS variables.
|
||||
- Controlled props added when uncontrolled DOM state or CSS variables would be enough.
|
||||
- Thin wrappers that rename Base UI parts without adding semantics.
|
||||
|
||||
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
|
||||
|
||||
## Forms
|
||||
|
||||
Flag:
|
||||
|
||||
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
|
||||
- Text-like fields not composed through `FieldRoot`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
|
||||
- Select fields using `FieldLabel` instead of `SelectLabel`.
|
||||
- Slider fields using a generic label instead of `SliderLabel`.
|
||||
- Checkbox/radio groups missing `FieldsetRoot` and `FieldsetLegend`.
|
||||
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
|
||||
|
||||
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
|
||||
|
||||
## Overlay Contract
|
||||
|
||||
Flag:
|
||||
|
||||
- Legacy web overlay imports in new or modified code.
|
||||
- Manual portals around Dify UI overlay primitives.
|
||||
- Call-site `z-*` overrides on overlays.
|
||||
- Missing root `isolation: isolate` assumptions when debugging overlay stacking.
|
||||
- Repeated backdrop, z-index, or portal chrome at call sites.
|
||||
- Tooltip used for infotips, long text, or interactive content.
|
||||
|
||||
All Dify UI body-portalled overlays use `z-50`. Toast uses `z-60`. DOM order handles stacking between overlays.
|
||||
|
||||
## Primitive Selection
|
||||
|
||||
Flag:
|
||||
|
||||
- `Tabs` used for simple mode/filter/view selection where `SegmentedControl` is the semantic primitive.
|
||||
- `SegmentedControl` used where `tablist` / `tabpanel` semantics are required.
|
||||
- `Select` used for searchable or free-form input.
|
||||
- `Combobox` used for unrestricted search text where no selected option is remembered.
|
||||
- `Autocomplete` used for closed-list selection.
|
||||
- Tooltip or PreviewCard used for content that must be reachable on touch or by screen readers.
|
||||
|
||||
Use:
|
||||
|
||||
- `Autocomplete` for free-form text with optional suggestions.
|
||||
- `Combobox` for searchable selected values from a collection.
|
||||
- `Select` for closed, scannable option sets.
|
||||
- `Popover` for infotips, help text, rich content, or interactions.
|
||||
|
||||
## Bad Usage Patterns To Flag
|
||||
|
||||
Flag:
|
||||
|
||||
- Manually recreating UI behavior or chrome already owned by `@langgenius/dify-ui/*` or `web/app/components/base/*`, such as buttons, inputs, toggle groups, popovers, dropdown menus, alert dialogs, switches, avatars, scroll areas, toasts, borders, focus states, disabled states, segmented controls, or existing feature components.
|
||||
- Styling a raw Base UI primitive directly in `web/` when a Dify UI primitive exists.
|
||||
- Wrapping a Dify UI primitive in a feature component that hides its label, error, disabled, or focus contract.
|
||||
- Replacing a semantic primitive with a generic `div` plus classes to match a screenshot.
|
||||
- Using `Tooltip` because it is visually convenient when the content is actually help text or needs touch access.
|
||||
- Adding a `z-*` override to make a child popup appear over a parent dialog.
|
||||
- Adding a new app-level wrapper around Dialog, Drawer, Popover, Select, or Combobox that repeats portal/backdrop/positioner logic.
|
||||
- Using dify-ui `Input` as a drop-in replacement for legacy inputs that include search, clear, copy, unit, localized placeholder, or number normalization behavior.
|
||||
- Building a form row from loose text and controls instead of the matching Field/Form primitives.
|
||||
- Adding component state only to style `data-open`, `data-checked`, `data-disabled`, or highlighted states that Base UI already exposes.
|
||||
- Passing booleans down only so children can toggle classes already expressible with primitive `data-*` selectors.
|
||||
|
||||
## Tokens, Radius, And Styling
|
||||
|
||||
Flag:
|
||||
|
||||
- `radius-*` class names.
|
||||
- Custom Tailwind `borderRadius` extension for Figma radius values.
|
||||
- Generic colors where semantic Dify tokens exist.
|
||||
- Hardcoded design values where Dify tokens, component variants, or documented Figma radius mappings exist.
|
||||
- `!` important modifiers used to fight primitive styles instead of fixing the variant, selector, or component composition.
|
||||
- Manual class strings that duplicate primitive variants.
|
||||
- `min-w-(--anchor-width)` on picker popups when it defeats viewport clamping.
|
||||
|
||||
Use the Figma radius mapping from `packages/dify-ui/AGENTS.md`; for example `--radius/sm` maps to `rounded-md`, and `--radius/md` maps to `rounded-lg`.
|
||||
|
||||
Use `!` only for a tightly scoped compatibility override after confirming the primitive API, data attributes, and selector structure cannot express the state.
|
||||
|
||||
## Focus Details
|
||||
|
||||
Flag focus rings attached to the wrong element. For example, Base UI `Slider.Thumb` focuses an internal `input[type=range]`, so the visible thumb wrapper needs `has-[:focus-visible]` rather than direct wrapper `focus-visible`.
|
||||
|
||||
## Custom SVG Icons
|
||||
|
||||
Flag:
|
||||
|
||||
- New generated React icon components or JSON files under `web/app/components/base/icons/src/...` for custom SVG icons.
|
||||
- Custom SVG icons consumed outside the Tailwind `i-custom-*` icon class pipeline.
|
||||
- Generated `packages/iconify-collections/custom-*/icons.json` diffs where unrelated existing icons lost or changed intrinsic `width` or `height`.
|
||||
|
||||
New custom SVG icons belong in `packages/iconify-collections/assets/...`. Regenerate with `pnpm --filter @dify/iconify-collections generate`, validate with `pnpm --filter @dify/iconify-collections check:dimensions`, and consume the generated icon with Tailwind `i-custom-*` classes.
|
||||
@ -1,78 +1,45 @@
|
||||
# Performance Rules
|
||||
# Rule Catalog — Performance
|
||||
|
||||
Review performance only where there is realistic impact. Do not request `memo`, `useMemo`, `useCallback`, virtualization, or caching as style preferences.
|
||||
## React Flow data usage
|
||||
|
||||
## Async Waterfalls
|
||||
IsUrgent: True
|
||||
Category: Performance
|
||||
|
||||
Flag:
|
||||
### Description
|
||||
|
||||
- Awaiting remote feature flags or fetches before checking cheap synchronous conditions.
|
||||
- Sequential awaits for independent operations.
|
||||
- API routes or server components starting requests late when they could start early.
|
||||
- Nested per-item fetches running serially when each item can fetch in parallel.
|
||||
- Suspense boundaries that force the whole page to wait when a lower boundary could stream or isolate loading.
|
||||
When rendering React Flow, prefer `useNodes`/`useEdges` for UI consumption and rely on `useStoreApi` inside callbacks that mutate or read node/edge state. Avoid manually pulling Flow data outside of these hooks.
|
||||
|
||||
Prefer `Promise.all` for independent work and branch-local awaits for conditionally needed data.
|
||||
## Complex prop stability
|
||||
|
||||
## Bundle Size
|
||||
IsUrgent: False
|
||||
Category: Performance
|
||||
|
||||
Flag:
|
||||
### Description
|
||||
|
||||
- Barrel imports from heavy libraries or `@langgenius/dify-ui`.
|
||||
- Dynamic paths that prevent static trace analysis.
|
||||
- Heavy components loaded eagerly when hidden behind a dialog, tab, command, or feature activation.
|
||||
- Analytics, logging, editor, visualization, or third-party SDK code loaded before it is needed.
|
||||
- Feature-local optional modules imported at top level only for rare flows.
|
||||
Only require stable object, array, or map props when there is a clear reason: the child is memoized, the value participates in effect/query dependencies, the value is part of a stable-reference API contract, or profiling/local behavior shows avoidable re-renders. Do not request `useMemo` for every inline object by default; `how-to-write-component` treats memoization as a targeted optimization.
|
||||
|
||||
Use direct imports and `next/dynamic` where the user-visible path benefits.
|
||||
Update this file when adding, editing, or removing Performance rules so the catalog remains accurate.
|
||||
|
||||
## Server Rendering
|
||||
Risky:
|
||||
|
||||
Flag:
|
||||
```tsx
|
||||
<HeavyComp
|
||||
config={{
|
||||
provider: ...,
|
||||
detail: ...
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
- Request-specific mutable state stored at module scope in SSR/RSC paths.
|
||||
- Large duplicate data serialized across RSC/client boundaries.
|
||||
- Static I/O repeated per request when it could be hoisted safely.
|
||||
- Cross-request cache without a bounded invalidation strategy.
|
||||
- Server actions lacking API-route-equivalent auth checks.
|
||||
Better when stable identity matters:
|
||||
|
||||
Use request-scoped deduplication such as `React.cache()` when repeated server reads in one request are the problem.
|
||||
```tsx
|
||||
const config = useMemo(() => ({
|
||||
provider: ...,
|
||||
detail: ...
|
||||
}), [provider, detail]);
|
||||
|
||||
## Re-rendering
|
||||
|
||||
Flag:
|
||||
|
||||
- Effects or subscriptions reading broad state when a derived boolean or narrower selector is enough.
|
||||
- Components defined inside components.
|
||||
- Derived rendering state stored in state/effects.
|
||||
- Non-primitive default props recreated for memoized children.
|
||||
- Expensive work recalculated on every render where it affects real interaction cost.
|
||||
- High-frequency transient values stored in state when refs or CSS variables would avoid render loops.
|
||||
|
||||
Do not flag simple primitive expressions wrapped or not wrapped in `useMemo`; prefer no memo for simple work.
|
||||
|
||||
Require stable object/array/function identity only when:
|
||||
|
||||
- The child is memoized and identity affects renders.
|
||||
- The value is an effect/query dependency.
|
||||
- A library API requires stable references.
|
||||
- Profiling or local behavior shows avoidable re-rendering.
|
||||
|
||||
## DOM, Lists, And Rendering
|
||||
|
||||
Flag:
|
||||
|
||||
- Layout reads in render (`getBoundingClientRect`, `offset*`, `scrollTop`).
|
||||
- Interleaved DOM reads/writes that can cause layout thrashing.
|
||||
- Large lists rendering without virtualization, pagination, or `content-visibility`.
|
||||
- SVG/animation code animating expensive properties when transform/opacity would work.
|
||||
- `transition-all`.
|
||||
- Long-running non-critical browser work performed immediately instead of idle/deferred scheduling.
|
||||
|
||||
## React Flow
|
||||
|
||||
For workflow React Flow components, keep this Dify-specific rule:
|
||||
|
||||
- UI consumption should use React Flow hooks such as `useNodes` / `useEdges`.
|
||||
- Callback-only reads or mutations can use `useStoreApi`.
|
||||
- Node components under `web/app/components/workflow/nodes/[nodeName]/node.tsx` must not depend on workflow stores that are absent in RAG Pipe template rendering.
|
||||
<HeavyComp
|
||||
config={config}
|
||||
/>
|
||||
```
|
||||
|
||||
@ -1,72 +0,0 @@
|
||||
# Testing Review Rules
|
||||
|
||||
Use these rules when reviewing test files, testability of changed code, or risky frontend changes that should have tests.
|
||||
|
||||
## Missing Coverage
|
||||
|
||||
Flag missing tests when the change affects:
|
||||
|
||||
- User-visible behavior, navigation, form submission, validation, permissions, or loading/error/empty states.
|
||||
- Query/mutation cache behavior.
|
||||
- Accessibility-critical behavior such as labels, keyboard flow, focus, disabled state, or popup reachability.
|
||||
- URL state parsing/serialization.
|
||||
- Storage persistence or one-shot signals.
|
||||
- Regression-prone workflow or generated contract migration paths.
|
||||
|
||||
Do not request tests for purely mechanical renames or styling-only changes unless the styling affects layout, focus, or interaction.
|
||||
|
||||
## Selectors
|
||||
|
||||
Flag:
|
||||
|
||||
- `getByTestId` used where role, label, text, placeholder, landmark, or scoped dialog/menu queries are available.
|
||||
- Production `data-testid` added only to satisfy tests.
|
||||
- Assertions against decorative icons rather than the named control.
|
||||
- Tests that cannot find controls semantically but leave broken markup unchanged.
|
||||
|
||||
Prefer `getByRole` with accessible name, then `getByLabelText`, `getByPlaceholderText`, `getByText`, and `within(...)`.
|
||||
|
||||
## Mocking
|
||||
|
||||
Flag:
|
||||
|
||||
- Mocking `@langgenius/dify-ui/*` primitives.
|
||||
- Mocking `@/app/components/base/*` components when the real component is practical.
|
||||
- Mocking sibling or child components in the same directory for integration behavior.
|
||||
- Mocks that do not match the real component's conditional rendering.
|
||||
- Module-level mock state not reset in `beforeEach`.
|
||||
- `vi.clearAllMocks()` in `afterEach` instead of `beforeEach`.
|
||||
|
||||
Use real project components for integration behavior. Mock APIs, `next/navigation`, browser shims, or complex providers only when setup would dominate the test.
|
||||
|
||||
## Behavior
|
||||
|
||||
Flag:
|
||||
|
||||
- Tests inspecting implementation details instead of user-observable behavior.
|
||||
- Assertions that hardcode brittle copy when pattern matching or semantic roles would express behavior better.
|
||||
- Fake timers used without real timing behavior.
|
||||
- Async assertions missing `await`, `findBy*`, or `waitFor`.
|
||||
- Test data missing required fields because inline partial objects bypass real types.
|
||||
|
||||
Use typed factory functions with complete defaults and partial overrides.
|
||||
|
||||
## URL State
|
||||
|
||||
For `nuqs` or query-state hooks, flag tests that:
|
||||
|
||||
- Mock URL state when URL synchronization is the behavior under review.
|
||||
- Do not test parser serialize/parse round trips for custom parsers.
|
||||
- Do not assert default-clearing behavior when defaults should be removed from the URL.
|
||||
|
||||
Prefer shared `NuqsTestingAdapter` helpers when available.
|
||||
|
||||
## Organization
|
||||
|
||||
Flag:
|
||||
|
||||
- Component/hook/util tests outside sibling `__tests__/` directories.
|
||||
- Directory-level reviews that test only `index.tsx` while other files in scope contain behavior.
|
||||
- Large test files with repeated setup that should use local builders.
|
||||
|
||||
When a component is very complex, prefer a refactor finding before asking for exhaustive tests.
|
||||
@ -1,6 +1,6 @@
|
||||
---
|
||||
name: how-to-write-component
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
|
||||
---
|
||||
|
||||
# How To Write A Component
|
||||
@ -12,79 +12,26 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
|
||||
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
|
||||
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
|
||||
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
|
||||
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
|
||||
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
|
||||
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
|
||||
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
|
||||
|
||||
## Feature Workflow Layout
|
||||
|
||||
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
|
||||
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
|
||||
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
|
||||
- Folders represent groups of related files with a shared owner and a stable reason to change together.
|
||||
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
|
||||
|
||||
## Ownership
|
||||
|
||||
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
|
||||
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
|
||||
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
|
||||
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
|
||||
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
|
||||
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
|
||||
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
|
||||
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
|
||||
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
|
||||
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
|
||||
|
||||
## Feature-Scoped Jotai State
|
||||
|
||||
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
|
||||
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
|
||||
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
|
||||
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
|
||||
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
|
||||
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
|
||||
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
|
||||
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
|
||||
|
||||
## Components, Props, And Types
|
||||
|
||||
- Type component signatures directly; do not use `FC` or `React.FC`.
|
||||
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
|
||||
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
|
||||
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
|
||||
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
|
||||
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
|
||||
|
||||
## Generated API Contracts
|
||||
|
||||
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
|
||||
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
|
||||
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
|
||||
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
|
||||
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
|
||||
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
|
||||
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
|
||||
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
|
||||
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
|
||||
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
|
||||
|
||||
## Nullable API Data
|
||||
|
||||
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
|
||||
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
|
||||
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
|
||||
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
|
||||
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
|
||||
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
|
||||
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
|
||||
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
|
||||
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers beside the component that needs them.
|
||||
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially IDs like `appInstanceId`. Normalize framework or route params at the boundary.
|
||||
- Keep fallback and invariant checks at the lowest component that already handles that state; callers should pass raw values through instead of duplicating checks.
|
||||
|
||||
## Queries And Mutations
|
||||
|
||||
@ -92,8 +39,7 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
|
||||
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
|
||||
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
|
||||
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
|
||||
- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly.
|
||||
- For missing required query input, use `input: skipToken`; use `enabled` only for extra business gating after the input is valid.
|
||||
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
|
||||
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
|
||||
- Do not use deprecated `useInvalid` or `useReset`.
|
||||
@ -102,13 +48,12 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
## Component Boundaries
|
||||
|
||||
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
|
||||
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
|
||||
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
|
||||
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
|
||||
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
|
||||
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
|
||||
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
|
||||
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface.
|
||||
- Avoid shallow wrappers and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary.
|
||||
|
||||
## You Might Not Need An Effect
|
||||
|
||||
@ -123,6 +68,4 @@ Use this as the decision guide for React/TypeScript component structure. Existin
|
||||
## Navigation And Performance
|
||||
|
||||
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
|
||||
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
|
||||
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
|
||||
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
---
|
||||
name: karpathy-guidelines
|
||||
description: Lightweight coding guardrails for making focused, simple, and verifiable changes in this repo. Use for all coding work.
|
||||
---
|
||||
|
||||
# Karpathy Guidelines
|
||||
|
||||
Use this skill whenever you touch code in this repository.
|
||||
|
||||
## Principles
|
||||
|
||||
- Keep the change small and directly tied to the user request.
|
||||
- Prefer the simplest implementation that fits the existing codebase.
|
||||
- Read the nearby code first, then match its patterns.
|
||||
- Avoid unrelated refactors, broad rewrites, or style churn.
|
||||
- Preserve existing behavior unless the user explicitly asked to change it.
|
||||
- Treat regressions as a signal to narrow the change, not to add workaround layers.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Inspect the current implementation and tests around the change.
|
||||
2. Make the smallest coherent edit.
|
||||
3. Add or update focused tests when the behavior changes or the risk is non-trivial.
|
||||
4. Run the narrowest relevant verification first.
|
||||
5. Report exactly what was verified and anything left unverified.
|
||||
|
||||
## Review Checklist
|
||||
|
||||
- Does this change solve the stated problem without expanding scope?
|
||||
- Did it preserve existing route/component/data-flow semantics?
|
||||
- Are new abstractions justified by real complexity?
|
||||
- Are tests focused on the behavior that could regress?
|
||||
- Are unrelated files and generated artifacts left alone?
|
||||
@ -1 +0,0 @@
|
||||
../../.agents/skills/karpathy-guidelines
|
||||
17
.github/CODEOWNERS
vendored
17
.github/CODEOWNERS
vendored
@ -15,16 +15,12 @@
|
||||
# Agents
|
||||
/.agents/skills/ @hyoban
|
||||
|
||||
# Packages
|
||||
/packages/ @lyzno1
|
||||
/packages/contracts/ @crazywoola @laipz8200
|
||||
|
||||
# Docs
|
||||
/docs/ @crazywoola
|
||||
|
||||
# CLI
|
||||
/cli/ @GareArc
|
||||
/.github/workflows/cli-tests.yml @GareArc
|
||||
/cli/ @langgenius/maintainers
|
||||
/.github/workflows/cli-tests.yml @langgenius/maintainers
|
||||
|
||||
# Backend (default owner, more specific rules below will override)
|
||||
/api/ @QuantumGhost
|
||||
@ -147,14 +143,6 @@
|
||||
# Frontend
|
||||
/web/ @iamjoel
|
||||
|
||||
# Frontend - Platform and Features
|
||||
/web/config/ @lyzno1
|
||||
/web/contract/ @lyzno1
|
||||
/web/env.ts @lyzno1
|
||||
/web/features/ @lyzno1
|
||||
/web/hooks/ @lyzno1
|
||||
/web/scripts/gen-icons.mjs @lyzno1
|
||||
|
||||
# Frontend - Web Tests
|
||||
/.github/workflows/web-tests.yml @iamjoel
|
||||
|
||||
@ -265,6 +253,7 @@
|
||||
/web/utils/time.ts @iamjoel @zxhlyh
|
||||
/web/utils/format.ts @iamjoel @zxhlyh
|
||||
/web/utils/clipboard.ts @iamjoel @zxhlyh
|
||||
/web/hooks/use-document-title.ts @iamjoel @zxhlyh
|
||||
|
||||
# Frontend - Billing and Education
|
||||
/web/app/components/billing/ @iamjoel @zxhlyh
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -91,13 +91,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
@ -142,13 +142,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -195,7 +195,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
files: ./coverage.xml
|
||||
disable_search: true
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
@ -66,7 +66,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
|
||||
- name: Generate Docker Compose
|
||||
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
|
||||
|
||||
35
.github/workflows/build-push.yml
vendored
35
.github/workflows/build-push.yml
vendored
@ -21,7 +21,6 @@ env:
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
|
||||
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
|
||||
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@ -61,20 +60,6 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-amd64"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
artifact_context: "agent"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
platform: linux/amd64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
- service_name: "build-agent-arm64"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
artifact_context: "agent"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
platform: linux/arm64
|
||||
runs_on: depot-ubuntu-24.04-4
|
||||
|
||||
steps:
|
||||
- name: Prepare
|
||||
@ -83,7 +68,7 @@ jobs:
|
||||
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
@ -93,13 +78,13 @@ jobs:
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
|
||||
- name: Build Docker image
|
||||
id: build
|
||||
uses: depot/build-push-action@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -137,15 +122,12 @@ jobs:
|
||||
- service_name: "validate-web-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "web/Dockerfile"
|
||||
- service_name: "validate-agent-amd64"
|
||||
build_context: "{{defaultContext}}"
|
||||
file: "dify-agent/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Validate Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.build_context }}
|
||||
@ -165,9 +147,6 @@ jobs:
|
||||
- service_name: "merge-web-images"
|
||||
image_name_env: "DIFY_WEB_IMAGE_NAME"
|
||||
context: "web"
|
||||
- service_name: "merge-agent-images"
|
||||
image_name_env: "DIFY_AGENT_IMAGE_NAME"
|
||||
context: "agent"
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
@ -177,14 +156,14 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ env.DOCKERHUB_USER }}
|
||||
password: ${{ env.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
||||
with:
|
||||
images: ${{ env[matrix.image_name_env] }}
|
||||
tags: |
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -131,7 +131,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -178,7 +178,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -233,7 +233,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -241,7 +241,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -274,7 +274,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -303,7 +303,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -351,7 +351,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -359,7 +359,7 @@ jobs:
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
@ -408,7 +408,7 @@ jobs:
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
|
||||
74
.github/workflows/cli-edge.yml
vendored
74
.github/workflows/cli-edge.yml
vendored
@ -1,74 +0,0 @@
|
||||
name: CLI Edge Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'cli/**'
|
||||
- 'packages/contracts/generated/api/openapi/**'
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: difyctl-edge-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: build + publish edge to R2
|
||||
runs-on: ${{ github.repository == 'langgenius/dify' && 'depot-ubuntu-24.04' || 'ubuntu-24.04' }}
|
||||
if: vars.DIFYCTL_R2_BUCKET != ''
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Enable cross-arch native prebuilds
|
||||
working-directory: ./
|
||||
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
with:
|
||||
bun-version-file: cli/.bun-version
|
||||
|
||||
- name: Compute edge version
|
||||
id: ver
|
||||
run: echo "version=$(node scripts/release-naming.mjs edge-version "$(git rev-parse --short HEAD)")" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Compile standalone binaries (all targets, all-or-nothing)
|
||||
run: |
|
||||
CLI_VERSION="${{ steps.ver.outputs.version }}" \
|
||||
DIFYCTL_CHANNEL=edge \
|
||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
pnpm build:bin
|
||||
|
||||
- name: Generate sha256 checksums
|
||||
run: CLI_VERSION="${{ steps.ver.outputs.version }}" scripts/release-write-checksums.sh
|
||||
|
||||
- name: Smoke the runner-arch binary
|
||||
run: ./dist/bin/difyctl-v${{ steps.ver.outputs.version }}-linux-x64 version
|
||||
|
||||
- name: Publish to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.DIFYCTL_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.DIFYCTL_R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
AWS_REQUEST_CHECKSUM_CALCULATION: WHEN_REQUIRED
|
||||
AWS_RESPONSE_CHECKSUM_VALIDATION: WHEN_REQUIRED
|
||||
DIFYCTL_R2_S3_ENDPOINT: ${{ vars.DIFYCTL_R2_S3_ENDPOINT }}
|
||||
DIFYCTL_R2_BUCKET: ${{ vars.DIFYCTL_R2_BUCKET }}
|
||||
DIFYCTL_R2_PUBLIC_BASE: ${{ vars.DIFYCTL_R2_PUBLIC_BASE }}
|
||||
DIFYCTL_COMMIT: ${{ github.sha }}
|
||||
run: |
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
scripts/release-r2-publish.sh edge "${{ steps.ver.outputs.version }}"
|
||||
180
.github/workflows/cli-release.yml
vendored
180
.github/workflows/cli-release.yml
vendored
@ -2,165 +2,87 @@ name: CLI Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Dify release tag to attach difyctl assets to (blank = latest stable)
|
||||
required: false
|
||||
type: string
|
||||
release:
|
||||
types: [released]
|
||||
push:
|
||||
tags:
|
||||
- 'difyctl-v*'
|
||||
|
||||
concurrency:
|
||||
group: difyctl-release
|
||||
cancel-in-progress: false
|
||||
group: cli-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: validate manifest + resolve target Dify release
|
||||
release:
|
||||
name: build standalone binaries (all targets)
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: github.repository == 'langgenius/dify'
|
||||
permissions:
|
||||
contents: read
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./cli
|
||||
outputs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: Resolve target Dify release
|
||||
id: resolve
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
EVENT_TAG: ${{ github.event.release.tag_name }}
|
||||
INPUT_TAG: ${{ inputs.release_tag }}
|
||||
run: |
|
||||
if [ -n "$EVENT_TAG" ]; then
|
||||
tag="$EVENT_TAG"
|
||||
elif [ -n "$INPUT_TAG" ]; then
|
||||
tag="$INPUT_TAG"
|
||||
else
|
||||
tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq .tag_name)"
|
||||
fi
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::could not resolve a target Dify release tag"
|
||||
exit 1
|
||||
fi
|
||||
if ! gh release view "$tag" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "::error::target Dify release ${tag} not found"
|
||||
exit 1
|
||||
fi
|
||||
echo "dify_tag=${tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::target Dify release ${tag}"
|
||||
|
||||
- name: Compatibility check
|
||||
env:
|
||||
DIFY_TAG: ${{ steps.resolve.outputs.dify_tag }}
|
||||
run: node scripts/release-naming.mjs compat-check "$DIFY_TAG"
|
||||
|
||||
- name: Reject duplicate difyctl version
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
if gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/${difyctlTag}" >/dev/null 2>&1; then
|
||||
echo "::error::difyctl ${version} already released (tag ${difyctlTag} exists); bump cli/package.json version"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
release:
|
||||
name: build + attach standalone binaries (all targets)
|
||||
needs: validate
|
||||
runs-on: depot-ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./cli
|
||||
env:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Enable cross-arch native prebuilds
|
||||
working-directory: ./
|
||||
run: cat cli/scripts/cross-arch.pnpm.yaml >> pnpm-workspace.yaml
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Export manifest to env
|
||||
run: node scripts/release-naming.mjs github-env >> "$GITHUB_ENV"
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.2
|
||||
with:
|
||||
bun-version-file: cli/.bun-version
|
||||
bun-version: latest
|
||||
|
||||
- name: Read cli/package.json
|
||||
id: manifest
|
||||
run: |
|
||||
version=$(node -p "require('./package.json').version")
|
||||
channel=$(node -p "require('./package.json').difyctl.channel")
|
||||
minDify=$(node -p "require('./package.json').difyctl.compat.minDify")
|
||||
maxDify=$(node -p "require('./package.json').difyctl.compat.maxDify")
|
||||
{
|
||||
echo "version=$version"
|
||||
echo "channel=$channel"
|
||||
echo "minDify=$minDify"
|
||||
echo "maxDify=$maxDify"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Validate manifest
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: Install cross-arch native prebuilds
|
||||
# Re-installs node_modules with every @napi-rs/keyring platform variant
|
||||
# so `bun build --compile` can embed the right .node into each target.
|
||||
working-directory: ./
|
||||
run: NPM_CONFIG_USERCONFIG="$PWD/cli/scripts/cross-arch.npmrc" pnpm install --frozen-lockfile
|
||||
|
||||
- name: Compile standalone binaries (all targets)
|
||||
env:
|
||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||
DIFYCTL_CHANNEL: ${{ steps.manifest.outputs.channel }}
|
||||
DIFYCTL_MIN_DIFY: ${{ steps.manifest.outputs.minDify }}
|
||||
DIFYCTL_MAX_DIFY: ${{ steps.manifest.outputs.maxDify }}
|
||||
run: |
|
||||
DIFYCTL_COMMIT="$(git rev-parse HEAD)" \
|
||||
DIFYCTL_BUILD_DATE="$(git log -1 --format=%cI HEAD)" \
|
||||
pnpm build:bin
|
||||
|
||||
- name: Generate sha256 checksum file
|
||||
env:
|
||||
CLI_VERSION: ${{ steps.manifest.outputs.version }}
|
||||
run: scripts/release-write-checksums.sh
|
||||
|
||||
- name: Attach difyctl assets to Dify release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
gh release upload "$DIFY_TAG" dist/bin/${tagPrefix}* \
|
||||
--repo "$GITHUB_REPOSITORY" --clobber
|
||||
|
||||
- name: Prune stale difyctl assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
new_set="$(cd dist/bin && ls ${tagPrefix}*)"
|
||||
gh release view "$DIFY_TAG" --repo "$GITHUB_REPOSITORY" \
|
||||
--json assets --jq '.assets[].name' \
|
||||
| { grep -E "^${tagPrefix}" || true; } \
|
||||
| while IFS= read -r name; do
|
||||
if ! printf '%s\n' "$new_set" | grep -qxF -- "$name"; then
|
||||
echo "::notice::pruning stale asset ${name}"
|
||||
gh release delete-asset "$DIFY_TAG" "$name" \
|
||||
--repo "$GITHUB_REPOSITORY" --yes
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Create provenance tag
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
ref="refs/tags/${difyctlTag}"
|
||||
sha="$(git rev-parse HEAD)"
|
||||
status="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/git/refs" \
|
||||
-f ref="$ref" -f sha="$sha" --silent --include 2>/dev/null \
|
||||
| awk 'NR==1 {print $2; exit}' || true)"
|
||||
case "$status" in
|
||||
201) echo "::notice::created ${ref}" ;;
|
||||
422) echo "::notice::tag ${ref} already exists; skipping (immutable)" ;;
|
||||
*) echo "::error::provenance tag ${ref} not created (HTTP ${status:-unknown})"; exit 1 ;;
|
||||
esac
|
||||
- name: Publish GitHub Release
|
||||
uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2
|
||||
with:
|
||||
tag_name: difyctl-v${{ steps.manifest.outputs.version }}
|
||||
name: difyctl ${{ steps.manifest.outputs.version }}
|
||||
prerelease: ${{ steps.manifest.outputs.channel != 'stable' }}
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
files: |
|
||||
cli/dist/bin/difyctl-v*
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
15
.github/workflows/cli-tests.yml
vendored
15
.github/workflows/cli-tests.yml
vendored
@ -30,28 +30,19 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Validate release manifest
|
||||
if: matrix.os == 'depot-ubuntu-24.04'
|
||||
run: scripts/release-validate-manifest.sh
|
||||
|
||||
- name: CI pipeline (typecheck, lint, coverage, build)
|
||||
run: pnpm run ci
|
||||
run: pnpm ci
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' && matrix.os == 'depot-ubuntu-24.04' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
||||
with:
|
||||
directory: cli/coverage
|
||||
flags: cli
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -40,7 +40,7 @@ jobs:
|
||||
cp envs/middleware.env.example middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
@ -63,13 +63,13 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
@ -94,7 +94,7 @@ jobs:
|
||||
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
|
||||
|
||||
- name: Set up Middlewares
|
||||
uses: hoverkraft-tech/compose-action@11beaa1c2dae4e8ed7b1665aa074723b6cecb0e4 # v3.0.0
|
||||
uses: hoverkraft-tech/compose-action@d2bee4f07e8ca410d6b196d00f90c12e7d48c33a # v2.6.0
|
||||
with:
|
||||
compose-file: |
|
||||
docker/docker-compose.middleware.yaml
|
||||
|
||||
28
.github/workflows/deploy-agent.yml
vendored
28
.github/workflows/deploy-agent.yml
vendored
@ -1,28 +0,0 @@
|
||||
name: Deploy Agent
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build and Push API & Web"]
|
||||
branches:
|
||||
- "deploy/agent"
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: depot-ubuntu-24.04
|
||||
if: |
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_branch == 'deploy/agent'
|
||||
steps:
|
||||
- name: Deploy to server
|
||||
uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5
|
||||
with:
|
||||
host: ${{ secrets.AGENT_SSH_HOST }}
|
||||
username: ${{ secrets.SSH_USER }}
|
||||
key: ${{ secrets.SSH_PRIVATE_KEY }}
|
||||
script: |
|
||||
${{ vars.SSH_SCRIPT_AGENT || secrets.SSH_SCRIPT_AGENT }}
|
||||
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@98e78adca7817480b8185f474a400b451d74e287 # v1.18.0
|
||||
uses: depot/build-push-action@5f3b3c2e5a00f0093de47f657aeaefcedff27d18 # v1.17.0
|
||||
with:
|
||||
project: ${{ vars.DEPOT_PROJECT_ID }}
|
||||
push: false
|
||||
@ -77,10 +77,10 @@ jobs:
|
||||
file: "web/Dockerfile"
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
||||
|
||||
- name: Build Docker Image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
push: false
|
||||
context: ${{ matrix.context }}
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- 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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
@ -21,10 +21,10 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||
steps:
|
||||
- name: Checkout default branch (trusted code)
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@ -18,7 +18,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
days-before-issue-stale: 15
|
||||
days-before-issue-close: 3
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
|
||||
- name: Setup UV and Python
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: false
|
||||
python-version: "3.12"
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
|
||||
uses: anthropics/claude-code-action@1dc994ee7a008f0ecc866d9ac23ef036b7229f84 # v1.0.127
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -36,7 +36,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -33,7 +33,7 @@ jobs:
|
||||
remove_tool_cache: true
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -28,7 +28,7 @@ jobs:
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Setup UV and Python
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
enable-cache: true
|
||||
python-version: "3.12"
|
||||
|
||||
35
.github/workflows/web-tests.yml
vendored
35
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: web/coverage
|
||||
flags: web
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -113,36 +113,13 @@ jobs:
|
||||
run: vp exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run dify-ui tests
|
||||
run: vp test run --project unit --coverage --silent=passed-only
|
||||
run: vp test run --coverage --silent=passed-only
|
||||
|
||||
- name: Report coverage
|
||||
if: ${{ env.CODECOV_TOKEN != '' }}
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
directory: packages/dify-ui/coverage
|
||||
flags: dify-ui
|
||||
env:
|
||||
CODECOV_TOKEN: ${{ env.CODECOV_TOKEN }}
|
||||
|
||||
dify-ui-storybook-test:
|
||||
name: dify-ui Storybook Tests
|
||||
runs-on: depot-ubuntu-24.04-4
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
working-directory: ./packages/dify-ui
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- name: Install Chromium for Browser Mode
|
||||
run: vp exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run dify-ui Storybook tests
|
||||
run: vp run test:storybook
|
||||
|
||||
2
Makefile
2
Makefile
@ -157,7 +157,7 @@ build-web:
|
||||
|
||||
build-api:
|
||||
@echo "Building API Docker image: $(API_IMAGE):$(VERSION)..."
|
||||
docker build -t $(API_IMAGE):$(VERSION) -f api/Dockerfile .
|
||||
docker build -t $(API_IMAGE):$(VERSION) ./api
|
||||
@echo "API Docker image built successfully: $(API_IMAGE):$(VERSION)"
|
||||
|
||||
# Push Docker images
|
||||
|
||||
@ -774,11 +774,3 @@ EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
|
||||
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
|
||||
# Human input timeout check interval in minutes
|
||||
HUMAN_INPUT_TIMEOUT_TASK_INTERVAL=1
|
||||
|
||||
# Nacos remote settings source HTTP timeouts (seconds).
|
||||
# Bound how long requests to the Nacos endpoint wait before failing, so a slow or
|
||||
# unresponsive Nacos server cannot stall API startup or token refresh.
|
||||
# Read timeout for Nacos requests (default: 10.0)
|
||||
DIFY_ENV_NACOS_REQUEST_TIMEOUT=10.0
|
||||
# Connect timeout for Nacos requests (default: 3.0)
|
||||
DIFY_ENV_NACOS_CONNECT_TIMEOUT=3.0
|
||||
|
||||
@ -8,30 +8,18 @@
|
||||
!dify-agent/src/
|
||||
!dify-agent/src/**
|
||||
|
||||
# Environment configuration and example
|
||||
.env
|
||||
*.env.*
|
||||
|
||||
# Python related files
|
||||
api/.venv
|
||||
api/.venv/**
|
||||
api/.env
|
||||
api/*.env.*
|
||||
api/.idea
|
||||
api/.mypy_cache
|
||||
api/.ruff_cache
|
||||
api/storage/generate_files/*
|
||||
api/storage/privkeys/*
|
||||
api/storage/tools/*
|
||||
api/storage/upload_files/*
|
||||
api/logs
|
||||
api/*.log*
|
||||
**/__pycache__
|
||||
**/*.pyc
|
||||
**/.venv/
|
||||
**/.mypy_cache/
|
||||
**/.ruff_cache/
|
||||
**/.import_linter_cache/
|
||||
**/.pytest_cache/
|
||||
**/.hypothesis/
|
||||
|
||||
|
||||
# Upload files and logs
|
||||
api/storage/**
|
||||
api/logs/
|
||||
api/*.log*
|
||||
|
||||
# Tests
|
||||
api/tests
|
||||
|
||||
|
||||
# Editor configuration
|
||||
**/.vscode/
|
||||
**/.idea/
|
||||
|
||||
@ -55,7 +55,7 @@ else:
|
||||
|
||||
if __name__ == "__main__":
|
||||
from gevent import pywsgi
|
||||
from geventwebsocket.handler import WebSocketHandler
|
||||
from geventwebsocket.handler import WebSocketHandler # type: ignore[reportMissingTypeStubs]
|
||||
|
||||
log_startup_banner(HOST, PORT)
|
||||
server = pywsgi.WSGIServer((HOST, PORT), socketio_app, handler_class=WebSocketHandler)
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import time
|
||||
|
||||
import socketio
|
||||
import socketio # type: ignore[reportMissingTypeStubs]
|
||||
from flask import request
|
||||
from opentelemetry.trace import get_current_span
|
||||
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import psycogreen.gevent as pscycogreen_gevent
|
||||
from grpc.experimental import gevent as grpc_gevent
|
||||
import psycogreen.gevent as pscycogreen_gevent # type: ignore
|
||||
from grpc.experimental import gevent as grpc_gevent # type: ignore
|
||||
|
||||
# grpc gevent
|
||||
grpc_gevent.init_gevent()
|
||||
|
||||
@ -5,8 +5,6 @@ API adapters: request building from Dify product concepts, a thin client wrapper
|
||||
event adaptation for future workflow integration, and deterministic fakes.
|
||||
"""
|
||||
|
||||
from dify_agent.protocol import RuntimeLayerSpec, extract_runtime_layer_specs
|
||||
|
||||
from clients.agent_backend.client import AgentBackendRunClient, DifyAgentBackendRunClient
|
||||
from clients.agent_backend.errors import (
|
||||
AgentBackendError,
|
||||
@ -18,12 +16,12 @@ from clients.agent_backend.errors import (
|
||||
AgentBackendValidationError,
|
||||
)
|
||||
from clients.agent_backend.event_adapter import (
|
||||
AgentBackendDeferredToolCallInternalEvent,
|
||||
AgentBackendInternalEvent,
|
||||
AgentBackendInternalEventType,
|
||||
AgentBackendRunCancelledInternalEvent,
|
||||
AgentBackendRunEventAdapter,
|
||||
AgentBackendRunFailedInternalEvent,
|
||||
AgentBackendRunPausedInternalEvent,
|
||||
AgentBackendRunStartedInternalEvent,
|
||||
AgentBackendRunSucceededInternalEvent,
|
||||
AgentBackendStreamInternalEvent,
|
||||
@ -33,7 +31,6 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
|
||||
from clients.agent_backend.request_builder import (
|
||||
AGENT_SOUL_PROMPT_LAYER_ID,
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID,
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID,
|
||||
WORKFLOW_NODE_JOB_PROMPT_LAYER_ID,
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID,
|
||||
@ -42,18 +39,18 @@ from clients.agent_backend.request_builder import (
|
||||
AgentBackendOutputConfig,
|
||||
AgentBackendRunRequestBuilder,
|
||||
AgentBackendWorkflowNodeRunInput,
|
||||
CleanupLayerSpec,
|
||||
extract_cleanup_layer_specs,
|
||||
redact_for_agent_backend_log,
|
||||
)
|
||||
|
||||
__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",
|
||||
"AgentBackendAgentAppRunInput",
|
||||
"AgentBackendDeferredToolCallInternalEvent",
|
||||
"AgentBackendError",
|
||||
"AgentBackendHTTPError",
|
||||
"AgentBackendInternalEvent",
|
||||
@ -66,6 +63,7 @@ __all__ = [
|
||||
"AgentBackendRunEventAdapter",
|
||||
"AgentBackendRunFailedError",
|
||||
"AgentBackendRunFailedInternalEvent",
|
||||
"AgentBackendRunPausedInternalEvent",
|
||||
"AgentBackendRunRequestBuilder",
|
||||
"AgentBackendRunStartedInternalEvent",
|
||||
"AgentBackendRunSucceededInternalEvent",
|
||||
@ -74,11 +72,11 @@ __all__ = [
|
||||
"AgentBackendTransportError",
|
||||
"AgentBackendValidationError",
|
||||
"AgentBackendWorkflowNodeRunInput",
|
||||
"CleanupLayerSpec",
|
||||
"DifyAgentBackendRunClient",
|
||||
"FakeAgentBackendRunClient",
|
||||
"FakeAgentBackendScenario",
|
||||
"RuntimeLayerSpec",
|
||||
"create_agent_backend_run_client",
|
||||
"extract_runtime_layer_specs",
|
||||
"extract_cleanup_layer_specs",
|
||||
"redact_for_agent_backend_log",
|
||||
]
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
|
||||
The adapter does not define a new cross-service event contract. It consumes
|
||||
``dify_agent.protocol.RunEvent`` and produces small API-internal models that the
|
||||
workflow Agent Node maps to Graphon/AppQueue events. Deferred external tool calls
|
||||
remain Dify Agent ``run_succeeded`` payloads on the wire; API code turns them
|
||||
into an internal event so workflow pause/session handling stays local to API.
|
||||
future workflow Agent Node can map to Graphon/AppQueue events in phase 3.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -14,11 +12,11 @@ from typing import Annotated, Literal, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.protocol import (
|
||||
DeferredToolCallPayload,
|
||||
PydanticAIStreamRunEvent,
|
||||
RunCancelledEvent,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunPausedEvent,
|
||||
RunStartedEvent,
|
||||
RunSucceededEvent,
|
||||
)
|
||||
@ -32,7 +30,7 @@ class AgentBackendInternalEventType(StrEnum):
|
||||
|
||||
RUN_STARTED = "run_started"
|
||||
STREAM_EVENT = "stream_event"
|
||||
DEFERRED_TOOL_CALL = "deferred_tool_call"
|
||||
RUN_PAUSED = "run_paused"
|
||||
RUN_SUCCEEDED = "run_succeeded"
|
||||
RUN_FAILED = "run_failed"
|
||||
RUN_CANCELLED = "run_cancelled"
|
||||
@ -69,13 +67,13 @@ class AgentBackendRunSucceededInternalEvent(AgentBackendInternalEventBase):
|
||||
session_snapshot: CompositorSessionSnapshot
|
||||
|
||||
|
||||
class AgentBackendDeferredToolCallInternalEvent(AgentBackendInternalEventBase):
|
||||
"""API-internal representation of a Dify Agent deferred external tool call."""
|
||||
class AgentBackendRunPausedInternalEvent(AgentBackendInternalEventBase):
|
||||
"""API-internal resumable pause event for human handoff and Babysit flows."""
|
||||
|
||||
type: Literal[AgentBackendInternalEventType.DEFERRED_TOOL_CALL] = AgentBackendInternalEventType.DEFERRED_TOOL_CALL
|
||||
deferred_tool_call: DeferredToolCallPayload
|
||||
type: Literal[AgentBackendInternalEventType.RUN_PAUSED] = AgentBackendInternalEventType.RUN_PAUSED
|
||||
reason: str
|
||||
message: str | None = None
|
||||
session_snapshot: CompositorSessionSnapshot
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
|
||||
|
||||
class AgentBackendRunFailedInternalEvent(AgentBackendInternalEventBase):
|
||||
@ -97,7 +95,7 @@ class AgentBackendRunCancelledInternalEvent(AgentBackendInternalEventBase):
|
||||
type AgentBackendInternalEvent = Annotated[
|
||||
AgentBackendRunStartedInternalEvent
|
||||
| AgentBackendStreamInternalEvent
|
||||
| AgentBackendDeferredToolCallInternalEvent
|
||||
| AgentBackendRunPausedInternalEvent
|
||||
| AgentBackendRunSucceededInternalEvent
|
||||
| AgentBackendRunFailedInternalEvent
|
||||
| AgentBackendRunCancelledInternalEvent,
|
||||
@ -130,18 +128,6 @@ class AgentBackendRunEventAdapter:
|
||||
)
|
||||
]
|
||||
case RunSucceededEvent():
|
||||
if "deferred_tool_call" in event.data.model_fields_set:
|
||||
if event.data.deferred_tool_call is None:
|
||||
raise TypeError("run_succeeded deferred_tool_call branch is missing payload")
|
||||
return [
|
||||
AgentBackendDeferredToolCallInternalEvent(
|
||||
run_id=event.run_id,
|
||||
source_event_id=event.id,
|
||||
deferred_tool_call=event.data.deferred_tool_call,
|
||||
message=_deferred_tool_call_message(event.data.deferred_tool_call),
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
)
|
||||
]
|
||||
return [
|
||||
AgentBackendRunSucceededInternalEvent(
|
||||
run_id=event.run_id,
|
||||
@ -150,6 +136,16 @@ class AgentBackendRunEventAdapter:
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
)
|
||||
]
|
||||
case RunPausedEvent():
|
||||
return [
|
||||
AgentBackendRunPausedInternalEvent(
|
||||
run_id=event.run_id,
|
||||
source_event_id=event.id,
|
||||
reason=event.data.reason,
|
||||
message=event.data.message,
|
||||
session_snapshot=event.data.session_snapshot,
|
||||
)
|
||||
]
|
||||
case RunFailedEvent():
|
||||
return [
|
||||
AgentBackendRunFailedInternalEvent(
|
||||
@ -169,18 +165,3 @@ class AgentBackendRunEventAdapter:
|
||||
)
|
||||
]
|
||||
raise TypeError(f"unsupported agent backend run event: {type(event).__name__}")
|
||||
|
||||
|
||||
def _deferred_tool_call_message(payload: DeferredToolCallPayload) -> str:
|
||||
"""Return a concise workflow pause message from deferred-tool arguments."""
|
||||
args = payload.args
|
||||
if isinstance(args, dict):
|
||||
question = args.get("question")
|
||||
if isinstance(question, str) and question.strip():
|
||||
return question
|
||||
|
||||
title = args.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
return title
|
||||
|
||||
return f"Agent backend requested external input via deferred tool '{payload.tool_name}'."
|
||||
|
||||
@ -17,10 +17,11 @@ from dify_agent.protocol import (
|
||||
CancelRunResponse,
|
||||
CreateRunRequest,
|
||||
CreateRunResponse,
|
||||
DeferredToolCallPayload,
|
||||
RunEvent,
|
||||
RunFailedEvent,
|
||||
RunFailedEventData,
|
||||
RunPausedEvent,
|
||||
RunPausedEventData,
|
||||
RunStartedEvent,
|
||||
RunStatusResponse,
|
||||
RunSucceededEvent,
|
||||
@ -31,11 +32,7 @@ _FIXED_TIME = datetime(2026, 1, 1, tzinfo=UTC)
|
||||
|
||||
|
||||
class FakeAgentBackendScenario(StrEnum):
|
||||
"""Deterministic fake scenarios for API-side integration tests.
|
||||
|
||||
``PAUSED`` represents the API workflow effect. On the Dify Agent wire
|
||||
protocol it is a succeeded run carrying a deferred external tool call.
|
||||
"""
|
||||
"""Deterministic fake scenarios for API-side integration tests."""
|
||||
|
||||
SUCCESS = "success"
|
||||
FAILED = "failed"
|
||||
@ -98,7 +95,7 @@ class FakeAgentBackendRunClient:
|
||||
case FakeAgentBackendScenario.PAUSED:
|
||||
return RunStatusResponse(
|
||||
run_id=run_id,
|
||||
status="succeeded",
|
||||
status="paused",
|
||||
created_at=_FIXED_TIME,
|
||||
updated_at=_FIXED_TIME,
|
||||
)
|
||||
@ -131,17 +128,13 @@ class FakeAgentBackendRunClient:
|
||||
case FakeAgentBackendScenario.PAUSED:
|
||||
return (
|
||||
RunStartedEvent(id="1-0", run_id=run_id, created_at=_FIXED_TIME),
|
||||
RunSucceededEvent(
|
||||
RunPausedEvent(
|
||||
id="2-0",
|
||||
run_id=run_id,
|
||||
created_at=_FIXED_TIME,
|
||||
data=RunSucceededEventData(
|
||||
deferred_tool_call=DeferredToolCallPayload(
|
||||
tool_call_id="fake-ask-human-1",
|
||||
tool_name="ask_human",
|
||||
args={"question": "Agent requested human input."},
|
||||
metadata={"layer_type": "dify.ask_human", "schema_version": 1},
|
||||
),
|
||||
data=RunPausedEventData(
|
||||
reason="human_input_required",
|
||||
message="Agent requested human input.",
|
||||
session_snapshot=CompositorSessionSnapshot(layers=[]),
|
||||
),
|
||||
),
|
||||
|
||||
@ -11,15 +11,13 @@ composition-driven.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import ClassVar
|
||||
from typing import ClassVar, cast
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from agenton.compositor.schemas import LayerSessionSnapshot
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PLAIN_PROMPT_LAYER_TYPE_ID, PromptLayerConfig
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID
|
||||
from dify_agent.layers.ask_human import DIFY_ASK_HUMAN_LAYER_TYPE_ID, DifyAskHumanLayerConfig
|
||||
from dify_agent.layers.dify_plugin import (
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
@ -27,12 +25,10 @@ from dify_agent.layers.dify_plugin import (
|
||||
DifyPluginLLMLayerConfig,
|
||||
DifyPluginToolsLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.drive import DIFY_DRIVE_LAYER_TYPE_ID, DifyDriveLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.knowledge import DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID, DifyKnowledgeBaseLayerConfig
|
||||
from dify_agent.layers.output import DIFY_OUTPUT_LAYER_TYPE_ID, DifyOutputLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.protocol import (
|
||||
@ -40,12 +36,10 @@ from dify_agent.protocol import (
|
||||
DIFY_AGENT_MODEL_LAYER_ID,
|
||||
DIFY_AGENT_OUTPUT_LAYER_ID,
|
||||
CreateRunRequest,
|
||||
DeferredToolResultsPayload,
|
||||
LayerExitSignals,
|
||||
RunComposition,
|
||||
RunLayerSpec,
|
||||
RunPurpose,
|
||||
RuntimeLayerSpec,
|
||||
)
|
||||
from pydantic import BaseModel, ConfigDict, Field, JsonValue, field_validator
|
||||
|
||||
@ -54,16 +48,74 @@ WORKFLOW_NODE_JOB_PROMPT_LAYER_ID = "workflow_node_job_prompt"
|
||||
WORKFLOW_USER_PROMPT_LAYER_ID = "workflow_user_prompt"
|
||||
AGENT_APP_USER_PROMPT_LAYER_ID = "agent_app_user_prompt"
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_ID = "execution_context"
|
||||
DIFY_DRIVE_LAYER_ID = "drive"
|
||||
DIFY_PLUGIN_TOOLS_LAYER_ID = "tools"
|
||||
DIFY_KNOWLEDGE_BASE_LAYER_ID = "knowledge"
|
||||
DIFY_ASK_HUMAN_LAYER_ID = "ask_human"
|
||||
DIFY_SHELL_LAYER_ID = "shell"
|
||||
|
||||
# Layer types that hold credentials in their per-run config. These are excluded
|
||||
# from the cleanup-replay composition (and from the snapshot that is sent with
|
||||
# the cleanup request) because we deliberately do not persist plaintext
|
||||
# credentials between runs.
|
||||
_CLEANUP_EXCLUDED_LAYER_TYPES: tuple[str, ...] = (
|
||||
DIFY_PLUGIN_LLM_LAYER_TYPE_ID,
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
)
|
||||
|
||||
|
||||
class CleanupLayerSpec(BaseModel):
|
||||
"""One layer node replayed by an Agent backend cleanup-only run.
|
||||
|
||||
Cleanup composition cannot include credential-bearing plugin layers, so we
|
||||
persist only the non-plugin layer specs together with the original config.
|
||||
Storing the config (rather than just ``name``/``type``) means cleanup does
|
||||
not depend on the original build-time inputs being re-derivable.
|
||||
"""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
deps: dict[str, str] = Field(default_factory=dict)
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
config: JsonValue = None
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
def extract_cleanup_layer_specs(composition: RunComposition) -> list[CleanupLayerSpec]:
|
||||
"""Project the in-flight composition into the persistable cleanup spec list.
|
||||
|
||||
Plugin layers are intentionally dropped (their configs hold credentials and
|
||||
the lifecycle contract says "do not include an LLM layer" during cleanup).
|
||||
The filtered names must later drive snapshot filtering so the agenton
|
||||
compositor's name-order check still passes for the cleanup run.
|
||||
"""
|
||||
excluded = set(_CLEANUP_EXCLUDED_LAYER_TYPES)
|
||||
specs: list[CleanupLayerSpec] = []
|
||||
for layer in composition.layers:
|
||||
if layer.type in excluded:
|
||||
continue
|
||||
config_value: JsonValue = None
|
||||
if isinstance(layer.config, BaseModel):
|
||||
config_value = layer.config.model_dump(mode="json", warnings=False)
|
||||
else:
|
||||
# ``RunLayerSpec.config`` is typed as ``LayerConfigInput`` which
|
||||
# includes ``Mapping[str, object] | bytes``. In the cleanup-replay
|
||||
# pipeline our builder only emits BaseModel-derived configs or
|
||||
# ``None``, so the wider input alias narrows safely here.
|
||||
config_value = cast(JsonValue, layer.config)
|
||||
specs.append(
|
||||
CleanupLayerSpec(
|
||||
name=layer.name,
|
||||
type=layer.type,
|
||||
deps=dict(layer.deps),
|
||||
metadata=dict(layer.metadata),
|
||||
config=config_value,
|
||||
)
|
||||
)
|
||||
return specs
|
||||
|
||||
|
||||
def _filter_snapshot_to_specs(
|
||||
snapshot: CompositorSessionSnapshot,
|
||||
specs: list[RuntimeLayerSpec],
|
||||
specs: list[CleanupLayerSpec],
|
||||
) -> CompositorSessionSnapshot:
|
||||
"""Keep only snapshot layers whose names appear in the cleanup spec list.
|
||||
|
||||
@ -90,30 +142,6 @@ class AgentBackendModelConfig(BaseModel):
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
# ``DifyPluginLLMLayerConfig.model_settings`` is pydantic_ai's ``ModelSettings``
|
||||
# TypedDict (closed: unknown keys are rejected, explicit ``None`` values fail the
|
||||
# per-field type checks). Agent Soul model settings carry a wider, nullable shape
|
||||
# (``stop`` / ``response_format`` plus null-padded fields), so the layer config
|
||||
# only receives the keys the runtime contract accepts.
|
||||
_AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS = (
|
||||
"temperature",
|
||||
"top_p",
|
||||
"presence_penalty",
|
||||
"frequency_penalty",
|
||||
"max_tokens",
|
||||
)
|
||||
|
||||
|
||||
def _agent_model_settings(settings: Mapping[str, JsonValue]) -> dict[str, JsonValue] | None:
|
||||
sanitized: dict[str, JsonValue] = {
|
||||
key: settings[key] for key in _AGENT_MODEL_SETTINGS_PASSTHROUGH_KEYS if settings.get(key) is not None
|
||||
}
|
||||
stop = settings.get("stop")
|
||||
if isinstance(stop, list) and stop:
|
||||
sanitized["stop_sequences"] = stop
|
||||
return sanitized or None
|
||||
|
||||
|
||||
class AgentBackendOutputConfig(BaseModel):
|
||||
"""API-side structured output declaration for the conventional output layer.
|
||||
|
||||
@ -141,22 +169,11 @@ class AgentBackendWorkflowNodeRunInput(BaseModel):
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement; a deferred call ends the run and
|
||||
# the workflow pauses via the existing HITL form mechanism (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
# Human tool results fed back into a continuation run after a HITL submission
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
@ -188,21 +205,11 @@ class AgentBackendAgentAppRunInput(BaseModel):
|
||||
idempotency_key: str | None = None
|
||||
output: AgentBackendOutputConfig | None = None
|
||||
tools: DifyPluginToolsLayerConfig | None = None
|
||||
knowledge: DifyKnowledgeBaseLayerConfig | None = None
|
||||
# Drive Skills & Files declaration (dify.drive) — an index the agent pulls
|
||||
# through the back proxy, never inline content; see AGENT_DRIVE_MANIFEST_ENABLED.
|
||||
drive_config: DifyDriveLayerConfig | None = None
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). Present only when
|
||||
# the Agent Soul configures human involvement (ENG-635).
|
||||
ask_human_config: DifyAskHumanLayerConfig | None = None
|
||||
# Inject the sandboxed shell layer (dify.shell). Requires the agent backend
|
||||
# to be wired with a shellctl entrypoint; see configs AGENT_SHELL_ENABLED.
|
||||
include_shell: bool = False
|
||||
shell_config: DifyShellLayerConfig | None = None
|
||||
session_snapshot: CompositorSessionSnapshot | None = None
|
||||
# Human tool results fed back into a continuation run after a HITL submission
|
||||
# (ENG-638). Keyed by the original deferred tool_call_id.
|
||||
deferred_tool_results: DeferredToolResultsPayload | None = None
|
||||
include_history: bool = True
|
||||
suspend_on_exit: bool = True
|
||||
metadata: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
@ -225,7 +232,7 @@ class AgentBackendRunRequestBuilder:
|
||||
|
||||
Layer graph: optional Agent Soul system prompt → user prompt →
|
||||
execution context → optional history (multi-turn) → LLM → optional
|
||||
plugin tools / knowledge search → optional structured output. Mirrors the workflow-node
|
||||
plugin tools → optional structured output. Mirrors the workflow-node
|
||||
layer ordering minus the workflow-job / previous-node prompt.
|
||||
"""
|
||||
layers: list[RunLayerSpec] = []
|
||||
@ -256,18 +263,6 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -288,7 +283,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
),
|
||||
)
|
||||
)
|
||||
@ -304,39 +299,13 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.knowledge,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.ask_human_config is not None:
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
|
||||
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
|
||||
# later resumes with deferred_tool_results. Needs the history layer above.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_ASK_HUMAN_LAYER_ID,
|
||||
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.ask_human_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
|
||||
# so the spec carries no deps; shellctl connection is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
@ -362,7 +331,6 @@ class AgentBackendRunRequestBuilder:
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
@ -372,7 +340,7 @@ class AgentBackendRunRequestBuilder:
|
||||
self,
|
||||
*,
|
||||
session_snapshot: CompositorSessionSnapshot,
|
||||
runtime_layer_specs: list[RuntimeLayerSpec],
|
||||
composition_layer_specs: list[CleanupLayerSpec],
|
||||
idempotency_key: str | None = None,
|
||||
metadata: dict[str, JsonValue] | None = None,
|
||||
) -> CreateRunRequest:
|
||||
@ -385,9 +353,9 @@ class AgentBackendRunRequestBuilder:
|
||||
composition and the snapshot before submission because their configs
|
||||
require credentials that are not persisted between runs.
|
||||
"""
|
||||
if not runtime_layer_specs:
|
||||
if not composition_layer_specs:
|
||||
raise ValueError(
|
||||
"build_cleanup_request requires runtime_layer_specs; an empty "
|
||||
"build_cleanup_request requires composition_layer_specs; an empty "
|
||||
"composition would fail the agent backend's snapshot validation."
|
||||
)
|
||||
request_metadata = dict(metadata or {})
|
||||
@ -400,9 +368,9 @@ class AgentBackendRunRequestBuilder:
|
||||
metadata=dict(spec.metadata),
|
||||
config=spec.config,
|
||||
)
|
||||
for spec in runtime_layer_specs
|
||||
for spec in composition_layer_specs
|
||||
]
|
||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, runtime_layer_specs)
|
||||
filtered_snapshot = _filter_snapshot_to_specs(session_snapshot, composition_layer_specs)
|
||||
return CreateRunRequest(
|
||||
composition=RunComposition(layers=layers),
|
||||
purpose="workflow_node",
|
||||
@ -413,12 +381,7 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
|
||||
def build_for_workflow_node(self, run_input: AgentBackendWorkflowNodeRunInput) -> CreateRunRequest:
|
||||
"""Build a workflow Agent Node run request without defining another wire schema.
|
||||
|
||||
Layer graph mirrors the workflow surface: prompts → execution context →
|
||||
optional drive/history → LLM → optional plugin tools / knowledge search
|
||||
→ optional auxiliary layers such as ask_human, shell, and structured output.
|
||||
"""
|
||||
"""Build a workflow Agent Node run request without defining another wire schema."""
|
||||
layers: list[RunLayerSpec] = []
|
||||
if run_input.agent_soul_prompt:
|
||||
layers.append(
|
||||
@ -453,18 +416,6 @@ class AgentBackendRunRequestBuilder:
|
||||
]
|
||||
)
|
||||
|
||||
if run_input.drive_config is not None:
|
||||
# Drive Skills & Files declaration (dify.drive): a config-only index;
|
||||
# the agent pulls listed entries through the back proxy by drive_ref.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_DRIVE_LAYER_ID,
|
||||
type=DIFY_DRIVE_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.drive_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_history:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
@ -486,7 +437,7 @@ class AgentBackendRunRequestBuilder:
|
||||
model_provider=run_input.model.model_provider,
|
||||
model=run_input.model.model,
|
||||
credentials=run_input.model.credentials,
|
||||
model_settings=_agent_model_settings(run_input.model.model_settings),
|
||||
model_settings=run_input.model.model_settings or None,
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -503,39 +454,13 @@ class AgentBackendRunRequestBuilder:
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.knowledge is not None and run_input.knowledge.dataset_ids:
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_KNOWLEDGE_BASE_LAYER_ID,
|
||||
type=DIFY_KNOWLEDGE_BASE_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.knowledge,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.ask_human_config is not None:
|
||||
# Human-in-the-loop ask_human deferred tool (dify.ask_human). A call ends
|
||||
# the run with a deferred_tool_call; the caller pauses (workflow HITL) and
|
||||
# later resumes with deferred_tool_results. Needs the history layer above.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_ASK_HUMAN_LAYER_ID,
|
||||
type=DIFY_ASK_HUMAN_LAYER_TYPE_ID,
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.ask_human_config,
|
||||
)
|
||||
)
|
||||
|
||||
if run_input.include_shell:
|
||||
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
|
||||
# the agent server can mint per-command Agent Stub env (back proxy);
|
||||
# shellctl connection itself is server-injected.
|
||||
# Sandboxed bash workspace (dify.shell). The layer declares NoLayerDeps,
|
||||
# so the spec carries no deps; shellctl connection is server-injected.
|
||||
layers.append(
|
||||
RunLayerSpec(
|
||||
name=DIFY_SHELL_LAYER_ID,
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
|
||||
metadata=run_input.metadata,
|
||||
config=run_input.shell_config or DifyShellLayerConfig(),
|
||||
)
|
||||
@ -561,7 +486,6 @@ class AgentBackendRunRequestBuilder:
|
||||
idempotency_key=run_input.idempotency_key,
|
||||
metadata=run_input.metadata,
|
||||
session_snapshot=run_input.session_snapshot,
|
||||
deferred_tool_results=run_input.deferred_tool_results,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND if run_input.suspend_on_exit else ExitIntent.DELETE,
|
||||
),
|
||||
|
||||
135
api/clients/agent_backend/workspace_files_client.py
Normal file
135
api/clients/agent_backend/workspace_files_client.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""API-side client for the agent backend's read-only workspace file endpoints.
|
||||
|
||||
The agent backend exposes ``/workspaces/{session_id}/files{,/preview,/download}``
|
||||
to inspect a shell-layer sandbox workspace. This thin synchronous client proxies
|
||||
those reads for the console FS inspector and normalizes transport/HTTP failures
|
||||
into the API backend's ``AgentBackendError`` boundary, preserving the backend's
|
||||
status code and ``{code, message}`` detail so the controller can relay them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
|
||||
|
||||
_DEFAULT_TIMEOUT_SECONDS = 30.0
|
||||
|
||||
|
||||
class WorkspaceFileEntry(BaseModel):
|
||||
"""One entry in a workspace directory listing."""
|
||||
|
||||
name: str
|
||||
type: Literal["file", "dir", "symlink"]
|
||||
size: int
|
||||
mtime: int
|
||||
|
||||
|
||||
class WorkspaceListResult(BaseModel):
|
||||
"""Directory listing of a workspace path."""
|
||||
|
||||
path: str
|
||||
entries: list[WorkspaceFileEntry]
|
||||
truncated: bool
|
||||
|
||||
|
||||
class WorkspacePreviewResult(BaseModel):
|
||||
"""Inline preview of a workspace file."""
|
||||
|
||||
path: str
|
||||
size: int
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkspaceDownloadResult:
|
||||
"""Decoded bytes of a workspace file for download."""
|
||||
|
||||
path: str
|
||||
size: int
|
||||
truncated: bool
|
||||
content: bytes
|
||||
|
||||
|
||||
class WorkspaceFilesBackendClient:
|
||||
"""Synchronous proxy to the agent backend workspace file endpoints."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
timeout: float = _DEFAULT_TIMEOUT_SECONDS,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> None:
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._timeout = timeout
|
||||
self._transport = transport
|
||||
|
||||
def list_files(self, session_id: str, path: str) -> WorkspaceListResult:
|
||||
data = self._get(f"/workspaces/{session_id}/files", params={"path": path})
|
||||
return WorkspaceListResult.model_validate(data)
|
||||
|
||||
def preview(self, session_id: str, path: str) -> WorkspacePreviewResult:
|
||||
data = self._get(f"/workspaces/{session_id}/files/preview", params={"path": path})
|
||||
return WorkspacePreviewResult.model_validate(data)
|
||||
|
||||
def download(self, session_id: str, path: str) -> WorkspaceDownloadResult:
|
||||
data = self._get(f"/workspaces/{session_id}/files/download", params={"path": path})
|
||||
encoded = data.get("content_base64")
|
||||
if not isinstance(encoded, str):
|
||||
raise AgentBackendHTTPError("agent backend download response missing content", status_code=502, detail=data)
|
||||
try:
|
||||
content = base64.b64decode(encoded, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise AgentBackendHTTPError(
|
||||
"agent backend returned undecodable download content", status_code=502, detail=str(exc)
|
||||
) from exc
|
||||
size = data.get("size")
|
||||
return WorkspaceDownloadResult(
|
||||
path=str(data.get("path", path)),
|
||||
size=int(size) if isinstance(size, (int, float)) else len(content),
|
||||
truncated=bool(data.get("truncated")),
|
||||
content=content,
|
||||
)
|
||||
|
||||
def _get(self, route: str, *, params: dict[str, str]) -> dict[str, object]:
|
||||
url = f"{self._base_url}{route}"
|
||||
try:
|
||||
with httpx.Client(timeout=self._timeout, transport=self._transport, trust_env=False) as client:
|
||||
response = client.get(url, params=params)
|
||||
except httpx.HTTPError as exc:
|
||||
raise AgentBackendTransportError(f"failed to reach agent backend workspace endpoint: {exc}") from exc
|
||||
if response.status_code >= 400:
|
||||
detail: object
|
||||
try:
|
||||
detail = response.json().get("detail", response.text)
|
||||
except ValueError:
|
||||
detail = response.text
|
||||
raise AgentBackendHTTPError(
|
||||
f"agent backend workspace request failed ({response.status_code})",
|
||||
status_code=response.status_code,
|
||||
detail=detail,
|
||||
)
|
||||
body = response.json()
|
||||
if not isinstance(body, dict):
|
||||
raise AgentBackendHTTPError(
|
||||
"agent backend workspace response was not an object", status_code=502, detail=body
|
||||
)
|
||||
return body
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkspaceDownloadResult",
|
||||
"WorkspaceFileEntry",
|
||||
"WorkspaceFilesBackendClient",
|
||||
"WorkspaceListResult",
|
||||
"WorkspacePreviewResult",
|
||||
]
|
||||
@ -11,7 +11,6 @@ from .data_migration import (
|
||||
migration_data_wizard,
|
||||
)
|
||||
from .plugin import (
|
||||
backfill_plugin_auto_upgrade,
|
||||
extract_plugins,
|
||||
extract_unique_plugins,
|
||||
install_plugins,
|
||||
@ -50,7 +49,6 @@ from .vector import (
|
||||
__all__ = [
|
||||
"add_qdrant_index",
|
||||
"archive_workflow_runs",
|
||||
"backfill_plugin_auto_upgrade",
|
||||
"clean_expired_messages",
|
||||
"clean_workflow_runs",
|
||||
"cleanup_orphaned_draft_variables",
|
||||
|
||||
@ -145,7 +145,7 @@ def legacy_model_types(
|
||||
option_name="--model-types",
|
||||
)
|
||||
selected_model_types = (
|
||||
tuple(ModelType(model_type) for model_type in normalized_model_types)
|
||||
tuple(ModelType.value_of(model_type) for model_type in normalized_model_types)
|
||||
if normalized_model_types
|
||||
else (
|
||||
ModelType.LLM,
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, cast
|
||||
|
||||
import click
|
||||
from pydantic import TypeAdapter
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.engine import CursorResult
|
||||
|
||||
from configs import dify_config
|
||||
@ -16,13 +15,11 @@ from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import encrypt_system_params
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID, ToolProviderID
|
||||
from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from models.tools import ToolOAuthSystemClient
|
||||
from services.plugin.data_migration import PluginDataMigration
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -405,110 +402,6 @@ def migrate_data_for_plugin():
|
||||
click.echo(click.style("Migrate data for plugin completed.", fg="green"))
|
||||
|
||||
|
||||
def _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit: int | None = None):
|
||||
category_count = len(TenantPluginAutoUpgradeStrategy.PluginCategory)
|
||||
stmt = (
|
||||
select(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.group_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
.having(func.count(func.distinct(TenantPluginAutoUpgradeStrategy.category)) < category_count)
|
||||
.order_by(TenantPluginAutoUpgradeStrategy.tenant_id)
|
||||
)
|
||||
|
||||
if limit is not None:
|
||||
stmt = stmt.limit(limit)
|
||||
|
||||
return stmt
|
||||
|
||||
|
||||
def _count_auto_upgrade_strategy_tenant_ids(limit: int | None) -> int:
|
||||
candidate_stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).subquery()
|
||||
return db.session.scalar(select(func.count()).select_from(candidate_stmt)) or 0
|
||||
|
||||
|
||||
def _iter_auto_upgrade_strategy_tenant_ids(limit: int | None):
|
||||
stmt = _candidate_auto_upgrade_strategy_tenant_ids_stmt(limit).execution_options(yield_per=1000)
|
||||
yield from db.session.scalars(stmt)
|
||||
|
||||
|
||||
@click.command(
|
||||
"backfill-plugin-auto-upgrade",
|
||||
help="Backfill category-scoped plugin auto-upgrade strategies and normalize plugin lists.",
|
||||
)
|
||||
@click.option("--tenant-id", multiple=True, help="Tenant ID to backfill. Can be passed multiple times.")
|
||||
@click.option("--limit", type=int, default=None, help="Maximum number of candidate tenants to process.")
|
||||
@click.option("--batch-size", type=int, default=500, show_default=True, help="Progress reporting batch size.")
|
||||
@click.option("--dry-run", is_flag=True, help="Only print candidate tenant count.")
|
||||
def backfill_plugin_auto_upgrade(
|
||||
tenant_id: tuple[str, ...],
|
||||
limit: int | None,
|
||||
batch_size: int,
|
||||
dry_run: bool,
|
||||
):
|
||||
"""
|
||||
Backfill historical auto-upgrade strategies after the category column exists.
|
||||
|
||||
Missing category rows are created from the tenant's tool/default row. Pure default
|
||||
strategies become latest for model plugins and fix-only for all other categories.
|
||||
Tenants with include/exclude plugin IDs are split
|
||||
by installed plugin category using plugin daemon metadata.
|
||||
"""
|
||||
start_at = time.perf_counter()
|
||||
candidate_count = len(tenant_id) if tenant_id else _count_auto_upgrade_strategy_tenant_ids(limit)
|
||||
click.echo(click.style(f"Found {candidate_count} candidate tenants.", fg="yellow"))
|
||||
|
||||
if dry_run:
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(click.style(f"Dry run completed. elapsed={elapsed:.2f}s", fg="green"))
|
||||
return
|
||||
|
||||
tenant_ids = list(tenant_id) if tenant_id else _iter_auto_upgrade_strategy_tenant_ids(limit)
|
||||
|
||||
backfilled_count = 0
|
||||
created_count = 0
|
||||
normalized_count = 0
|
||||
skipped_count = 0
|
||||
failed_count = 0
|
||||
for index, current_tenant_id in enumerate(tenant_ids, start=1):
|
||||
try:
|
||||
result = PluginAutoUpgradeService.backfill_strategy_categories(
|
||||
current_tenant_id,
|
||||
)
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
click.echo(click.style(f"Failed tenant {current_tenant_id}: {str(e)}", fg="red"))
|
||||
continue
|
||||
|
||||
if result.created_count > 0:
|
||||
backfilled_count += 1
|
||||
created_count += result.created_count
|
||||
elif not result.normalized:
|
||||
skipped_count += 1
|
||||
if result.normalized:
|
||||
normalized_count += 1
|
||||
|
||||
if batch_size > 0 and index % batch_size == 0:
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Processed {index}/{candidate_count} tenants. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={time.perf_counter() - start_at:.2f}s",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
elapsed = time.perf_counter() - start_at
|
||||
click.echo(
|
||||
click.style(
|
||||
f"Backfill plugin auto-upgrade strategy categories completed. "
|
||||
f"backfilled={backfilled_count}, created_rows={created_count}, "
|
||||
f"normalized={normalized_count}, skipped={skipped_count}, failed={failed_count}, "
|
||||
f"elapsed={elapsed:.2f}s",
|
||||
fg="green",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@click.command("extract-plugins", help="Extract plugins.")
|
||||
@click.option("--output_file", prompt=True, help="The file to store the extracted plugins.", default="plugins.jsonl")
|
||||
@click.option("--workers", prompt=True, help="The number of workers to extract plugins.", default=10)
|
||||
|
||||
@ -16,7 +16,7 @@ class EnterpriseFeatureConfig(BaseSettings):
|
||||
|
||||
CAN_REPLACE_LOGO: bool = Field(
|
||||
description="Allow customization of the enterprise logo.",
|
||||
default=True,
|
||||
default=False,
|
||||
)
|
||||
|
||||
ENTERPRISE_REQUEST_TIMEOUT: int = Field(
|
||||
|
||||
@ -31,13 +31,3 @@ class AgentBackendConfig(BaseSettings):
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
AGENT_DRIVE_MANIFEST_ENABLED: bool = Field(
|
||||
description=(
|
||||
"Inject the dify.drive layer (Skills & Files drive manifest declaration) "
|
||||
"into Agent runs. The declaration is an index only — the agent backend "
|
||||
"pulls the actual SKILL.md / files through the back proxy. Keep it off "
|
||||
"until the agent backend registers the dify.drive layer type."
|
||||
),
|
||||
default=False,
|
||||
)
|
||||
|
||||
@ -943,18 +943,12 @@ class AuthConfig(BaseSettings):
|
||||
default=True,
|
||||
)
|
||||
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN: NonNegativeInt = Field(
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN: PositiveInt = Field(
|
||||
description="Per-token rate limit on /openapi/v1/* (requests per minute). "
|
||||
"Bucket keyed on sha256(token), shared across api replicas via Redis. "
|
||||
"Set to 0 to disable the per-token limit entirely.",
|
||||
"Bucket keyed on sha256(token), shared across api replicas via Redis.",
|
||||
default=60,
|
||||
)
|
||||
|
||||
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR: PositiveInt = Field(
|
||||
description="Max device-flow approve requests per session per hour on /openapi/oauth/device/approve.",
|
||||
default=10,
|
||||
)
|
||||
|
||||
|
||||
class ModerationConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@ -20,12 +20,6 @@ class NacosHttpClient:
|
||||
self.token: str | None = None
|
||||
self.token_ttl = 18000
|
||||
self.token_expire_time: float = 0
|
||||
# Bounded timeouts so a slow or unresponsive Nacos server cannot hang the API
|
||||
# service indefinitely during startup or token refresh.
|
||||
self.timeout = httpx.Timeout(
|
||||
float(os.getenv("DIFY_ENV_NACOS_REQUEST_TIMEOUT", "10.0")),
|
||||
connect=float(os.getenv("DIFY_ENV_NACOS_CONNECT_TIMEOUT", "3.0")),
|
||||
)
|
||||
|
||||
def http_request(
|
||||
self, url: str, method: str = "GET", headers: dict[str, str] | None = None, params: dict[str, str] | None = None
|
||||
@ -34,17 +28,12 @@ class NacosHttpClient:
|
||||
headers = {}
|
||||
if params is None:
|
||||
params = {}
|
||||
full_url = "http://" + self.server + url
|
||||
try:
|
||||
self._inject_auth_info(headers, params)
|
||||
response = httpx.request(method, url=full_url, headers=headers, params=params, timeout=self.timeout)
|
||||
response = httpx.request(method, url="http://" + self.server + url, headers=headers, params=params)
|
||||
response.raise_for_status()
|
||||
return response.text
|
||||
except httpx.TimeoutException as e:
|
||||
logger.warning("Request to Nacos timed out (url=%s, timeout=%s): %s", full_url, self.timeout, e)
|
||||
return f"Request to Nacos timed out: {e}"
|
||||
except httpx.RequestError as e:
|
||||
logger.warning("Request to Nacos failed (url=%s): %s", full_url, e)
|
||||
return f"Request to Nacos failed: {e}"
|
||||
|
||||
def _inject_auth_info(self, headers: dict[str, str], params: dict[str, str], module: str = "config") -> None:
|
||||
@ -89,16 +78,13 @@ class NacosHttpClient:
|
||||
params = {"username": self.username, "password": self.password}
|
||||
url = "http://" + self.server + "/nacos/v1/auth/login"
|
||||
try:
|
||||
resp = httpx.request("POST", url, headers=None, params=params, timeout=self.timeout)
|
||||
resp = httpx.request("POST", url, headers=None, params=params)
|
||||
resp.raise_for_status()
|
||||
response_data = resp.json()
|
||||
self.token = response_data.get("accessToken")
|
||||
self.token_ttl = response_data.get("tokenTtl", 18000)
|
||||
self.token_expire_time = current_time + self.token_ttl - 10
|
||||
return self.token
|
||||
except httpx.TimeoutException:
|
||||
logger.exception("[get-access-token] request to Nacos timed out (url=%s, timeout=%s)", url, self.timeout)
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("[get-access-token] exception occur")
|
||||
raise
|
||||
|
||||
@ -7,6 +7,7 @@ consumes injected context managers when it needs to preserve thread-local state.
|
||||
|
||||
import contextvars
|
||||
import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from typing import Any, Protocol, final, override, runtime_checkable
|
||||
@ -14,25 +15,28 @@ from typing import Any, Protocol, final, override, runtime_checkable
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AppContext(Protocol):
|
||||
class AppContext(ABC):
|
||||
"""
|
||||
Application context interface.
|
||||
Abstract application context interface.
|
||||
|
||||
Application adapters can implement this to restore framework-specific state
|
||||
such as Flask app context around worker execution.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def get_extension(self, name: str) -> Any:
|
||||
"""Get application extension by name."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def enter(self) -> AbstractContextManager[None]:
|
||||
"""Enter the application context."""
|
||||
...
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
from copy import deepcopy
|
||||
from typing import Annotated, Any, Literal, override
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, Field, GetJsonSchemaHandler, WithJsonSchema, model_validator
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from libs.helper import UUIDStrOrEmpty
|
||||
|
||||
@ -10,53 +9,8 @@ from libs.helper import UUIDStrOrEmpty
|
||||
|
||||
|
||||
class ConversationRenamePayload(BaseModel):
|
||||
name: str | None = Field(
|
||||
default=None,
|
||||
description="Conversation name. Required when `auto_generate` is `false`.",
|
||||
)
|
||||
auto_generate: bool = Field(
|
||||
default=False,
|
||||
description="Automatically generate the conversation name. When `true`, the `name` field is ignored.",
|
||||
)
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: GetJsonSchemaHandler) -> dict[str, Any]:
|
||||
schema = handler.resolve_ref_schema(handler(core_schema))
|
||||
properties = schema.get("properties")
|
||||
if not isinstance(properties, dict):
|
||||
return schema
|
||||
|
||||
auto_generate_schema = deepcopy(properties.get("auto_generate", {"type": "boolean"}))
|
||||
name_schema = deepcopy(properties.get("name", {"type": "string"}))
|
||||
non_blank_name_schema: dict[str, Any] = {"pattern": r".*\S.*", "type": "string"}
|
||||
if isinstance(name_schema, dict) and isinstance(name_schema.get("title"), str):
|
||||
non_blank_name_schema["title"] = name_schema["title"]
|
||||
|
||||
auto_generate_true_schema = {**auto_generate_schema, "enum": [True]}
|
||||
auto_generate_true_schema.pop("default", None)
|
||||
|
||||
return {
|
||||
**schema,
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
"auto_generate": auto_generate_true_schema,
|
||||
"name": name_schema,
|
||||
},
|
||||
"required": ["auto_generate"],
|
||||
"type": "object",
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"auto_generate": {**auto_generate_schema, "enum": [False]},
|
||||
"name": non_blank_name_schema,
|
||||
},
|
||||
"required": ["name"],
|
||||
"type": "object",
|
||||
},
|
||||
],
|
||||
}
|
||||
name: str | None = None
|
||||
auto_generate: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_name_requirement(self):
|
||||
@ -70,28 +24,14 @@ class ConversationRenamePayload(BaseModel):
|
||||
|
||||
|
||||
class MessageListQuery(BaseModel):
|
||||
conversation_id: UUIDStrOrEmpty = Field(description="Conversation ID.")
|
||||
first_id: UUIDStrOrEmpty | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"The ID of the first chat record on the current page. Omit this value to fetch the latest messages; "
|
||||
"for subsequent pages, use the first message ID from the current list to fetch older messages."
|
||||
),
|
||||
)
|
||||
limit: int = Field(
|
||||
default=20,
|
||||
ge=1,
|
||||
le=100,
|
||||
description="Number of chat history messages to return per request.",
|
||||
)
|
||||
conversation_id: UUIDStrOrEmpty = Field(description="Conversation UUID")
|
||||
first_id: UUIDStrOrEmpty | None = Field(default=None, description="First message ID for pagination")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Number of messages to return (1-100)")
|
||||
|
||||
|
||||
class MessageFeedbackPayload(BaseModel):
|
||||
rating: Literal["like", "dislike"] | None = Field(
|
||||
default=None,
|
||||
description="Feedback rating. Set to `null` to revoke previously submitted feedback.",
|
||||
)
|
||||
content: str | None = Field(default=None, description="Optional text feedback providing additional detail.")
|
||||
rating: Literal["like", "dislike"] | None = None
|
||||
content: str | None = None
|
||||
|
||||
|
||||
# --- Saved message schemas ---
|
||||
@ -108,39 +48,6 @@ class SavedMessageCreatePayload(BaseModel):
|
||||
|
||||
# --- Workflow schemas ---
|
||||
|
||||
WORKFLOW_INPUT_FILE_ITEM_SCHEMA: dict[str, object] = {
|
||||
"type": "object",
|
||||
"required": ["type", "transfer_method"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"description": "File type.",
|
||||
"enum": ["document", "image", "audio", "video", "custom"],
|
||||
"type": "string",
|
||||
},
|
||||
"transfer_method": {
|
||||
"description": "Transfer method: `remote_url` for file URL, `local_file` for uploaded file.",
|
||||
"enum": ["remote_url", "local_file"],
|
||||
"type": "string",
|
||||
},
|
||||
"url": {
|
||||
"description": "File URL when `transfer_method` is `remote_url`.",
|
||||
"format": "url",
|
||||
"type": "string",
|
||||
},
|
||||
"upload_file_id": {
|
||||
"description": (
|
||||
"Uploaded file ID obtained from the [Upload File](/api-reference/files/upload-file) API when "
|
||||
"`transfer_method` is `local_file`."
|
||||
),
|
||||
"type": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
WORKFLOW_INPUT_FILE_LIST_SCHEMA: dict[str, object] = {
|
||||
"anyOf": [{"items": WORKFLOW_INPUT_FILE_ITEM_SCHEMA, "type": "array"}, {"type": "null"}]
|
||||
}
|
||||
WorkflowInputFileList = Annotated[list[dict[str, Any]] | None, WithJsonSchema(WORKFLOW_INPUT_FILE_LIST_SCHEMA)]
|
||||
|
||||
|
||||
class DefaultBlockConfigQuery(BaseModel):
|
||||
q: str | None = None
|
||||
@ -154,22 +61,8 @@ class WorkflowListQuery(BaseModel):
|
||||
|
||||
|
||||
class WorkflowRunPayload(BaseModel):
|
||||
inputs: dict[str, Any] = Field(
|
||||
description=(
|
||||
"Key-value pairs for workflow input variables. Values for file-type variables should be arrays of "
|
||||
"file objects with `type`, `transfer_method`, and either `url` or `upload_file_id`. Refer to the "
|
||||
"`user_input_form` field in the [Get App Parameters](/api-reference/applications/get-app-parameters) "
|
||||
"response to discover the variable names and types expected by your app."
|
||||
)
|
||||
)
|
||||
files: WorkflowInputFileList = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"File list for workflow system file inputs. Available when file upload is enabled for the workflow. "
|
||||
"To attach a local file, first upload it via [Upload File](/api-reference/files/upload-file) and use "
|
||||
"the returned `id` as `upload_file_id` with `transfer_method: local_file`."
|
||||
),
|
||||
)
|
||||
inputs: dict[str, Any]
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class WorkflowUpdatePayload(BaseModel):
|
||||
@ -184,49 +77,28 @@ DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS = 100
|
||||
|
||||
|
||||
class ChildChunkCreatePayload(BaseModel):
|
||||
content: str = Field(description="Child chunk text content.")
|
||||
content: str
|
||||
|
||||
|
||||
class ChildChunkUpdatePayload(BaseModel):
|
||||
content: str = Field(description="Child chunk text content.")
|
||||
content: str
|
||||
|
||||
|
||||
class DocumentBatchDownloadZipPayload(BaseModel):
|
||||
"""Request payload for bulk downloading documents as a zip archive."""
|
||||
|
||||
document_ids: list[UUID] = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS,
|
||||
description="List of document IDs to include in the ZIP download.",
|
||||
)
|
||||
document_ids: list[UUID] = Field(..., min_length=1, max_length=DOCUMENT_BATCH_DOWNLOAD_ZIP_MAX_DOCS)
|
||||
|
||||
|
||||
class MetadataUpdatePayload(BaseModel):
|
||||
name: str = Field(description="New metadata field name.")
|
||||
name: str
|
||||
|
||||
|
||||
# --- Audio schemas ---
|
||||
|
||||
|
||||
UUIDString = Annotated[str, WithJsonSchema({"format": "uuid", "type": "string"})]
|
||||
|
||||
|
||||
class TextToAudioPayload(BaseModel):
|
||||
message_id: UUIDString | None = Field(
|
||||
default=None,
|
||||
description="Message ID. Takes priority over `text` when both are provided.",
|
||||
)
|
||||
voice: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Voice to use for text-to-speech. Available voices depend on the TTS provider configured for this app. "
|
||||
"Omit to use the app's configured voice when available; that value is exposed by "
|
||||
"[Get App Parameters](/api-reference/applications/get-app-parameters) as `text_to_speech.voice`."
|
||||
),
|
||||
)
|
||||
text: str | None = Field(default=None, description="Speech content to convert.")
|
||||
streaming: bool | None = Field(
|
||||
default=None,
|
||||
description="Reserved for compatibility; TTS response streaming is determined by the provider output.",
|
||||
)
|
||||
message_id: str | None = Field(default=None, description="Message ID")
|
||||
voice: str | None = Field(default=None, description="Voice to use for TTS")
|
||||
text: str | None = Field(default=None, description="Text to convert to audio")
|
||||
streaming: bool | None = Field(default=None, description="Enable streaming response")
|
||||
|
||||
@ -41,13 +41,3 @@ class NoFileUploadedError(BaseHTTPException):
|
||||
error_code = "no_file_uploaded"
|
||||
description = "Please upload your file."
|
||||
code = 400
|
||||
|
||||
|
||||
class NotFoundError(BaseHTTPException):
|
||||
error_code = "not_found"
|
||||
code = 404
|
||||
|
||||
|
||||
class InvalidArgumentError(BaseHTTPException):
|
||||
error_code = "invalid_param"
|
||||
code = 400
|
||||
|
||||
@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, RootModel, computed_field
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
from fields.base import ResponseModel
|
||||
from graphon.file import helpers as file_helpers
|
||||
@ -24,34 +24,6 @@ class SimpleResultResponse(ResponseModel):
|
||||
result: str
|
||||
|
||||
|
||||
class GeneratedAppResponse(RootModel[JSONValue]):
|
||||
root: JSONValue
|
||||
|
||||
|
||||
class EventStreamResponse(RootModel[str]):
|
||||
root: str
|
||||
|
||||
|
||||
class TextFileResponse(RootModel[str]):
|
||||
root: str
|
||||
|
||||
|
||||
class RedirectResponse(RootModel[str]):
|
||||
root: str
|
||||
|
||||
|
||||
class BinaryFileResponse(RootModel[bytes]):
|
||||
root: bytes
|
||||
|
||||
|
||||
class AudioBinaryResponse(RootModel[bytes]):
|
||||
root: bytes
|
||||
|
||||
|
||||
class AudioTranscriptResponse(ResponseModel):
|
||||
text: str
|
||||
|
||||
|
||||
class SimpleResultMessageResponse(ResponseModel):
|
||||
result: str
|
||||
message: str
|
||||
|
||||
@ -35,23 +35,17 @@ class HumanInputFormSubmitPayload(BaseModel):
|
||||
),
|
||||
examples=[HUMAN_INPUT_FORM_INPUT_EXAMPLE],
|
||||
)
|
||||
action: str = Field(
|
||||
description=(
|
||||
"ID of the action button the recipient selected. Must match one of the `id` values from the form's "
|
||||
"`user_actions` list."
|
||||
)
|
||||
)
|
||||
action: str
|
||||
|
||||
|
||||
def stringify_form_default_values(values: dict[str, object]) -> dict[str, str]:
|
||||
"""Serialize default values into strings expected by human-input form clients."""
|
||||
result: dict[str, str] = {}
|
||||
for key, value in values.items():
|
||||
match value:
|
||||
case None:
|
||||
result[key] = ""
|
||||
case dict() | list():
|
||||
result[key] = json.dumps(value, ensure_ascii=False)
|
||||
case _:
|
||||
result[key] = str(value)
|
||||
if value is None:
|
||||
result[key] = ""
|
||||
elif isinstance(value, (dict, list)):
|
||||
result[key] = json.dumps(value, ensure_ascii=False)
|
||||
else:
|
||||
result[key] = str(value)
|
||||
return result
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
"""Helpers for registering Pydantic models with Flask-RESTX namespaces.
|
||||
|
||||
Flask-RESTX treats `SchemaModel` bodies as opaque JSON schemas; it does not
|
||||
promote Pydantic's nested `$defs` into top-level OpenAPI component schemas.
|
||||
promote Pydantic's nested `$defs` into top-level Swagger `definitions`.
|
||||
These helpers keep that translation centralized so models registered through
|
||||
`register_schema_models` emit resolvable OpenAPI 3 references.
|
||||
`register_schema_models` emit resolvable Swagger 2.0 references.
|
||||
"""
|
||||
|
||||
from collections.abc import Iterable, Mapping
|
||||
@ -14,7 +14,7 @@ from flask import request
|
||||
from flask_restx import Namespace
|
||||
from pydantic import BaseModel, TypeAdapter
|
||||
|
||||
DEFAULT_REF_TEMPLATE_OPENAPI_3_0 = "#/components/schemas/{model}"
|
||||
DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}"
|
||||
|
||||
|
||||
QueryParamDoc = TypedDict(
|
||||
@ -27,18 +27,12 @@ QueryParamDoc = TypedDict(
|
||||
"description": NotRequired[str],
|
||||
"enum": NotRequired[list[object]],
|
||||
"default": NotRequired[object],
|
||||
"format": NotRequired[str],
|
||||
"minimum": NotRequired[int | float],
|
||||
"maximum": NotRequired[int | float],
|
||||
"exclusiveMinimum": NotRequired[int | float],
|
||||
"exclusiveMaximum": NotRequired[int | float],
|
||||
"minLength": NotRequired[int],
|
||||
"maxLength": NotRequired[int],
|
||||
"pattern": NotRequired[str],
|
||||
"minItems": NotRequired[int],
|
||||
"maxItems": NotRequired[int],
|
||||
"uniqueItems": NotRequired[bool],
|
||||
"multipleOf": NotRequired[int | float],
|
||||
},
|
||||
)
|
||||
|
||||
@ -54,6 +48,7 @@ class QueryArgs(Protocol):
|
||||
def _register_json_schema(namespace: Namespace, name: str, schema: dict) -> None:
|
||||
"""Register a JSON schema and promote any nested Pydantic `$defs`."""
|
||||
|
||||
schema = _swagger_2_compatible_schema(schema)
|
||||
nested_definitions = schema.get("$defs")
|
||||
schema_to_register = dict(schema)
|
||||
if isinstance(nested_definitions, dict):
|
||||
@ -76,12 +71,41 @@ def _register_schema_model(namespace: Namespace, model: type[BaseModel], *, mode
|
||||
_register_json_schema(
|
||||
namespace,
|
||||
model.__name__,
|
||||
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0, mode=mode),
|
||||
model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0, mode=mode),
|
||||
)
|
||||
|
||||
|
||||
def _swagger_2_compatible_schema(value: Any) -> Any:
|
||||
if isinstance(value, list):
|
||||
return [_swagger_2_compatible_schema(item) for item in value]
|
||||
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
|
||||
converted = {key: _swagger_2_compatible_schema(child) for key, child in value.items()}
|
||||
any_of = value.get("anyOf")
|
||||
if not isinstance(any_of, list):
|
||||
return converted
|
||||
|
||||
non_null_candidates = [
|
||||
candidate for candidate in any_of if isinstance(candidate, Mapping) and candidate.get("type") != "null"
|
||||
]
|
||||
has_null_candidate = any(isinstance(candidate, Mapping) and candidate.get("type") == "null" for candidate in any_of)
|
||||
if not has_null_candidate or len(non_null_candidates) != 1:
|
||||
return converted
|
||||
|
||||
non_null_schema = _swagger_2_compatible_schema(dict(non_null_candidates[0]))
|
||||
if not isinstance(non_null_schema, dict):
|
||||
return converted
|
||||
|
||||
converted.pop("anyOf", None)
|
||||
converted.update(non_null_schema)
|
||||
converted["x-nullable"] = True
|
||||
return converted
|
||||
|
||||
|
||||
def register_schema_model(namespace: Namespace, model: type[BaseModel]) -> None:
|
||||
"""Register a BaseModel and its nested component schemas for OpenAPI documentation."""
|
||||
"""Register a BaseModel and its nested schema definitions for Swagger documentation."""
|
||||
|
||||
_register_schema_model(namespace, model, mode="validation")
|
||||
|
||||
@ -122,7 +146,7 @@ def register_enum_models(namespace: Namespace, *models: type[StrEnum]) -> None:
|
||||
_register_json_schema(
|
||||
namespace,
|
||||
model.__name__,
|
||||
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
|
||||
TypeAdapter(model).json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0),
|
||||
)
|
||||
|
||||
|
||||
@ -131,11 +155,10 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
|
||||
|
||||
`Namespace.expect()` treats Pydantic schema models as request bodies, so GET
|
||||
endpoints should keep runtime validation on the Pydantic model and feed this
|
||||
derived mapping to `Namespace.doc(params=...)` for OpenAPI documentation.
|
||||
derived mapping to `Namespace.doc(params=...)` for Swagger documentation.
|
||||
"""
|
||||
|
||||
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0)
|
||||
definitions = _schema_definitions(schema)
|
||||
schema = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)
|
||||
properties = schema.get("properties", {})
|
||||
if not isinstance(properties, Mapping):
|
||||
return {}
|
||||
@ -148,11 +171,7 @@ def query_params_from_model(model: type[BaseModel]) -> dict[str, QueryParamDoc]:
|
||||
if not isinstance(name, str) or not isinstance(property_schema, Mapping):
|
||||
continue
|
||||
|
||||
params[name] = _query_param_from_property(
|
||||
property_schema,
|
||||
required=name in required_names,
|
||||
definitions=definitions,
|
||||
)
|
||||
params[name] = _query_param_from_property(property_schema, required=name in required_names)
|
||||
|
||||
return params
|
||||
|
||||
@ -184,7 +203,7 @@ def query_params_from_request[ModelT: BaseModel](
|
||||
|
||||
|
||||
def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dict[str, Any]) -> None:
|
||||
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0).get("properties", {})
|
||||
properties = model.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0).get("properties", {})
|
||||
if not isinstance(properties, Mapping):
|
||||
return
|
||||
|
||||
@ -209,18 +228,8 @@ def _drop_malformed_defaulted_integer_params(model: type[BaseModel], params: dic
|
||||
params.pop(name)
|
||||
|
||||
|
||||
def _schema_definitions(schema: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
definitions = schema.get("$defs")
|
||||
return definitions if isinstance(definitions, Mapping) else {}
|
||||
|
||||
|
||||
def _query_param_from_property(
|
||||
property_schema: Mapping[str, Any],
|
||||
*,
|
||||
required: bool,
|
||||
definitions: Mapping[str, Any],
|
||||
) -> QueryParamDoc:
|
||||
param_schema = _resolve_schema_ref(_nullable_property_schema(property_schema), definitions)
|
||||
def _query_param_from_property(property_schema: Mapping[str, Any], *, required: bool) -> QueryParamDoc:
|
||||
param_schema = _nullable_property_schema(property_schema)
|
||||
param_doc: QueryParamDoc = {"in": "query", "required": required}
|
||||
|
||||
description = param_schema.get("description")
|
||||
@ -233,16 +242,9 @@ def _query_param_from_property(
|
||||
if schema_type == "array":
|
||||
items = param_schema.get("items")
|
||||
if isinstance(items, Mapping):
|
||||
item_schema = _resolve_schema_ref(items, definitions)
|
||||
item_type = item_schema.get("type")
|
||||
item_type = items.get("type")
|
||||
if isinstance(item_type, str):
|
||||
param_doc["items"] = {"type": item_type}
|
||||
item_enum = item_schema.get("enum")
|
||||
if isinstance(item_enum, list):
|
||||
param_doc.setdefault("items", {})["enum"] = item_enum
|
||||
item_format = item_schema.get("format")
|
||||
if isinstance(item_format, str):
|
||||
param_doc.setdefault("items", {})["format"] = item_format
|
||||
|
||||
enum = param_schema.get("enum")
|
||||
if isinstance(enum, list):
|
||||
@ -252,10 +254,6 @@ def _query_param_from_property(
|
||||
if default is not None:
|
||||
param_doc["default"] = default
|
||||
|
||||
schema_format = param_schema.get("format")
|
||||
if isinstance(schema_format, str):
|
||||
param_doc["format"] = schema_format
|
||||
|
||||
minimum = param_schema.get("minimum")
|
||||
if isinstance(minimum, int | float):
|
||||
param_doc["minimum"] = minimum
|
||||
@ -264,14 +262,6 @@ def _query_param_from_property(
|
||||
if isinstance(maximum, int | float):
|
||||
param_doc["maximum"] = maximum
|
||||
|
||||
exclusive_minimum = param_schema.get("exclusiveMinimum")
|
||||
if isinstance(exclusive_minimum, int | float):
|
||||
param_doc["exclusiveMinimum"] = exclusive_minimum
|
||||
|
||||
exclusive_maximum = param_schema.get("exclusiveMaximum")
|
||||
if isinstance(exclusive_maximum, int | float):
|
||||
param_doc["exclusiveMaximum"] = exclusive_maximum
|
||||
|
||||
min_length = param_schema.get("minLength")
|
||||
if isinstance(min_length, int):
|
||||
param_doc["minLength"] = min_length
|
||||
@ -280,10 +270,6 @@ def _query_param_from_property(
|
||||
if isinstance(max_length, int):
|
||||
param_doc["maxLength"] = max_length
|
||||
|
||||
pattern = param_schema.get("pattern")
|
||||
if isinstance(pattern, str):
|
||||
param_doc["pattern"] = pattern
|
||||
|
||||
min_items = param_schema.get("minItems")
|
||||
if isinstance(min_items, int):
|
||||
param_doc["minItems"] = min_items
|
||||
@ -292,31 +278,9 @@ def _query_param_from_property(
|
||||
if isinstance(max_items, int):
|
||||
param_doc["maxItems"] = max_items
|
||||
|
||||
unique_items = param_schema.get("uniqueItems")
|
||||
if isinstance(unique_items, bool):
|
||||
param_doc["uniqueItems"] = unique_items
|
||||
|
||||
multiple_of = param_schema.get("multipleOf")
|
||||
if isinstance(multiple_of, int | float):
|
||||
param_doc["multipleOf"] = multiple_of
|
||||
|
||||
return param_doc
|
||||
|
||||
|
||||
def _resolve_schema_ref(property_schema: Mapping[str, Any], definitions: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
ref = property_schema.get("$ref")
|
||||
if not isinstance(ref, str):
|
||||
return property_schema
|
||||
|
||||
ref_name = ref.rsplit("/", 1)[-1]
|
||||
resolved = definitions.get(ref_name)
|
||||
if not isinstance(resolved, Mapping):
|
||||
return property_schema
|
||||
|
||||
property_without_ref = {key: value for key, value in property_schema.items() if key != "$ref"}
|
||||
return {**resolved, **property_without_ref}
|
||||
|
||||
|
||||
def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
any_of = property_schema.get("anyOf")
|
||||
if not isinstance(any_of, list):
|
||||
@ -333,7 +297,7 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_REF_TEMPLATE_OPENAPI_3_0",
|
||||
"DEFAULT_REF_TEMPLATE_SWAGGER_2_0",
|
||||
"get_or_create_model",
|
||||
"query_params_from_model",
|
||||
"query_params_from_request",
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
|
||||
__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"]
|
||||
|
||||
|
||||
def rbac_permission_required[**P, R](
|
||||
resource_type: RBACResourceScope,
|
||||
scene: RBACPermission,
|
||||
*,
|
||||
resource_required: bool = True,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""Check enterprise RBAC permissions for the current user.
|
||||
|
||||
Args:
|
||||
resource_type: The :class:`RBACResourceScope` member (app/dataset/workspace).
|
||||
scene: The :class:`RBACPermission` permission point.
|
||||
resource_required: Whether a concrete resource ID is required.
|
||||
"""
|
||||
|
||||
def decorator(view: Callable[P, R]) -> Callable[P, R]:
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
@ -53,8 +53,7 @@ from .app import (
|
||||
agent,
|
||||
agent_app_access,
|
||||
agent_app_feature,
|
||||
agent_app_sandbox,
|
||||
agent_drive_inspector,
|
||||
agent_app_workspace,
|
||||
annotation,
|
||||
app,
|
||||
audio,
|
||||
@ -123,7 +122,6 @@ from .explore import (
|
||||
saved_message,
|
||||
trial,
|
||||
)
|
||||
from .snippets import snippet_workflow, snippet_workflow_draft_variable
|
||||
from .socketio import workflow as socketio_workflow
|
||||
|
||||
# Import tag controllers
|
||||
@ -139,7 +137,6 @@ from .workspace import (
|
||||
model_providers,
|
||||
models,
|
||||
plugin,
|
||||
snippets,
|
||||
tool_providers,
|
||||
trigger_providers,
|
||||
workspace,
|
||||
@ -154,9 +151,8 @@ __all__ = [
|
||||
"agent",
|
||||
"agent_app_access",
|
||||
"agent_app_feature",
|
||||
"agent_app_sandbox",
|
||||
"agent_app_workspace",
|
||||
"agent_composer",
|
||||
"agent_drive_inspector",
|
||||
"agent_providers",
|
||||
"agent_roster",
|
||||
"annotation",
|
||||
@ -216,9 +212,6 @@ __all__ = [
|
||||
"saved_message",
|
||||
"setup",
|
||||
"site",
|
||||
"snippet_workflow",
|
||||
"snippet_workflow_draft_variable",
|
||||
"snippets",
|
||||
"socketio_workflow",
|
||||
"spec",
|
||||
"statistic",
|
||||
|
||||
@ -1,10 +0,0 @@
|
||||
from uuid import UUID
|
||||
|
||||
from extensions.ext_database import db
|
||||
from models.model import App
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
def resolve_agent_app_model(*, tenant_id: str, agent_id: UUID) -> App:
|
||||
"""Resolve the hidden Agent App backing an Agent Console resource."""
|
||||
return AgentRosterService(db.session).get_agent_app_model(tenant_id=tenant_id, agent_id=str(agent_id))
|
||||
@ -1,10 +1,7 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
@ -38,10 +35,6 @@ register_response_schema_models(
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_id(*, tenant_id: str, agent_id: UUID) -> str:
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id).id
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer")
|
||||
class WorkflowAgentComposerApi(Resource):
|
||||
@console_ns.response(
|
||||
@ -97,18 +90,10 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=AgentComposerService.resolve_workflow_node_agent_id(
|
||||
tenant_id=tenant_id, app_id=app_model.id, node_id=node_id
|
||||
),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
|
||||
@ -120,17 +105,10 @@ class WorkflowAgentComposerCandidatesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
|
||||
def get(self, app_model: App, node_id: str):
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_workflow_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
user_id=current_user_id,
|
||||
),
|
||||
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
|
||||
@ -183,18 +161,18 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/composer")
|
||||
class AgentComposerApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer")
|
||||
class AgentAppComposerApi(Resource):
|
||||
@console_ns.response(200, "Agent app composer state", console_ns.models[AgentAppComposerResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
def get(self, tenant_id: str, app_model: App):
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id),
|
||||
AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id),
|
||||
)
|
||||
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@ -203,24 +181,24 @@ class AgentComposerApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model()
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
def put(self, tenant_id: str, account_id: str, app_model: App):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
AgentAppComposerResponse,
|
||||
AgentComposerService.save_agent_app_composer(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
app_id=app_model.id,
|
||||
account_id=account_id,
|
||||
payload=payload,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/composer/validate")
|
||||
class AgentComposerValidateApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer/validate")
|
||||
class AgentAppComposerValidateApi(Resource):
|
||||
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
|
||||
@console_ns.response(
|
||||
200, "Agent app composer validation result", console_ns.models[AgentComposerValidateResponse.__name__]
|
||||
@ -228,36 +206,24 @@ class AgentComposerValidateApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
@get_app_model()
|
||||
def post(self, app_model: App):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
findings = AgentComposerService.collect_validation_findings(
|
||||
tenant_id=tenant_id,
|
||||
payload=payload,
|
||||
agent_id=str(agent_id),
|
||||
)
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
|
||||
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/composer/candidates")
|
||||
class AgentComposerCandidatesApi(Resource):
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
|
||||
class AgentAppComposerCandidatesApi(Resource):
|
||||
@console_ns.response(
|
||||
200, "Agent app composer candidates", console_ns.models[AgentComposerCandidatesResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user_id: str, agent_id: UUID):
|
||||
app_id = _resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
return dump_response(
|
||||
AgentComposerCandidatesResponse,
|
||||
AgentComposerService.get_agent_app_candidates(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_id,
|
||||
user_id=current_user_id,
|
||||
),
|
||||
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
|
||||
)
|
||||
|
||||
@ -1,65 +1,30 @@
|
||||
from uuid import UUID
|
||||
|
||||
from flask import abort, request
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import AliasChoices, BaseModel, Field, field_validator
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.app import (
|
||||
AppDetailWithSite as GenericAppDetailWithSite,
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
AppListQuery,
|
||||
CopyAppPayload,
|
||||
_normalize_app_list_query_args,
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
AppPagination as GenericAppPagination,
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
AppPartial as GenericAppPartial,
|
||||
)
|
||||
from controllers.console.app.app import (
|
||||
UpdateAppPayload as GenericUpdateAppPayload,
|
||||
)
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
enterprise_license_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
with_current_user_id,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.agent_fields import (
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentLogMessageListResponse,
|
||||
AgentLogSourceListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
AgentRosterResponse,
|
||||
)
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import dump_response
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.model import IconType
|
||||
from services.agent.errors import AgentNotFoundError
|
||||
from services.agent.observability_service import (
|
||||
AgentLogQueryParams,
|
||||
AgentObservabilityService,
|
||||
AgentStatisticsQueryParams,
|
||||
)
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.agent_entities import RosterListQuery
|
||||
from services.feature_service import FeatureService
|
||||
from services.entities.agent_entities import RosterAgentCreatePayload, RosterAgentUpdatePayload, RosterListQuery
|
||||
|
||||
|
||||
class AgentInviteOptionsQuery(RosterListQuery):
|
||||
@ -70,166 +35,21 @@ class AgentIdPath(BaseModel):
|
||||
agent_id: str
|
||||
|
||||
|
||||
class AgentAppCreatePayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, description="Agent name")
|
||||
description: str | None = Field(default=None, description="Agent description (max 400 chars)", max_length=400)
|
||||
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, value: str) -> str:
|
||||
role = value.strip()
|
||||
if not role:
|
||||
raise ValueError("Agent role is required.")
|
||||
return role
|
||||
|
||||
|
||||
# Keep agent-app roster DTOs agent-specific instead of reusing the shared
|
||||
# /apps response/request models. The roster surface needs Agent-only fields such
|
||||
# as `role`, while the generic console/apps contracts must stay unchanged.
|
||||
class AgentAppUpdatePayload(GenericUpdateAppPayload):
|
||||
role: str = Field(..., min_length=1, description="Agent role", max_length=255)
|
||||
|
||||
@field_validator("role")
|
||||
@classmethod
|
||||
def validate_role(cls, value: str) -> str:
|
||||
role = value.strip()
|
||||
if not role:
|
||||
raise ValueError("Agent role is required.")
|
||||
return role
|
||||
|
||||
|
||||
class AgentAppPublishedReferenceResponse(BaseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
app_icon_type: str | None = None
|
||||
app_icon: str | None = None
|
||||
app_icon_background: str | None = None
|
||||
|
||||
|
||||
class AgentLogsQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size")
|
||||
keyword: str | None = Field(default=None, description="Search query, answer, or conversation name")
|
||||
status: str | None = Field(default=None, description="Deprecated single status filter")
|
||||
statuses: list[str] = Field(default_factory=list, description="Filter by one or more of success, failed, paused")
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
description="Deprecated single source filter",
|
||||
)
|
||||
sources: list[str] = Field(
|
||||
default_factory=list,
|
||||
description=(
|
||||
"Filter by one or more source IDs, e.g. webapp:<app_id> "
|
||||
"or workflow:<app_id>:<workflow_id>:<version>:<node_id>"
|
||||
),
|
||||
)
|
||||
sort_by: str = Field(default="updated_at", description="Sort by created_at or updated_at")
|
||||
sort_order: str = Field(default="desc", description="Sort order: asc or desc")
|
||||
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
|
||||
|
||||
@field_validator("statuses", "sources", mode="before")
|
||||
@classmethod
|
||||
def empty_list_values_to_list(cls, value: object) -> list[str]:
|
||||
if value in (None, ""):
|
||||
return []
|
||||
if isinstance(value, str):
|
||||
return [value]
|
||||
if isinstance(value, list):
|
||||
return [item for item in value if item]
|
||||
return []
|
||||
|
||||
@field_validator("sort_by")
|
||||
@classmethod
|
||||
def validate_sort_by(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"created_at", "updated_at"}:
|
||||
raise ValueError("sort_by must be created_at or updated_at")
|
||||
return normalized
|
||||
|
||||
@field_validator("sort_order")
|
||||
@classmethod
|
||||
def validate_sort_order(cls, value: str) -> str:
|
||||
normalized = value.strip().lower()
|
||||
if normalized not in {"asc", "desc"}:
|
||||
raise ValueError("sort_order must be asc or desc")
|
||||
return normalized
|
||||
|
||||
|
||||
class AgentStatisticsQuery(BaseModel):
|
||||
source: str | None = Field(
|
||||
default=None,
|
||||
description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger",
|
||||
)
|
||||
start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)")
|
||||
end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)")
|
||||
|
||||
@field_validator("source", "start", "end", mode="before")
|
||||
@classmethod
|
||||
def empty_string_to_none(cls, value: str | None) -> str | None:
|
||||
if value == "":
|
||||
return None
|
||||
return value
|
||||
|
||||
|
||||
class AgentAppPartial(GenericAppPartial):
|
||||
app_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
published_reference_count: int = 0
|
||||
published_references: list[AgentAppPublishedReferenceResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentAppDetailWithSite(GenericAppDetailWithSite):
|
||||
app_id: str | None = None
|
||||
role: str | None = None
|
||||
active_config_is_published: bool = False
|
||||
|
||||
|
||||
class AgentAppPagination(GenericAppPagination):
|
||||
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
|
||||
validation_alias=AliasChoices("items", "data")
|
||||
)
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AgentAppCreatePayload,
|
||||
AgentAppUpdatePayload,
|
||||
CopyAppPayload,
|
||||
AgentInviteOptionsQuery,
|
||||
AgentLogsQuery,
|
||||
AgentStatisticsQuery,
|
||||
AgentIdPath,
|
||||
AppListQuery,
|
||||
RosterAgentCreatePayload,
|
||||
RosterAgentUpdatePayload,
|
||||
RosterListQuery,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentAppPagination,
|
||||
AgentAppPublishedReferenceResponse,
|
||||
AgentAppDetailWithSite,
|
||||
AgentAppPartial,
|
||||
AgentConfigSnapshotDetailResponse,
|
||||
AgentConfigSnapshotListResponse,
|
||||
AgentInviteOptionsResponse,
|
||||
AgentLogListResponse,
|
||||
AgentLogMessageListResponse,
|
||||
AgentLogSourceListResponse,
|
||||
AgentPublishedReferenceResponse,
|
||||
AgentRosterListResponse,
|
||||
AgentStatisticSummaryEnvelopeResponse,
|
||||
AgentRosterResponse,
|
||||
)
|
||||
|
||||
|
||||
@ -237,249 +57,42 @@ def _agent_roster_service() -> AgentRosterService:
|
||||
return AgentRosterService(db.session)
|
||||
|
||||
|
||||
def _serialize_agent_app_detail(app_model) -> dict:
|
||||
"""Serialize an Agent App detail using roster-only DTOs.
|
||||
|
||||
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
|
||||
becomes the backing roster Agent id, `app_id` carries the underlying App
|
||||
id, and `role` is injected from the backing roster Agent. Keeping that
|
||||
remap in this serializer lets generated console/agent contracts expose the
|
||||
roster persona fields without widening the shared /apps detail schema.
|
||||
"""
|
||||
|
||||
app_model = AppService().get_app(app_model)
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
|
||||
|
||||
roster_service = _agent_roster_service()
|
||||
payload = AgentAppDetailWithSite.model_validate(app_model, from_attributes=True).model_dump(mode="json")
|
||||
agent = roster_service.get_app_backing_agent(tenant_id=app_model.tenant_id, app_id=str(app_model.id))
|
||||
if not agent:
|
||||
raise AgentNotFoundError()
|
||||
payload.pop("bound_agent_id", None)
|
||||
payload["app_id"] = str(app_model.id)
|
||||
payload["id"] = agent.id
|
||||
payload["role"] = agent.role or ""
|
||||
payload["active_config_is_published"] = roster_service.active_config_is_published(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent=agent,
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
|
||||
"""Serialize Agent App lists with roster-shaped items.
|
||||
|
||||
Each item starts from the shared App list shape, then drops
|
||||
`bound_agent_id`, rewrites `id` to the backing roster Agent id, stores the
|
||||
original App id in `app_id`, and injects roster-only `role` when a backing
|
||||
Agent is present.
|
||||
"""
|
||||
|
||||
app_ids = [str(app.id) for app in app_pagination.items]
|
||||
roster_service = _agent_roster_service()
|
||||
agents_by_app_id = roster_service.load_app_backing_agents_by_app_id(
|
||||
tenant_id=tenant_id,
|
||||
app_ids=app_ids,
|
||||
)
|
||||
active_config_is_published_by_agent_id = roster_service.load_active_config_is_published_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agents=list(agents_by_app_id.values()),
|
||||
)
|
||||
published_references_by_agent_id = roster_service.load_published_references_by_agent_id(
|
||||
tenant_id=tenant_id,
|
||||
agent_ids=[agent.id for agent in agents_by_app_id.values()],
|
||||
)
|
||||
payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
|
||||
for item in payload["data"]:
|
||||
app_id = item["id"]
|
||||
item.pop("bound_agent_id", None)
|
||||
agent = agents_by_app_id.get(app_id)
|
||||
if agent:
|
||||
item["app_id"] = app_id
|
||||
item["id"] = agent.id
|
||||
item["role"] = agent.role or ""
|
||||
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
|
||||
published_references = published_references_by_agent_id.get(agent.id, [])
|
||||
item["published_reference_count"] = len(published_references)
|
||||
item["published_references"] = [
|
||||
{
|
||||
"app_id": reference["app_id"],
|
||||
"app_name": reference["app_name"],
|
||||
"app_icon_type": reference["app_icon_type"],
|
||||
"app_icon": reference["app_icon"],
|
||||
"app_icon_background": reference["app_icon_background"],
|
||||
}
|
||||
for reference in published_references
|
||||
]
|
||||
return AgentAppPagination.model_validate(payload).model_dump(
|
||||
mode="json",
|
||||
exclude={"data": {"__all__": {"bound_agent_id"}}},
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
|
||||
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
|
||||
|
||||
def _agent_observability_service() -> AgentObservabilityService:
|
||||
return AgentObservabilityService(db.session)
|
||||
|
||||
|
||||
def _parse_observability_time_range(start: str | None, end: str | None, account: Account):
|
||||
timezone = account.timezone or "UTC"
|
||||
try:
|
||||
return parse_time_range(start, end, timezone)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
|
||||
|
||||
def _multi_query_values(name: str, legacy_name: str | None = None) -> list[str]:
|
||||
values: list[str] = []
|
||||
for query_name in (name, f"{name}[]"):
|
||||
values.extend(request.args.getlist(query_name))
|
||||
if legacy_name:
|
||||
values.extend(request.args.getlist(legacy_name))
|
||||
parsed: list[str] = []
|
||||
for value in values:
|
||||
parsed.extend(item.strip() for item in value.split(",") if item.strip())
|
||||
return parsed
|
||||
|
||||
|
||||
@console_ns.route("/agent")
|
||||
class AgentAppListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.response(200, "Agent app list", console_ns.models[AgentAppPagination.__name__])
|
||||
@console_ns.route("/agents")
|
||||
class AgentRosterListApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(RosterListQuery))
|
||||
@console_ns.response(200, "Agent roster list", console_ns.models[AgentRosterListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account):
|
||||
args = AppListQuery.model_validate(_normalize_app_list_query_args(request.args))
|
||||
params = AppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode="agent",
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
status="normal",
|
||||
def get(self, tenant_id: str):
|
||||
query = RosterListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
return dump_response(
|
||||
AgentRosterListResponse,
|
||||
_agent_roster_service().list_roster_agents(
|
||||
tenant_id=tenant_id, page=query.page, limit=query.limit, keyword=query.keyword
|
||||
),
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_apps(current_user.id, current_tenant_id, params, db.session)
|
||||
if app_pagination is None:
|
||||
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json")
|
||||
|
||||
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.expect(console_ns.models[RosterAgentCreatePayload.__name__])
|
||||
@console_ns.response(201, "Agent created", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account):
|
||||
args = AgentAppCreatePayload.model_validate(console_ns.payload)
|
||||
params = CreateAppParams(
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
mode="agent",
|
||||
agent_role=args.role,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
|
||||
app = AppService().create_app(current_tenant_id, params, current_user)
|
||||
return _serialize_agent_app_detail(app), 201
|
||||
def post(self, tenant_id: str, account_id: str):
|
||||
payload = RosterAgentCreatePayload.model_validate(console_ns.payload or {})
|
||||
service = _agent_roster_service()
|
||||
agent = service.create_roster_agent(tenant_id=tenant_id, account_id=account_id, payload=payload)
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
service.get_roster_agent_detail(tenant_id=tenant_id, agent_id=agent.id),
|
||||
), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>")
|
||||
class AgentAppApi(Resource):
|
||||
@console_ns.response(200, "Agent app detail", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _serialize_agent_app_detail(app_model)
|
||||
|
||||
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def put(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
|
||||
args_dict: AppService.ArgsDict = {
|
||||
"name": args.name,
|
||||
"description": args.description or "",
|
||||
"icon_type": args.icon_type,
|
||||
"icon": args.icon or "",
|
||||
"icon_background": args.icon_background or "",
|
||||
"use_icon_as_answer_icon": args.use_icon_as_answer_icon or False,
|
||||
"max_active_requests": args.max_active_requests or 0,
|
||||
"role": args.role,
|
||||
}
|
||||
updated = AppService().update_app(app_model, args_dict)
|
||||
return _serialize_agent_app_detail(updated)
|
||||
|
||||
@console_ns.response(204, "Agent app deleted successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
AppService().delete_app(app_model)
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/copy")
|
||||
class AgentAppCopyApi(Resource):
|
||||
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
|
||||
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
args = CopyAppPayload.model_validate(console_ns.payload or {})
|
||||
copied_app = _agent_roster_service().duplicate_agent_app(
|
||||
tenant_id=tenant_id,
|
||||
agent_id=str(agent_id),
|
||||
account=current_user,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
icon_type=args.icon_type,
|
||||
icon=args.icon,
|
||||
icon_background=args.icon_background,
|
||||
)
|
||||
return _serialize_agent_app_detail(copied_app), 201
|
||||
|
||||
|
||||
@console_ns.route("/agent/invite-options")
|
||||
@console_ns.route("/agents/invite-options")
|
||||
class AgentInviteOptionsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentInviteOptionsQuery))
|
||||
@console_ns.response(200, "Agent invite options", console_ns.models[AgentInviteOptionsResponse.__name__])
|
||||
@ -501,125 +114,49 @@ class AgentInviteOptionsApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/logs")
|
||||
class AgentLogsApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
|
||||
@console_ns.response(200, "Agent logs", console_ns.models[AgentLogListResponse.__name__])
|
||||
@console_ns.route("/agents/<uuid:agent_id>")
|
||||
class AgentRosterDetailApi(Resource):
|
||||
@console_ns.response(200, "Agent detail", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
query = AgentLogsQuery.model_validate(query_data)
|
||||
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,
|
||||
statuses=tuple(query.statuses),
|
||||
sources=tuple(query.sources),
|
||||
sort_by=query.sort_by,
|
||||
sort_order=query.sort_order,
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return dump_response(AgentLogListResponse, payload)
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
_agent_roster_service().get_roster_agent_detail(tenant_id=tenant_id, agent_id=str(agent_id)),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/logs/<uuid:conversation_id>/messages")
|
||||
class AgentLogMessagesApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentLogsQuery))
|
||||
@console_ns.response(200, "Agent log messages", console_ns.models[AgentLogMessageListResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[RosterAgentUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Agent updated", console_ns.models[AgentRosterResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query_data: dict[str, object] = dict(request.args.to_dict(flat=True))
|
||||
query_data["sources"] = _multi_query_values("sources", "source")
|
||||
query_data["statuses"] = _multi_query_values("statuses", "status")
|
||||
query = AgentLogsQuery.model_validate(query_data)
|
||||
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,
|
||||
statuses=tuple(query.statuses),
|
||||
sources=tuple(query.sources),
|
||||
sort_by=query.sort_by,
|
||||
sort_order=query.sort_order,
|
||||
start=start,
|
||||
end=end,
|
||||
),
|
||||
)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return dump_response(AgentLogMessageListResponse, payload)
|
||||
def patch(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
payload = RosterAgentUpdatePayload.model_validate(console_ns.payload or {})
|
||||
return dump_response(
|
||||
AgentRosterResponse,
|
||||
_agent_roster_service().update_roster_agent(
|
||||
tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id, payload=payload
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/log-sources")
|
||||
class AgentLogSourcesApi(Resource):
|
||||
@console_ns.response(200, "Agent log sources", console_ns.models[AgentLogSourceListResponse.__name__])
|
||||
@console_ns.response(204, "Agent archived")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = _agent_observability_service().list_log_sources(app=app_model, agent_id=str(agent_id))
|
||||
return dump_response(AgentLogSourceListResponse, payload)
|
||||
def delete(self, tenant_id: str, account_id: str, agent_id: UUID):
|
||||
_agent_roster_service().archive_roster_agent(tenant_id=tenant_id, agent_id=str(agent_id), account_id=account_id)
|
||||
return "", 204
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/statistics/summary")
|
||||
class AgentStatisticsSummaryApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(AgentStatisticsQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Agent monitoring summary and chart data",
|
||||
console_ns.models[AgentStatisticSummaryEnvelopeResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = AgentStatisticsQuery.model_validate(request.args.to_dict(flat=True))
|
||||
timezone = current_user.timezone or "UTC"
|
||||
start, end = _parse_observability_time_range(query.start, query.end, current_user)
|
||||
try:
|
||||
payload = _agent_observability_service().get_statistics_summary(
|
||||
app=app_model,
|
||||
agent_id=str(agent_id),
|
||||
params=AgentStatisticsQueryParams(source=query.source, start=start, end=end, timezone=timezone),
|
||||
)
|
||||
except ValueError as exc:
|
||||
abort(400, description=str(exc))
|
||||
return dump_response(AgentStatisticSummaryEnvelopeResponse, payload)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/versions")
|
||||
@console_ns.route("/agents/<uuid:agent_id>/versions")
|
||||
class AgentRosterVersionsApi(Resource):
|
||||
@console_ns.response(200, "Agent versions", console_ns.models[AgentConfigSnapshotListResponse.__name__])
|
||||
@setup_required
|
||||
@ -633,7 +170,7 @@ class AgentRosterVersionsApi(Resource):
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/versions/<uuid:version_id>")
|
||||
@console_ns.route("/agents/<uuid:agent_id>/versions/<uuid:version_id>")
|
||||
class AgentRosterVersionDetailApi(Resource):
|
||||
@console_ns.response(200, "Agent version detail", console_ns.models[AgentConfigSnapshotDetailResponse.__name__])
|
||||
@setup_required
|
||||
|
||||
@ -1,17 +1,9 @@
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import (
|
||||
DEFAULT_REF_TEMPLATE_OPENAPI_3_0,
|
||||
query_params_from_model,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from services.advanced_prompt_template_service import AdvancedPromptTemplateArgs, AdvancedPromptTemplateService
|
||||
|
||||
@ -23,27 +15,19 @@ class AdvancedPromptTemplateQuery(BaseModel):
|
||||
model_name: str = Field(..., description="Model name")
|
||||
|
||||
|
||||
class AdvancedPromptTemplateResponse(ResponseModel):
|
||||
chat_prompt_config: dict[str, Any] | None = Field(default=None)
|
||||
completion_prompt_config: dict[str, Any] | None = Field(default=None)
|
||||
|
||||
|
||||
console_ns.schema_model(
|
||||
AdvancedPromptTemplateQuery.__name__,
|
||||
AdvancedPromptTemplateQuery.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_OPENAPI_3_0),
|
||||
AdvancedPromptTemplateQuery.model_json_schema(ref_template="#/definitions/{model}"),
|
||||
)
|
||||
register_response_schema_models(console_ns, AdvancedPromptTemplateResponse)
|
||||
|
||||
|
||||
@console_ns.route("/app/prompt-templates")
|
||||
class AdvancedPromptTemplateList(Resource):
|
||||
@console_ns.doc("get_advanced_prompt_templates")
|
||||
@console_ns.doc(description="Get advanced prompt templates based on app mode and model configuration")
|
||||
@console_ns.doc(params=query_params_from_model(AdvancedPromptTemplateQuery))
|
||||
@console_ns.expect(console_ns.models[AdvancedPromptTemplateQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Prompt templates retrieved successfully",
|
||||
console_ns.models[AdvancedPromptTemplateResponse.__name__],
|
||||
200, "Prompt templates retrieved successfully", fields.List(fields.Raw(description="Prompt template data"))
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
|
||||
@ -1,63 +1,16 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account
|
||||
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
|
||||
from models.model import App, AppMode, UploadFile
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.skill_package_service import SkillManifest, SkillPackageError
|
||||
from services.agent.skill_standardize_service import SkillStandardizeService
|
||||
from services.agent.skill_tool_inference_service import (
|
||||
SkillToolInferenceError,
|
||||
SkillToolInferenceResult,
|
||||
SkillToolInferenceService,
|
||||
)
|
||||
from services.agent_drive_service import (
|
||||
AgentDriveError,
|
||||
AgentDriveService,
|
||||
DriveCommitItem,
|
||||
DriveFileRef,
|
||||
normalize_drive_key,
|
||||
)
|
||||
from models.model import App, AppMode
|
||||
from services.agent_service import AgentService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
_AGENT_SKILL_UPLOAD_PARAMS = {
|
||||
"file": {
|
||||
"in": "formData",
|
||||
"type": "file",
|
||||
"required": True,
|
||||
"description": "Skill package (.zip or .skill).",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class AgentLogQuery(BaseModel):
|
||||
message_id: str = Field(..., description="Message UUID")
|
||||
@ -69,275 +22,7 @@ class AgentLogQuery(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveFilePayload(BaseModel):
|
||||
upload_file_id: str = Field(..., description="UploadFile UUID from POST /console/api/files/upload")
|
||||
|
||||
@field_validator("upload_file_id")
|
||||
@classmethod
|
||||
def validate_upload_file_id(cls, value: str) -> str:
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AgentDriveMutationQuery(BaseModel):
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileQuery(AgentDriveMutationQuery):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentDriveDeleteFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. files/sample.pdf")
|
||||
|
||||
|
||||
class AgentLogMetaResponse(ResponseModel):
|
||||
status: str
|
||||
executor: str
|
||||
start_time: str
|
||||
elapsed_time: float | None = None
|
||||
total_tokens: int
|
||||
agent_mode: str
|
||||
iterations: int
|
||||
|
||||
|
||||
class AgentToolCallResponse(ResponseModel):
|
||||
status: str
|
||||
error: str | None = None
|
||||
time_cost: float | int
|
||||
tool_name: str
|
||||
tool_label: str
|
||||
tool_input: dict[str, Any]
|
||||
tool_output: dict[str, Any]
|
||||
tool_parameters: dict[str, Any]
|
||||
tool_icon: Any = Field(default=None)
|
||||
|
||||
|
||||
class AgentIterationLogResponse(ResponseModel):
|
||||
tokens: int
|
||||
tool_calls: list[AgentToolCallResponse]
|
||||
tool_raw: dict[str, Any]
|
||||
thought: str | None = None
|
||||
created_at: str
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentLogResponse(ResponseModel):
|
||||
meta: AgentLogMetaResponse
|
||||
iterations: list[AgentIterationLogResponse]
|
||||
files: list[Any] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentSkillUploadResponse(ResponseModel):
|
||||
skill: AgentSkillRefConfig
|
||||
manifest: SkillManifest
|
||||
|
||||
|
||||
class AgentDriveFileResponse(ResponseModel):
|
||||
name: str
|
||||
drive_key: str
|
||||
file_id: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
|
||||
|
||||
class AgentDriveFileCommitResponse(ResponseModel):
|
||||
file: AgentDriveFileResponse
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDeleteResponse(ResponseModel):
|
||||
result: str
|
||||
removed_keys: list[str] = Field(default_factory=list)
|
||||
config_version_id: str | None = None
|
||||
|
||||
|
||||
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AgentDriveDeleteResponse,
|
||||
AgentDriveFileCommitResponse,
|
||||
AgentDriveFileResponse,
|
||||
AgentLogResponse,
|
||||
AgentSkillUploadResponse,
|
||||
SkillToolInferenceResult,
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
|
||||
if node_id and app_model.mode != AppMode.AGENT:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, str], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _upload_skill_for_app(*, current_user: Account, app_model: App):
|
||||
"""Upload one skill package and commit its normalized files into the agent drive."""
|
||||
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "file" not in request.files:
|
||||
return {"code": "no_file", "message": "no skill file uploaded"}, 400
|
||||
if len(request.files) > 1:
|
||||
return {"code": "too_many_files", "message": "only one skill file is allowed"}, 400
|
||||
|
||||
upload = request.files["file"]
|
||||
content = upload.stream.read()
|
||||
try:
|
||||
result = SkillStandardizeService().standardize(
|
||||
content=content,
|
||||
filename=upload.filename or "",
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
)
|
||||
except (SkillPackageError, AgentDriveError) as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
return result, 201
|
||||
|
||||
|
||||
def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
payload = AgentDriveFilePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
upload_file = db.session.scalar(
|
||||
select(UploadFile).where(
|
||||
UploadFile.id == payload.upload_file_id,
|
||||
UploadFile.tenant_id == app_model.tenant_id,
|
||||
)
|
||||
)
|
||||
if upload_file is None:
|
||||
return {"code": "upload_file_not_found", "message": "upload file not found in this workspace"}, 404
|
||||
|
||||
try:
|
||||
key = normalize_drive_key(f"files/{upload_file.name}")
|
||||
committed = AgentDriveService().commit(
|
||||
tenant_id=app_model.tenant_id,
|
||||
user_id=current_user.id,
|
||||
agent_id=agent_id,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key=key,
|
||||
file_ref=DriveFileRef(kind="upload_file", id=upload_file.id),
|
||||
# ADD FILE uploads exist solely to live in the drive, so the
|
||||
# drive owns (and physically cleans) the value on delete.
|
||||
value_owned_by_drive=True,
|
||||
)
|
||||
],
|
||||
)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
row = committed[0]
|
||||
file_ref = AgentFileRefConfig.model_validate(
|
||||
{
|
||||
"id": row["key"],
|
||||
"name": upload_file.name,
|
||||
"file_id": upload_file.id,
|
||||
"drive_key": row["key"],
|
||||
"type": row.get("mime_type"),
|
||||
"size": row.get("size"),
|
||||
}
|
||||
)
|
||||
config_version_id = AgentComposerService.add_drive_file_ref(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_ref=file_ref,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
return {
|
||||
"file": {
|
||||
"name": upload_file.name,
|
||||
"drive_key": row["key"],
|
||||
"file_id": upload_file.id,
|
||||
"size": row.get("size"),
|
||||
"mime_type": row.get("mime_type"),
|
||||
},
|
||||
"config_version_id": config_version_id,
|
||||
}, 201
|
||||
|
||||
|
||||
def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveDeleteFileQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
key = normalize_drive_key(query.key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
file_key=key,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
# Soul-first ordering: the ref is already gone; orphan KV rows are
|
||||
# harmless and an idempotent DELETE retry cleans them.
|
||||
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
|
||||
|
||||
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
node_id = query.node_id if allow_node_id else None
|
||||
agent_id = _resolve_agent_id(app_model, node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
|
||||
config_version_id = AgentComposerService.remove_drive_refs(
|
||||
tenant_id=app_model.tenant_id,
|
||||
agent_id=agent_id,
|
||||
account_id=current_user.id,
|
||||
skill_slug=slug,
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
)
|
||||
removed_keys: list[str] = []
|
||||
try:
|
||||
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
|
||||
except AgentDriveError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
except Exception:
|
||||
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
|
||||
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
|
||||
|
||||
|
||||
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
|
||||
query = query_params_from_request(AgentDriveMutationQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
if "/" in slug or not slug.strip():
|
||||
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
|
||||
try:
|
||||
return SkillToolInferenceService().infer(tenant_id=app_model.tenant_id, agent_id=agent_id, slug=slug)
|
||||
except SkillToolInferenceError as exc:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
register_schema_models(console_ns, AgentLogQuery)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/logs")
|
||||
@ -345,8 +30,10 @@ class AgentLogApi(Resource):
|
||||
@console_ns.doc("get_agent_logs")
|
||||
@console_ns.doc(description="Get agent execution logs for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(AgentLogQuery))
|
||||
@console_ns.response(200, "Agent logs retrieved successfully", console_ns.models[AgentLogResponse.__name__])
|
||||
@console_ns.expect(console_ns.models[AgentLogQuery.__name__])
|
||||
@console_ns.response(
|
||||
200, "Agent logs retrieved successfully", fields.List(fields.Raw(description="Agent log entries"))
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -357,192 +44,3 @@ class AgentLogApi(Resource):
|
||||
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
return AgentService.get_agent_logs(app_model, args.conversation_id, args.message_id)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/upload")
|
||||
class AgentSkillUploadByAgentApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into an Agent App drive")
|
||||
@console_ns.doc(consumes=["multipart/form-data"], params={"agent_id": "Agent ID", **_AGENT_SKILL_UPLOAD_PARAMS})
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/upload")
|
||||
class AgentSkillUploadApi(Resource):
|
||||
@console_ns.doc("upload_agent_skill")
|
||||
@console_ns.doc(description="Upload + standardize a Skill into the agent drive")
|
||||
@console_ns.doc(
|
||||
consumes=["multipart/form-data"],
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
**_AGENT_SKILL_UPLOAD_PARAMS,
|
||||
},
|
||||
)
|
||||
@console_ns.response(201, "Skill uploaded into drive", console_ns.models[AgentSkillUploadResponse.__name__])
|
||||
@console_ns.response(400, "Invalid skill package or no bound agent")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""Upload a Skill, validate it, and commit drive-backed skill files."""
|
||||
return _upload_skill_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/files")
|
||||
class AgentDriveFilesByAgentApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Commit an uploaded file into the Agent App drive under files/<name>")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Delete one Agent App drive file by key")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveDeleteFileByAgentQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model, allow_node_id=False)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/files")
|
||||
class AgentDriveFilesApi(Resource):
|
||||
@console_ns.doc("commit_agent_drive_file")
|
||||
@console_ns.doc(description="Commit an uploaded file into the agent drive under files/<name> (ENG-625 D3)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveMutationQuery)})
|
||||
@console_ns.expect(console_ns.models[AgentDriveFilePayload.__name__])
|
||||
@console_ns.response(
|
||||
201, "File committed into the agent drive", console_ns.models[AgentDriveFileCommitResponse.__name__]
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
"""ADD FILE: commit one uploaded file into the bound agent's drive."""
|
||||
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
@console_ns.doc("delete_agent_drive_file")
|
||||
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
|
||||
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def delete(self, current_user: Account, app_model: App):
|
||||
return _delete_drive_file_for_app(current_user=current_user, app_model=app_model)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>")
|
||||
class AgentSkillByAgentApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill_by_agent")
|
||||
@console_ns.doc(description="Delete a standardized skill from an Agent App drive")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug, allow_node_id=False)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
|
||||
class AgentSkillApi(Resource):
|
||||
@console_ns.doc("delete_agent_skill")
|
||||
@console_ns.doc(
|
||||
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Skill removed", console_ns.models[AgentDriveDeleteResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
@with_current_user
|
||||
def delete(self, current_user: Account, app_model: App, slug: str):
|
||||
return _delete_skill_for_app(current_user=current_user, app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsByAgentApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools_by_agent")
|
||||
@console_ns.doc(description="Infer CLI tool + ENV suggestions from a standardized Agent App skill")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "slug": "Skill slug (single path segment)"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID, slug: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>/infer-tools")
|
||||
class AgentSkillInferToolsApi(Resource):
|
||||
@console_ns.doc("infer_agent_skill_tools")
|
||||
@console_ns.doc(
|
||||
description="Infer CLI tool + ENV suggestions from a standardized skill's SKILL.md (draft only, ENG-371)"
|
||||
)
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"slug": "Skill slug (single path segment)",
|
||||
**query_params_from_model(AgentDriveMutationQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Inference result (draft suggestions, nothing persisted)",
|
||||
console_ns.models[SkillToolInferenceResult.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_AGENT_DRIVE_APP_MODES)
|
||||
def post(self, app_model: App, slug: str):
|
||||
"""Suggest CLI tools/env for a skill. Saving still goes through composer validation."""
|
||||
return _infer_skill_tools_for_app(app_model=app_model, slug=slug)
|
||||
|
||||
@ -5,31 +5,25 @@ reference. This exposes the read-only "Workflow access" surface from the PRD:
|
||||
which workflow apps use this Agent, without leaking the workflows' internals.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import Field
|
||||
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.roster_service import AgentRosterService
|
||||
|
||||
|
||||
class AgentReferencingWorkflowResponse(ResponseModel):
|
||||
app_id: str
|
||||
app_name: str
|
||||
app_icon_type: str | None = None
|
||||
app_icon: str | None = None
|
||||
app_icon_background: str | None = None
|
||||
app_mode: str
|
||||
app_updated_at: int | None = None
|
||||
workflow_id: str
|
||||
workflow_version: str
|
||||
node_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@ -40,23 +34,23 @@ class AgentReferencingWorkflowsResponse(ResponseModel):
|
||||
register_response_schema_models(console_ns, AgentReferencingWorkflowsResponse)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/referencing-workflows")
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-referencing-workflows")
|
||||
class AgentAppReferencingWorkflowsResource(Resource):
|
||||
@console_ns.doc("list_agent_app_referencing_workflows")
|
||||
@console_ns.doc(description="List workflow apps that reference this Agent App's bound Agent (read-only)")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Referencing workflows listed successfully",
|
||||
console_ns.models[AgentReferencingWorkflowsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Agent not found")
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
def get(self, tenant_id: str, app_model: App):
|
||||
workflows = AgentRosterService(db.session).list_workflows_referencing_app_agent(
|
||||
tenant_id=tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
@ -9,20 +9,17 @@ persists them onto the app's ``app_model_config`` without touching anything the
|
||||
Soul owns.
|
||||
"""
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from events.app_event import app_model_config_was_updated
|
||||
@ -35,6 +32,7 @@ from models.agent_config_entities import (
|
||||
AgentSuggestedQuestionsAfterAnswerFeatureConfig,
|
||||
AgentTextToSpeechFeatureConfig,
|
||||
)
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_feature_service import AgentAppFeatureConfigService
|
||||
|
||||
|
||||
@ -66,23 +64,22 @@ register_schema_models(console_ns, AgentAppFeaturesPayload)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/features")
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-features")
|
||||
class AgentAppFeatureConfigResource(Resource):
|
||||
@console_ns.doc("update_agent_app_features")
|
||||
@console_ns.doc(description="Update an Agent App's presentation features (opener, follow-up, citations, ...)")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[AgentAppFeaturesPayload.__name__])
|
||||
@console_ns.response(200, "Features updated successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(400, "Invalid configuration")
|
||||
@console_ns.response(404, "Agent not found")
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@edit_permission_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
new_app_model_config = AgentAppFeatureConfigService.update_features(
|
||||
|
||||
@ -1,307 +0,0 @@
|
||||
"""Console routes for Agent App and workflow Agent sandbox file access.
|
||||
|
||||
The API keeps product-facing locators (conversation or workflow node identity)
|
||||
on this public boundary and proxies list/read/upload to the agent backend's new
|
||||
``/sandbox`` contract.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
AgentSandboxInspectorError,
|
||||
WorkflowAgentSandboxService,
|
||||
)
|
||||
|
||||
_NODE_EXECUTION_ID_DESCRIPTION = (
|
||||
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
|
||||
)
|
||||
|
||||
|
||||
class AgentSandboxListQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class AgentSandboxFileQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class AgentSandboxUploadPayload(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class WorkflowAgentSandboxListQuery(BaseModel):
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxFileQuery(BaseModel):
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentSandboxUploadPayload(BaseModel):
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=_NODE_EXECUTION_ID_DESCRIPTION,
|
||||
)
|
||||
|
||||
|
||||
class SandboxFileEntryResponse(ResponseModel):
|
||||
name: str
|
||||
type: Literal["file", "dir", "symlink", "other"]
|
||||
size: int | None = None
|
||||
mtime: int | None = None
|
||||
|
||||
|
||||
class SandboxListResponse(ResponseModel):
|
||||
path: str
|
||||
entries: list[SandboxFileEntryResponse] = Field(default_factory=list)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class SandboxReadResponse(ResponseModel):
|
||||
path: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class SandboxToolFileResponse(ResponseModel):
|
||||
transfer_method: Literal["tool_file"] = "tool_file"
|
||||
reference: str
|
||||
|
||||
|
||||
class SandboxUploadResponse(ResponseModel):
|
||||
path: str
|
||||
file: SandboxToolFileResponse
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AgentSandboxUploadPayload,
|
||||
WorkflowAgentSandboxUploadPayload,
|
||||
)
|
||||
register_response_schema_models(console_ns, SandboxListResponse, SandboxReadResponse, SandboxUploadResponse)
|
||||
|
||||
|
||||
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
|
||||
if isinstance(exc, AgentSandboxInspectorError):
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
if isinstance(exc, DifyAgentHTTPError):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
return {
|
||||
"code": detail.get("code", "agent_backend_error"),
|
||||
"message": detail.get("message", str(exc)),
|
||||
}, exc.status_code
|
||||
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
|
||||
if isinstance(exc, DifyAgentTimeoutError | DifyAgentClientError):
|
||||
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
|
||||
raise exc
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files")
|
||||
class AgentAppSandboxListResource(Resource):
|
||||
@console_ns.doc("list_agent_app_sandbox_files")
|
||||
@console_ns.doc(description="List a directory in an Agent App conversation sandbox")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxListQuery)})
|
||||
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxListQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/read")
|
||||
class AgentAppSandboxReadResource(Resource):
|
||||
@console_ns.doc("read_agent_app_sandbox_file")
|
||||
@console_ns.doc(description="Read a text/binary preview file in an Agent App conversation sandbox")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentSandboxFileQuery)})
|
||||
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
query = query_params_from_request(AgentSandboxFileQuery)
|
||||
try:
|
||||
result = AgentAppSandboxService().read_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/sandbox/files/upload")
|
||||
class AgentAppSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_agent_app_sandbox_file")
|
||||
@console_ns.doc(description="Upload one Agent App sandbox file as a Dify ToolFile mapping")
|
||||
@console_ns.expect(console_ns.models[AgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
payload = AgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = AgentAppSandboxService().upload_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=payload.conversation_id,
|
||||
path=payload.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files")
|
||||
class WorkflowAgentSandboxListResource(Resource):
|
||||
@console_ns.doc("list_workflow_agent_sandbox_files")
|
||||
@console_ns.doc(description="List a directory in a workflow Agent node sandbox")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"workflow_run_id": "Workflow run ID",
|
||||
"node_id": "Workflow Agent node ID",
|
||||
**query_params_from_model(WorkflowAgentSandboxListQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Listing returned", console_ns.models[SandboxListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
query = query_params_from_request(WorkflowAgentSandboxListQuery)
|
||||
try:
|
||||
result = WorkflowAgentSandboxService().list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=query.node_execution_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/read"
|
||||
)
|
||||
class WorkflowAgentSandboxReadResource(Resource):
|
||||
@console_ns.doc("read_workflow_agent_sandbox_file")
|
||||
@console_ns.doc(description="Read a text/binary preview file in a workflow Agent node sandbox")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"workflow_run_id": "Workflow run ID",
|
||||
"node_id": "Workflow Agent node ID",
|
||||
**query_params_from_model(WorkflowAgentSandboxFileQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Preview returned", console_ns.models[SandboxReadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
query = query_params_from_request(WorkflowAgentSandboxFileQuery)
|
||||
try:
|
||||
result = WorkflowAgentSandboxService().read_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=query.node_execution_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/sandbox/files/upload"
|
||||
)
|
||||
class WorkflowAgentSandboxUploadResource(Resource):
|
||||
@console_ns.doc("upload_workflow_agent_sandbox_file")
|
||||
@console_ns.doc(description="Upload one workflow Agent sandbox file as a Dify ToolFile mapping")
|
||||
@console_ns.expect(console_ns.models[WorkflowAgentSandboxUploadPayload.__name__])
|
||||
@console_ns.response(200, "Uploaded", console_ns.models[SandboxUploadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant_id: str, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
payload = WorkflowAgentSandboxUploadPayload.model_validate(request.get_json(silent=True) or {})
|
||||
try:
|
||||
result = WorkflowAgentSandboxService().upload_file(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=payload.node_execution_id,
|
||||
path=payload.path,
|
||||
)
|
||||
except Exception as exc:
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
319
api/controllers/console/app/agent_app_workspace.py
Normal file
319
api/controllers/console/app/agent_app_workspace.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""Agent App sandbox file-system inspector (read-only).
|
||||
|
||||
Exposes the PRD "rc1-like sandbox file system, downloadable not editable" view
|
||||
for an Agent App conversation: list a directory, preview a file, or download a
|
||||
file from the conversation's shell-layer workspace. The API never touches
|
||||
shellctl directly — it resolves the conversation's sandbox ``session_id`` from
|
||||
the stored session snapshot and proxies to the agent backend's read-only
|
||||
workspace endpoints.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from clients.agent_backend.errors import AgentBackendHTTPError, AgentBackendTransportError
|
||||
from clients.agent_backend.workspace_files_client import WorkspaceDownloadResult
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent_app_workspace_service import (
|
||||
AgentAppWorkspaceService,
|
||||
AgentWorkspaceInspectorError,
|
||||
WorkflowAgentWorkspaceService,
|
||||
)
|
||||
|
||||
|
||||
class _WorkspaceFileDownloadField(fields.Raw):
|
||||
__schema_type__ = "string"
|
||||
__schema_format__ = "binary"
|
||||
|
||||
|
||||
class AgentWorkspaceListQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class AgentWorkspaceFileQuery(BaseModel):
|
||||
conversation_id: str = Field(min_length=1, description="Agent App conversation ID")
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
|
||||
|
||||
class WorkflowAgentWorkspaceListQuery(BaseModel):
|
||||
path: str = Field(default=".", description="Directory path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkflowAgentWorkspaceFileQuery(BaseModel):
|
||||
path: str = Field(min_length=1, description="File path relative to the sandbox workspace")
|
||||
node_execution_id: str | None = Field(
|
||||
default=None,
|
||||
description=(
|
||||
"Optional workflow node execution ID. When omitted, the latest active session for the node is used."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class WorkspaceFileEntryResponse(ResponseModel):
|
||||
name: str
|
||||
type: Literal["file", "dir", "symlink"]
|
||||
size: int
|
||||
mtime: int
|
||||
|
||||
|
||||
class WorkspaceListResponse(ResponseModel):
|
||||
path: str
|
||||
entries: list[WorkspaceFileEntryResponse] = Field(default_factory=list)
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class WorkspacePreviewResponse(ResponseModel):
|
||||
path: str
|
||||
size: int
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
register_response_schema_models(console_ns, WorkspaceListResponse)
|
||||
register_response_schema_models(console_ns, WorkspacePreviewResponse)
|
||||
|
||||
|
||||
def _handle(exc: Exception) -> tuple[dict[str, object], int]:
|
||||
if isinstance(exc, AgentWorkspaceInspectorError):
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
if isinstance(exc, AgentBackendHTTPError):
|
||||
detail = exc.detail
|
||||
if isinstance(detail, dict):
|
||||
return {
|
||||
"code": detail.get("code", "agent_backend_error"),
|
||||
"message": detail.get("message", str(exc)),
|
||||
}, exc.status_code
|
||||
return {"code": "agent_backend_error", "message": str(detail)}, exc.status_code
|
||||
if isinstance(exc, AgentBackendTransportError):
|
||||
return {"code": "agent_backend_unreachable", "message": str(exc)}, 502
|
||||
raise exc
|
||||
|
||||
|
||||
def _download_response(result: WorkspaceDownloadResult) -> Response | tuple[dict[str, object], int]:
|
||||
if result.truncated:
|
||||
return {
|
||||
"code": "workspace_file_too_large",
|
||||
"message": (
|
||||
"file exceeds the workspace download limit; use preview for partial text or download a smaller file"
|
||||
),
|
||||
"size": result.size,
|
||||
}, 413
|
||||
filename = result.path.rsplit("/", 1)[-1] or "download"
|
||||
return Response(
|
||||
result.content,
|
||||
mimetype="application/octet-stream",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
"Content-Length": str(len(result.content)),
|
||||
"X-Workspace-File-Size": str(result.size),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files")
|
||||
class AgentAppWorkspaceListResource(Resource):
|
||||
@console_ns.doc("list_agent_app_workspace_files")
|
||||
@console_ns.doc(description="List a directory in an Agent App conversation's sandbox workspace (read-only)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceListQuery)})
|
||||
@console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(AgentWorkspaceListQuery)
|
||||
try:
|
||||
result = AgentAppWorkspaceService().list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files/preview")
|
||||
class AgentAppWorkspacePreviewResource(Resource):
|
||||
@console_ns.doc("preview_agent_app_workspace_file")
|
||||
@console_ns.doc(description="Preview a text/binary file in an Agent App conversation's sandbox workspace")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)})
|
||||
@console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(AgentWorkspaceFileQuery)
|
||||
try:
|
||||
result = AgentAppWorkspaceService().preview(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent-workspace/files/download")
|
||||
class AgentAppWorkspaceDownloadResource(Resource):
|
||||
@console_ns.doc("download_agent_app_workspace_file")
|
||||
@console_ns.doc(description="Download a file from an Agent App conversation's sandbox workspace (read-only)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentWorkspaceFileQuery)})
|
||||
@console_ns.doc(produces=["application/octet-stream"])
|
||||
@console_ns.response(200, "File bytes", _WorkspaceFileDownloadField)
|
||||
@console_ns.response(413, "File exceeds the workspace download limit")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT])
|
||||
def get(self, app_model: App):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(AgentWorkspaceFileQuery)
|
||||
try:
|
||||
result = AgentAppWorkspaceService().download(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
conversation_id=query.conversation_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return _download_response(result)
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files"
|
||||
)
|
||||
class WorkflowAgentWorkspaceListResource(Resource):
|
||||
@console_ns.doc("list_workflow_agent_workspace_files")
|
||||
@console_ns.doc(description="List a directory in a Workflow Agent node's sandbox workspace (read-only)")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"workflow_run_id": "Workflow run ID",
|
||||
"node_id": "Workflow Agent node ID",
|
||||
**query_params_from_model(WorkflowAgentWorkspaceListQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Listing returned", console_ns.models[WorkspaceListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(WorkflowAgentWorkspaceListQuery)
|
||||
try:
|
||||
result = WorkflowAgentWorkspaceService().list_files(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=query.node_execution_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files/preview"
|
||||
)
|
||||
class WorkflowAgentWorkspacePreviewResource(Resource):
|
||||
@console_ns.doc("preview_workflow_agent_workspace_file")
|
||||
@console_ns.doc(description="Preview a text/binary file in a Workflow Agent node's sandbox workspace")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"workflow_run_id": "Workflow run ID",
|
||||
"node_id": "Workflow Agent node ID",
|
||||
**query_params_from_model(WorkflowAgentWorkspaceFileQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Preview returned", console_ns.models[WorkspacePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
|
||||
try:
|
||||
result = WorkflowAgentWorkspaceService().preview(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=query.node_execution_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return result.model_dump()
|
||||
|
||||
|
||||
@console_ns.route(
|
||||
"/apps/<uuid:app_id>/workflow-runs/<uuid:workflow_run_id>/agent-nodes/<string:node_id>/workspace/files/download"
|
||||
)
|
||||
class WorkflowAgentWorkspaceDownloadResource(Resource):
|
||||
@console_ns.doc("download_workflow_agent_workspace_file")
|
||||
@console_ns.doc(description="Download a file from a Workflow Agent node's sandbox workspace (read-only)")
|
||||
@console_ns.doc(
|
||||
params={
|
||||
"app_id": "Application ID",
|
||||
"workflow_run_id": "Workflow run ID",
|
||||
"node_id": "Workflow Agent node ID",
|
||||
**query_params_from_model(WorkflowAgentWorkspaceFileQuery),
|
||||
}
|
||||
)
|
||||
@console_ns.doc(produces=["application/octet-stream"])
|
||||
@console_ns.response(200, "File bytes", _WorkspaceFileDownloadField)
|
||||
@console_ns.response(413, "File exceeds the workspace download limit")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def get(self, app_model: App, workflow_run_id: UUID, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
query = query_params_from_request(WorkflowAgentWorkspaceFileQuery)
|
||||
try:
|
||||
result = WorkflowAgentWorkspaceService().download(
|
||||
tenant_id=tenant_id,
|
||||
app_id=app_model.id,
|
||||
workflow_run_id=str(workflow_run_id),
|
||||
node_id=node_id,
|
||||
node_execution_id=query.node_execution_id,
|
||||
path=query.path,
|
||||
)
|
||||
except Exception as exc: # normalized to an HTTP response below
|
||||
return _handle(exc)
|
||||
return _download_response(result)
|
||||
@ -1,235 +0,0 @@
|
||||
"""Console read-only inspector for the agent drive (ENG-624).
|
||||
|
||||
``agent-drive`` looks at the *static* drive assets (standardized skills and
|
||||
committed files); the sibling ``agent-sandbox`` routes look at a *runtime*
|
||||
sandbox workspace. Unlike the sandbox routes this never proxies to the agent
|
||||
backend — drive data lives in the API's own DB/storage, served straight from
|
||||
``AgentDriveService``. Download hands the browser an **external** signed URL
|
||||
(the inner manifest hands agents internal ones — the two must never mix).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
query_params_from_request,
|
||||
register_response_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_tenant_id
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent_drive_service import AgentDriveError, AgentDriveService
|
||||
|
||||
|
||||
class AgentDriveListQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveListByAgentQuery(BaseModel):
|
||||
prefix: str = Field(default="", description="Key prefix filter: '<slug>/' for one skill, 'files/' for files")
|
||||
|
||||
|
||||
class AgentDriveFileQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
|
||||
|
||||
|
||||
class AgentDriveFileByAgentQuery(BaseModel):
|
||||
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
|
||||
|
||||
|
||||
class AgentDriveItemResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
mime_type: str | None = None
|
||||
hash: str | None = None
|
||||
file_kind: str
|
||||
created_at: int | None = None
|
||||
|
||||
|
||||
class AgentDriveListResponse(ResponseModel):
|
||||
items: list[AgentDriveItemResponse] = Field(default_factory=list)
|
||||
|
||||
|
||||
class AgentDrivePreviewResponse(ResponseModel):
|
||||
key: str
|
||||
size: int | None = None
|
||||
truncated: bool
|
||||
binary: bool
|
||||
text: str | None = None
|
||||
|
||||
|
||||
class AgentDriveDownloadResponse(ResponseModel):
|
||||
url: str
|
||||
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns, AgentDriveListResponse, AgentDrivePreviewResponse, AgentDriveDownloadResponse
|
||||
)
|
||||
|
||||
|
||||
def _resolve_agent_id(app_model: App, node_id: str | None) -> str | None:
|
||||
"""Agent identity for the drive: app-bound agent, or the workflow node binding."""
|
||||
if node_id:
|
||||
return AgentComposerService.resolve_workflow_node_agent_id(
|
||||
tenant_id=app_model.tenant_id, app_id=app_model.id, node_id=node_id
|
||||
)
|
||||
return app_model.bound_agent_id
|
||||
|
||||
|
||||
def _agent_not_bound() -> tuple[dict[str, object], int]:
|
||||
return {"code": "agent_not_bound", "message": "no agent is bound for this app/node"}, 400
|
||||
|
||||
|
||||
def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
|
||||
return {"code": exc.code, "message": exc.message}, exc.status_code
|
||||
|
||||
|
||||
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files")
|
||||
class AgentDriveListByAgentApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files_by_agent")
|
||||
@console_ns.doc(description="List agent drive entries for an Agent App")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveListByAgentQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveListByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=tenant_id, agent_id=str(agent_id), prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
|
||||
class AgentDrivePreviewByAgentApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Truncated text preview of one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/drive/files/download")
|
||||
class AgentDriveDownloadByAgentApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file_by_agent")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one Agent App drive value")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", **query_params_from_model(AgentDriveFileByAgentQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, tenant_id: str, agent_id: UUID):
|
||||
query = query_params_from_request(AgentDriveFileByAgentQuery)
|
||||
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=tenant_id, agent_id=str(agent_id), key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files")
|
||||
class AgentDriveListApi(Resource):
|
||||
@console_ns.doc("list_agent_drive_files")
|
||||
@console_ns.doc(description="List agent drive entries (read-only inspector; one endpoint for both tabs)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveListQuery)})
|
||||
@console_ns.response(200, "Drive entries", console_ns.models[AgentDriveListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveListQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
items = AgentDriveService().manifest(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=query.prefix)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
# the inner manifest exposes file_id for agent-side pulls; the console
|
||||
# inspector is a pure read surface and does not need value pointers
|
||||
return {"items": [{k: v for k, v in item.items() if k != "file_id"} for item in items]}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
|
||||
class AgentDrivePreviewApi(Resource):
|
||||
@console_ns.doc("preview_agent_drive_file")
|
||||
@console_ns.doc(description="Truncated text preview of one drive value (binary-safe; SKILL.md is the main case)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Preview", console_ns.models[AgentDrivePreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
return AgentDriveService().preview(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/download")
|
||||
class AgentDriveDownloadApi(Resource):
|
||||
@console_ns.doc("download_agent_drive_file")
|
||||
@console_ns.doc(description="Time-limited external signed URL for one drive value (no streaming proxy)")
|
||||
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveFileQuery)})
|
||||
@console_ns.response(200, "Signed URL", console_ns.models[AgentDriveDownloadResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=_WORKFLOW_APP_MODES)
|
||||
def get(self, app_model: App):
|
||||
query = query_params_from_request(AgentDriveFileQuery)
|
||||
agent_id = _resolve_agent_id(app_model, query.node_id)
|
||||
if not agent_id:
|
||||
return _agent_not_bound()
|
||||
try:
|
||||
url = AgentDriveService().download_url(tenant_id=app_model.tenant_id, agent_id=agent_id, key=query.key)
|
||||
except AgentDriveError as exc:
|
||||
return _handle(exc)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentDriveDownloadApi",
|
||||
"AgentDriveDownloadByAgentApi",
|
||||
"AgentDriveListApi",
|
||||
"AgentDriveListByAgentApi",
|
||||
"AgentDrivePreviewApi",
|
||||
"AgentDrivePreviewByAgentApi",
|
||||
]
|
||||
@ -6,7 +6,7 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, TypeAdapter, field_validator
|
||||
|
||||
from controllers.common.errors import NoFileUploadedError, TooManyFilesError
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
@ -24,7 +24,6 @@ from fields.annotation_fields import (
|
||||
AnnotationHitHistoryList,
|
||||
AnnotationList,
|
||||
)
|
||||
from fields.base import ResponseModel
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from services.annotation_service import (
|
||||
@ -57,10 +56,7 @@ class CreateAnnotationPayload(BaseModel):
|
||||
question: str | None = Field(default=None, description="Question text")
|
||||
answer: str | None = Field(default=None, description="Answer text")
|
||||
content: str | None = Field(default=None, description="Content text")
|
||||
annotation_reply: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Annotation reply data",
|
||||
)
|
||||
annotation_reply: dict[str, Any] | None = Field(default=None, description="Annotation reply data")
|
||||
|
||||
@field_validator("message_id")
|
||||
@classmethod
|
||||
@ -74,18 +70,13 @@ class UpdateAnnotationPayload(BaseModel):
|
||||
question: str | None = None
|
||||
answer: str | None = None
|
||||
content: str | None = None
|
||||
annotation_reply: dict[str, Any] | None = Field(default=None)
|
||||
annotation_reply: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class AnnotationReplyStatusQuery(BaseModel):
|
||||
action: Literal["enable", "disable"]
|
||||
|
||||
|
||||
class AnnotationHitHistoryListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, description="Page number")
|
||||
limit: int = Field(default=20, ge=1, description="Page size")
|
||||
|
||||
|
||||
class AnnotationFilePayload(BaseModel):
|
||||
message_id: str = Field(..., description="Message ID")
|
||||
|
||||
@ -95,25 +86,6 @@ class AnnotationFilePayload(BaseModel):
|
||||
return uuid_value(value)
|
||||
|
||||
|
||||
class AnnotationJobStatusResponse(ResponseModel):
|
||||
job_id: str | None = None
|
||||
job_status: str | None = None
|
||||
error_msg: str | None = None
|
||||
record_count: int | None = None
|
||||
|
||||
|
||||
class AnnotationEmbeddingModelResponse(ResponseModel):
|
||||
embedding_provider_name: str | None = None
|
||||
embedding_model_name: str | None = None
|
||||
|
||||
|
||||
class AnnotationSettingResponse(ResponseModel):
|
||||
id: str | None = None
|
||||
enabled: bool
|
||||
score_threshold: float | None = None
|
||||
embedding_model: AnnotationEmbeddingModelResponse | None = None
|
||||
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
Annotation,
|
||||
@ -127,19 +99,8 @@ register_schema_models(
|
||||
CreateAnnotationPayload,
|
||||
UpdateAnnotationPayload,
|
||||
AnnotationReplyStatusQuery,
|
||||
AnnotationHitHistoryListQuery,
|
||||
AnnotationFilePayload,
|
||||
)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
Annotation,
|
||||
AnnotationList,
|
||||
AnnotationExportList,
|
||||
AnnotationHitHistory,
|
||||
AnnotationHitHistoryList,
|
||||
AnnotationJobStatusResponse,
|
||||
AnnotationSettingResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/annotation-reply/<string:action>")
|
||||
@ -148,7 +109,7 @@ class AnnotationReplyActionApi(Resource):
|
||||
@console_ns.doc(description="Enable or disable annotation reply for an app")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "action": "Action to perform (enable/disable)"})
|
||||
@console_ns.expect(console_ns.models[AnnotationReplyPayload.__name__])
|
||||
@console_ns.response(200, "Action completed successfully", console_ns.models[AnnotationJobStatusResponse.__name__])
|
||||
@console_ns.response(200, "Action completed successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -175,11 +136,7 @@ class AppAnnotationSettingDetailApi(Resource):
|
||||
@console_ns.doc("get_annotation_setting")
|
||||
@console_ns.doc(description="Get annotation settings for an app")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Annotation settings retrieved successfully",
|
||||
console_ns.models[AnnotationSettingResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Annotation settings retrieved successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -196,7 +153,7 @@ class AppAnnotationSettingUpdateApi(Resource):
|
||||
@console_ns.doc(description="Update annotation settings for an app")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "annotation_setting_id": "Annotation setting ID"})
|
||||
@console_ns.expect(console_ns.models[AnnotationSettingUpdatePayload.__name__])
|
||||
@console_ns.response(200, "Settings updated successfully", console_ns.models[AnnotationSettingResponse.__name__])
|
||||
@console_ns.response(200, "Settings updated successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -219,11 +176,7 @@ class AnnotationReplyActionStatusApi(Resource):
|
||||
@console_ns.doc("get_annotation_reply_action_status")
|
||||
@console_ns.doc(description="Get status of annotation reply action job")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID", "action": "Action type"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Job status retrieved successfully",
|
||||
console_ns.models[AnnotationJobStatusResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Job status retrieved successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -251,8 +204,8 @@ class AnnotationApi(Resource):
|
||||
@console_ns.doc("list_annotations")
|
||||
@console_ns.doc(description="Get annotations for an app with pagination")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(AnnotationListQuery))
|
||||
@console_ns.response(200, "Annotations retrieved successfully", console_ns.models[AnnotationList.__name__])
|
||||
@console_ns.expect(console_ns.models[AnnotationListQuery.__name__])
|
||||
@console_ns.response(200, "Annotations retrieved successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -304,7 +257,6 @@ class AnnotationApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@console_ns.response(204, "Annotations deleted successfully")
|
||||
def delete(self, app_id: UUID):
|
||||
|
||||
# Use request.args.getlist to get annotation_ids array directly
|
||||
@ -383,7 +335,6 @@ class AnnotationUpdateDeleteApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@console_ns.response(204, "Annotation deleted successfully")
|
||||
def delete(self, app_id: UUID, annotation_id: UUID):
|
||||
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
|
||||
return "", 204
|
||||
@ -394,11 +345,7 @@ class AnnotationBatchImportApi(Resource):
|
||||
@console_ns.doc("batch_import_annotations")
|
||||
@console_ns.doc(description="Batch import annotations from CSV file with rate limiting and security checks")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Batch import started successfully",
|
||||
console_ns.models[AnnotationJobStatusResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Batch import started successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "No file uploaded or too many files")
|
||||
@console_ns.response(413, "File too large")
|
||||
@ -451,11 +398,7 @@ class AnnotationBatchImportStatusApi(Resource):
|
||||
@console_ns.doc("get_batch_import_status")
|
||||
@console_ns.doc(description="Get status of batch import job")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "job_id": "Job ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Job status retrieved successfully",
|
||||
console_ns.models[AnnotationJobStatusResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Job status retrieved successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -481,7 +424,11 @@ class AnnotationHitHistoryListApi(Resource):
|
||||
@console_ns.doc("list_annotation_hit_histories")
|
||||
@console_ns.doc(description="Get hit histories for an annotation")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "annotation_id": "Annotation ID"})
|
||||
@console_ns.doc(params=query_params_from_model(AnnotationHitHistoryListQuery))
|
||||
@console_ns.expect(
|
||||
console_ns.parser()
|
||||
.add_argument("page", type=int, location="args", default=1, help="Page number")
|
||||
.add_argument("limit", type=int, location="args", default=20, help="Page size")
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Hit histories retrieved successfully",
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, cast
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
@ -15,12 +14,7 @@ from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.fields import RedirectUrlResponse, SimpleResultResponse
|
||||
from controllers.common.helpers import FileInfo
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_enum_models,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model, with_session
|
||||
from controllers.console.workspace.models import LoadBalancingPayload
|
||||
@ -42,12 +36,12 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.helper import build_icon_url, dump_response, to_timestamp
|
||||
from libs.helper import build_icon_url, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App, DatasetPermissionEnum, Workflow
|
||||
from models.model import IconType
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.app_service import AppListParams, AppListSortBy, AppService, CreateAppParams, StarredAppListParams
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
from services.enterprise.enterprise_service import EnterpriseService
|
||||
from services.entities.dsl_entities import ImportMode, ImportStatus
|
||||
from services.entities.knowledge_entities.knowledge_entities import (
|
||||
@ -64,27 +58,22 @@ from services.entities.knowledge_entities.knowledge_entities import (
|
||||
)
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
|
||||
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
|
||||
|
||||
register_enum_models(console_ns, IconType)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$")
|
||||
_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$")
|
||||
AppListMode = Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"]
|
||||
|
||||
|
||||
class AppListBaseQuery(BaseModel):
|
||||
class AppListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, le=99999, description="Page number (1-99999)")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Page size (1-100)")
|
||||
mode: AppListMode = Field(default=cast(AppListMode, "all"), description="App mode filter")
|
||||
sort_by: AppListSortBy = Field(
|
||||
default="last_modified",
|
||||
description="Sort apps by last modified, recently created, or earliest created",
|
||||
mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = Field(
|
||||
default="all", description="App mode filter"
|
||||
)
|
||||
name: str | None = Field(default=None, description="Filter by app name")
|
||||
tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs")
|
||||
creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs")
|
||||
is_created_by_me: bool | None = Field(default=None, description="Filter by creator")
|
||||
|
||||
@field_validator("tag_ids", mode="before")
|
||||
@ -105,37 +94,10 @@ class AppListBaseQuery(BaseModel):
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in tag_ids.") from exc
|
||||
|
||||
@field_validator("creator_ids", mode="before")
|
||||
@classmethod
|
||||
def validate_creator_ids(cls, value: list[str] | None) -> list[str] | None:
|
||||
if not value:
|
||||
return None
|
||||
|
||||
if not isinstance(value, list):
|
||||
raise ValueError("Unsupported creator_ids type.")
|
||||
|
||||
items = [str(item).strip() for item in value if item and str(item).strip()]
|
||||
if not items:
|
||||
return None
|
||||
|
||||
try:
|
||||
return [str(uuid.UUID(item)) for item in items]
|
||||
except ValueError as exc:
|
||||
raise ValueError("Invalid UUID format in creator_ids.") from exc
|
||||
|
||||
|
||||
class AppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
|
||||
class StarredAppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]:
|
||||
normalized: dict[str, str | list[str]] = {}
|
||||
indexed_tag_ids: list[tuple[int, str]] = []
|
||||
indexed_creator_ids: list[tuple[int, str]] = []
|
||||
|
||||
for key in query_args:
|
||||
match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
@ -143,19 +105,12 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
|
||||
indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key)
|
||||
if match:
|
||||
indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key))
|
||||
continue
|
||||
|
||||
value = query_args.get(key)
|
||||
if value is not None:
|
||||
normalized[key] = value
|
||||
|
||||
if indexed_tag_ids:
|
||||
normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)]
|
||||
if indexed_creator_ids:
|
||||
normalized["creator_ids"] = [value for _, value in sorted(indexed_creator_ids)]
|
||||
|
||||
return normalized
|
||||
|
||||
@ -163,7 +118,9 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str,
|
||||
class CreateAppPayload(BaseModel):
|
||||
name: str = Field(..., min_length=1, description="App name")
|
||||
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
|
||||
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
|
||||
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"] = Field(
|
||||
..., description="App mode"
|
||||
)
|
||||
icon_type: IconType | None = Field(default=None, description="Icon type")
|
||||
icon: str | None = Field(default=None, description="Icon")
|
||||
icon_background: str | None = Field(default=None, description="Icon background color")
|
||||
@ -222,11 +179,6 @@ class AppTracePayload(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class AppTraceResponse(ResponseModel):
|
||||
enabled: bool
|
||||
tracing_provider: str | None = None
|
||||
|
||||
|
||||
type JSONValue = Any
|
||||
|
||||
|
||||
@ -398,11 +350,6 @@ class AppPartial(ResponseModel):
|
||||
create_user_name: str | None = None
|
||||
author_name: str | None = None
|
||||
has_draft_trigger: bool | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
app_id: str | None = None
|
||||
is_starred: bool = False
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
@ -453,8 +400,6 @@ class AppDetailWithSite(AppDetail):
|
||||
site: Site | None = None
|
||||
# For Agent App type: the roster Agent backing this app (None otherwise).
|
||||
bound_agent_id: str | None = None
|
||||
# For Agent App responses exposed through /agent.
|
||||
app_id: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore
|
||||
@property
|
||||
@ -474,54 +419,12 @@ class AppExportResponse(ResponseModel):
|
||||
data: str
|
||||
|
||||
|
||||
def _enrich_app_list_items(session: Session, *, apps: Sequence[App], tenant_id: str) -> None:
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_ids = [str(app.id) for app in apps]
|
||||
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||
if len(res) != len(app_ids):
|
||||
raise BadRequest("Invalid app id in webapp auth")
|
||||
|
||||
for app in apps:
|
||||
if str(app.id) in res:
|
||||
app.access_mode = res[str(app.id)].access_mode
|
||||
|
||||
workflow_capable_app_ids = [str(app.id) for app in apps if app.mode in {"workflow", "advanced-chat"}]
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
Workflow.tenant_id == tenant_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
trigger_node_types = TRIGGER_NODE_TYPES
|
||||
for workflow in draft_workflows:
|
||||
node_id = None
|
||||
try:
|
||||
for node_id, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
|
||||
continue
|
||||
|
||||
for app in apps:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
|
||||
|
||||
register_enum_models(console_ns, RetrievalMethod, WorkflowExecutionStatus, DatasetPermissionEnum)
|
||||
register_response_schema_models(console_ns, AppTraceResponse, RedirectUrlResponse, SimpleResultResponse)
|
||||
register_response_schema_models(console_ns, RedirectUrlResponse, SimpleResultResponse)
|
||||
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
AppListQuery,
|
||||
StarredAppListQuery,
|
||||
CreateAppPayload,
|
||||
UpdateAppPayload,
|
||||
CopyAppPayload,
|
||||
@ -537,7 +440,10 @@ register_schema_models(
|
||||
ModelConfig,
|
||||
Site,
|
||||
DeletedTool,
|
||||
AppPartial,
|
||||
AppDetail,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
AppExportResponse,
|
||||
Segmentation,
|
||||
PreProcessingRule,
|
||||
@ -557,19 +463,12 @@ register_schema_models(
|
||||
LoadBalancingPayload,
|
||||
)
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppPartial,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps")
|
||||
class AppListApi(Resource):
|
||||
@console_ns.doc("list_apps")
|
||||
@console_ns.doc(description="Get list of applications with pagination and filtering")
|
||||
@console_ns.doc(params=query_params_from_model(AppListQuery))
|
||||
@console_ns.expect(console_ns.models[AppListQuery.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -585,21 +484,58 @@ class AppListApi(Resource):
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode=args.mode,
|
||||
sort_by=args.sort_by,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
# get app list
|
||||
app_service = AppService()
|
||||
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params, db.session)
|
||||
app_pagination = app_service.get_paginate_apps(current_user_id, current_tenant_id, params)
|
||||
if not app_pagination:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json"), 200
|
||||
|
||||
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_ids = [str(app.id) for app in app_pagination.items]
|
||||
res = EnterpriseService.WebAppAuth.batch_get_app_access_mode_by_id(app_ids=app_ids)
|
||||
if len(res) != len(app_ids):
|
||||
raise BadRequest("Invalid app id in webapp auth")
|
||||
|
||||
for app in app_pagination.items:
|
||||
if str(app.id) in res:
|
||||
app.access_mode = res[str(app.id)].access_mode
|
||||
|
||||
workflow_capable_app_ids = [
|
||||
str(app.id) for app in app_pagination.items if app.mode in {"workflow", "advanced-chat"}
|
||||
]
|
||||
draft_trigger_app_ids: set[str] = set()
|
||||
if workflow_capable_app_ids:
|
||||
draft_workflows = (
|
||||
session.execute(
|
||||
select(Workflow).where(
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
Workflow.app_id.in_(workflow_capable_app_ids),
|
||||
Workflow.tenant_id == current_tenant_id,
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
trigger_node_types = TRIGGER_NODE_TYPES
|
||||
for workflow in draft_workflows:
|
||||
node_id = None
|
||||
try:
|
||||
for node_id, node_data in workflow.walk_nodes():
|
||||
if node_data.get("type") in trigger_node_types:
|
||||
draft_trigger_app_ids.add(str(workflow.app_id))
|
||||
break
|
||||
except Exception:
|
||||
_logger.exception("error while walking nodes, workflow_id=%s, node_id=%s", workflow.id, node_id)
|
||||
continue
|
||||
|
||||
for app in app_pagination.items:
|
||||
app.has_draft_trigger = str(app.id) in draft_trigger_app_ids
|
||||
|
||||
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
|
||||
return pagination_model.model_dump(mode="json"), 200
|
||||
@ -607,7 +543,7 @@ class AppListApi(Resource):
|
||||
@console_ns.doc("create_app")
|
||||
@console_ns.doc(description="Create a new application")
|
||||
@console_ns.expect(console_ns.models[CreateAppPayload.__name__])
|
||||
@console_ns.response(201, "App created successfully", console_ns.models[AppDetailWithSite.__name__])
|
||||
@console_ns.response(201, "App created successfully", console_ns.models[AppDetail.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@ -631,82 +567,10 @@ class AppListApi(Resource):
|
||||
|
||||
app_service = AppService()
|
||||
app = app_service.create_app(current_tenant_id, params, current_user)
|
||||
app_detail = AppDetailWithSite.model_validate(app, from_attributes=True)
|
||||
app_detail = AppDetail.model_validate(app, from_attributes=True)
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/starred")
|
||||
class StarredAppListApi(Resource):
|
||||
@console_ns.doc("list_starred_apps")
|
||||
@console_ns.doc(description="Get applications starred by the current account")
|
||||
@console_ns.doc(params=query_params_from_model(StarredAppListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[AppPagination.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
|
||||
args = StarredAppListQuery.model_validate(_normalize_app_list_query_args(request.args))
|
||||
params = StarredAppListParams(
|
||||
page=args.page,
|
||||
limit=args.limit,
|
||||
mode=args.mode,
|
||||
sort_by=args.sort_by,
|
||||
name=args.name,
|
||||
tag_ids=args.tag_ids,
|
||||
creator_ids=args.creator_ids,
|
||||
is_created_by_me=args.is_created_by_me,
|
||||
)
|
||||
|
||||
app_pagination = AppService().get_paginate_starred_apps(current_user_id, current_tenant_id, params, db.session)
|
||||
if not app_pagination:
|
||||
empty = AppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
|
||||
return empty.model_dump(mode="json"), 200
|
||||
|
||||
_enrich_app_list_items(session, apps=app_pagination.items, tenant_id=current_tenant_id)
|
||||
|
||||
pagination_model = AppPagination.model_validate(app_pagination, from_attributes=True)
|
||||
return pagination_model.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/star")
|
||||
class AppStarApi(Resource):
|
||||
@console_ns.doc("star_app")
|
||||
@console_ns.doc(description="Star an application for the current account")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_user_id
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def post(self, session: Session, current_user_id: str, app_model: App):
|
||||
AppService.star_app(session, app=app_model, account_id=current_user_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"})
|
||||
|
||||
@console_ns.doc("unstar_app")
|
||||
@console_ns.doc(description="Remove the current account's star from an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_current_user_id
|
||||
@with_session
|
||||
@get_app_model(mode=None)
|
||||
def delete(self, session: Session, current_user_id: str, app_model: App):
|
||||
AppService.unstar_app(session, app=app_model, account_id=current_user_id)
|
||||
return dump_response(SimpleResultResponse, {"result": "success"})
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>")
|
||||
class AppApi(Resource):
|
||||
@console_ns.doc("get_app_detail")
|
||||
@ -726,7 +590,7 @@ class AppApi(Resource):
|
||||
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||
app_model.access_mode = app_setting.access_mode
|
||||
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
|
||||
|
||||
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
|
||||
return response_model.model_dump(mode="json")
|
||||
@ -845,7 +709,7 @@ class AppExportApi(Resource):
|
||||
@console_ns.doc("export_app")
|
||||
@console_ns.doc(description="Export application configuration as DSL")
|
||||
@console_ns.doc(params={"app_id": "Application ID to export"})
|
||||
@console_ns.doc(params=query_params_from_model(AppExportQuery))
|
||||
@console_ns.expect(console_ns.models[AppExportQuery.__name__])
|
||||
@console_ns.response(200, "App exported successfully", console_ns.models[AppExportResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@get_app_model
|
||||
@ -920,7 +784,7 @@ class AppIconApi(Resource):
|
||||
@console_ns.doc(description="Update application icon")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[AppIconPayload.__name__])
|
||||
@console_ns.response(200, "Icon updated successfully", console_ns.models[AppDetail.__name__])
|
||||
@console_ns.response(200, "Icon updated successfully")
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -990,11 +854,7 @@ class AppTraceApi(Resource):
|
||||
@console_ns.doc("get_app_trace")
|
||||
@console_ns.doc(description="Get app tracing configuration")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Trace configuration retrieved successfully",
|
||||
console_ns.models[AppTraceResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Trace configuration retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
|
||||
@ -1,14 +1,12 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
import services
|
||||
from controllers.common.fields import AudioBinaryResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
@ -53,12 +51,7 @@ class AudioTranscriptResponse(BaseModel):
|
||||
text: str = Field(description="Transcribed text from audio")
|
||||
|
||||
|
||||
class TextToSpeechVoiceListResponse(RootModel[list[dict[str, Any]]]):
|
||||
root: list[dict[str, Any]]
|
||||
|
||||
|
||||
register_schema_models(console_ns, AudioTranscriptResponse, TextToSpeechPayload, TextToSpeechVoiceQuery)
|
||||
register_response_schema_models(console_ns, AudioBinaryResponse, TextToSpeechVoiceListResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/audio-to-text")
|
||||
@ -120,11 +113,7 @@ class ChatMessageTextApi(Resource):
|
||||
@console_ns.doc(description="Convert text to speech for chat messages")
|
||||
@console_ns.doc(params={"app_id": "App ID"})
|
||||
@console_ns.expect(console_ns.models[TextToSpeechPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Text to speech conversion successful",
|
||||
console_ns.models[AudioBinaryResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Text to speech conversion successful")
|
||||
@console_ns.response(400, "Bad request - Invalid parameters")
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -173,11 +162,9 @@ class TextModesApi(Resource):
|
||||
@console_ns.doc("get_text_to_speech_voices")
|
||||
@console_ns.doc(description="Get available TTS voices for a specific language")
|
||||
@console_ns.doc(params={"app_id": "App ID"})
|
||||
@console_ns.doc(params=query_params_from_model(TextToSpeechVoiceQuery))
|
||||
@console_ns.expect(console_ns.models[TextToSpeechVoiceQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"TTS voices retrieved successfully",
|
||||
console_ns.models[TextToSpeechVoiceListResponse.__name__],
|
||||
200, "TTS voices retrieved successfully", fields.List(fields.Raw(description="Available voices"))
|
||||
)
|
||||
@console_ns.response(400, "Invalid language parameter")
|
||||
@get_app_model
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
@ -8,10 +7,9 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
@ -25,8 +23,6 @@ from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
with_current_user_id,
|
||||
)
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
@ -40,7 +36,7 @@ from core.helper.trace_id_helper import get_external_trace_id
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs import helper
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from libs.login import current_user, login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
@ -67,14 +63,8 @@ class BaseMessagePayload(BaseModel):
|
||||
# Soul, so no override ``model_config`` is sent; chat / agent-chat / completion
|
||||
# debugging still pass it. Optional here, required in practice by those modes
|
||||
# downstream when their config is built from args.
|
||||
model_config_data: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
alias="model_config",
|
||||
)
|
||||
files: list[Any] | None = Field(
|
||||
default=None,
|
||||
description="Uploaded files",
|
||||
)
|
||||
model_config_data: dict[str, Any] = Field(default_factory=dict, alias="model_config")
|
||||
files: list[Any] | None = Field(default=None, description="Uploaded files")
|
||||
response_mode: Literal["blocking", "streaming"] = Field(default="blocking", description="Response mode")
|
||||
retriever_from: str = Field(default="dev", description="Retriever source")
|
||||
|
||||
@ -97,7 +87,7 @@ class ChatMessagePayload(BaseMessagePayload):
|
||||
|
||||
|
||||
register_schema_models(console_ns, CompletionMessagePayload, ChatMessagePayload)
|
||||
register_response_schema_models(console_ns, GeneratedAppResponse, SimpleResultResponse)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
# define completion message api for user
|
||||
@ -107,15 +97,14 @@ class CompletionMessageApi(Resource):
|
||||
@console_ns.doc(description="Generate completion message for debugging")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[CompletionMessagePayload.__name__])
|
||||
@console_ns.response(200, "Completion generated successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(200, "Completion generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
@ -123,6 +112,8 @@ class CompletionMessageApi(Resource):
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
try:
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account or EndUser instance")
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
|
||||
)
|
||||
@ -179,7 +170,7 @@ class ChatMessageApi(Resource):
|
||||
@console_ns.doc(description="Generate chat message for debugging")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
|
||||
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(200, "Chat message generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(404, "App or conversation not found")
|
||||
@setup_required
|
||||
@ -187,29 +178,54 @@ class ChatMessageApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
def post(self, app_model: App):
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
args["response_mode"] = "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
class AgentChatMessageApi(Resource):
|
||||
@console_ns.doc("create_agent_chat_message")
|
||||
@console_ns.doc(description="Generate an Agent App chat message for debugging")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[ChatMessagePayload.__name__])
|
||||
@console_ns.response(200, "Chat message generated successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(404, "Agent or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _create_chat_message(current_user=current_user, app_model=app_model)
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account or EndUser instance")
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeRateLimitError as ex:
|
||||
raise InvokeRateLimitHttpError(ex.description)
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
|
||||
@ -224,79 +240,12 @@ class ChatMessageStopApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user_id
|
||||
def post(self, current_user_id: str, app_model: App, task_id: str):
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop")
|
||||
class AgentChatMessageStopApi(Resource):
|
||||
@console_ns.doc("stop_agent_chat_message")
|
||||
@console_ns.doc(description="Stop a running Agent App chat message generation")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "task_id": "Task ID to stop"})
|
||||
@console_ns.response(200, "Task stopped successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user_id: str, agent_id: UUID, task_id: str):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
|
||||
|
||||
|
||||
def _create_chat_message(*, current_user: Account, app_model: App):
|
||||
raw_payload = console_ns.payload or {}
|
||||
args_model = ChatMessagePayload.model_validate(raw_payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
streaming = _resolve_debugger_chat_streaming(
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
response_mode=args_model.response_mode,
|
||||
response_mode_provided=isinstance(raw_payload, dict) and "response_mode" in raw_payload,
|
||||
)
|
||||
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
|
||||
args["response_mode"] = "streaming"
|
||||
args["auto_generate_name"] = False
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
if external_trace_id:
|
||||
args["external_trace_id"] = external_trace_id
|
||||
|
||||
try:
|
||||
response = AppGenerateService.generate(
|
||||
app_model=app_model, user=current_user, args=args, invoke_from=InvokeFrom.DEBUGGER, streaming=streaming
|
||||
AppTaskService.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id=current_user_id,
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
)
|
||||
|
||||
return helper.compact_generate_response(response)
|
||||
except services.errors.conversation.ConversationNotExistsError:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
except services.errors.conversation.ConversationCompletedError:
|
||||
raise ConversationCompletedError()
|
||||
except services.errors.app_model_config.AppModelConfigBrokenError:
|
||||
logger.exception("App model config broken.")
|
||||
raise AppUnavailableError()
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeRateLimitError as ex:
|
||||
raise InvokeRateLimitHttpError(ex.description)
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except ValueError as e:
|
||||
raise e
|
||||
except Exception as e:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
|
||||
def _stop_chat_message(*, current_user_id: str, app_model: App, task_id: str):
|
||||
AppTaskService.stop_task(
|
||||
task_id=task_id,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
user_id=current_user_id,
|
||||
app_mode=AppMode.value_of(app_model.mode),
|
||||
)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
return {"result": "success"}, 200
|
||||
|
||||
@ -9,7 +9,7 @@ from sqlalchemy import func, or_
|
||||
from sqlalchemy.orm import selectinload
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
@ -91,7 +91,7 @@ class CompletionConversationApi(Resource):
|
||||
@console_ns.doc("list_completion_conversations")
|
||||
@console_ns.doc(description="Get completion conversations with pagination and filtering")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(CompletionConversationQuery))
|
||||
@console_ns.expect(console_ns.models[CompletionConversationQuery.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationPaginationResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
@ -206,7 +206,7 @@ class ChatConversationApi(Resource):
|
||||
@console_ns.doc("list_chat_conversations")
|
||||
@console_ns.doc(description="Get chat conversations with pagination, filtering and summary")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(ChatConversationQuery))
|
||||
@console_ns.expect(console_ns.models[ChatConversationQuery.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[ConversationWithSummaryPaginationResponse.__name__])
|
||||
@console_ns.response(403, "Insufficient permissions")
|
||||
@setup_required
|
||||
|
||||
@ -9,7 +9,7 @@ from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
@ -84,7 +84,7 @@ class ConversationVariablesApi(Resource):
|
||||
@console_ns.doc("get_conversation_variables")
|
||||
@console_ns.doc(description="Get conversation variables for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(ConversationVariablesQuery))
|
||||
@console_ns.expect(console_ns.models[ConversationVariablesQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Conversation variables retrieved successfully",
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, RootModel
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.fields import SimpleDataResponse
|
||||
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_enum_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
@ -27,7 +25,6 @@ from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@ -37,11 +34,7 @@ class InstructionGeneratePayload(BaseModel):
|
||||
current: str = Field(default="", description="Current instruction text")
|
||||
language: str = Field(default="javascript", description="Programming language (javascript/python)")
|
||||
instruction: str = Field(..., description="Instruction for generation")
|
||||
model_config_data: ModelConfig = Field(
|
||||
...,
|
||||
alias="model_config",
|
||||
description="Model configuration",
|
||||
)
|
||||
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
|
||||
ideal_output: str = Field(default="", description="Expected ideal output")
|
||||
|
||||
|
||||
@ -49,39 +42,6 @@ class InstructionTemplatePayload(BaseModel):
|
||||
type: str = Field(..., description="Instruction template type")
|
||||
|
||||
|
||||
# Upper bound for the generator's free-text inputs. Generous for prose (a
|
||||
# detailed instruction rarely passes 2k chars) while keeping the
|
||||
# planner+builder prompts well inside every mainstream context window.
|
||||
# Mirrored by the ``maxLength`` on the frontend generator textarea.
|
||||
_MAX_INSTRUCTION_LENGTH = 10_000
|
||||
|
||||
|
||||
class WorkflowGeneratePayload(BaseModel):
|
||||
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
can reuse its existing handler.
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
|
||||
instruction: str = Field(..., description="Natural-language workflow description")
|
||||
ideal_output: str = Field(default="", description="Optional sample output for grounding")
|
||||
model_config_data: ModelConfig = Field(
|
||||
...,
|
||||
alias="model_config",
|
||||
description="Model configuration",
|
||||
)
|
||||
current_graph: dict | None = Field(
|
||||
default=None,
|
||||
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
|
||||
)
|
||||
|
||||
|
||||
class GeneratorResponse(RootModel[Any]):
|
||||
root: Any
|
||||
|
||||
|
||||
register_enum_models(console_ns, LLMMode)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
@ -90,10 +50,8 @@ register_schema_models(
|
||||
RuleStructuredOutputPayload,
|
||||
InstructionGeneratePayload,
|
||||
InstructionTemplatePayload,
|
||||
WorkflowGeneratePayload,
|
||||
ModelConfig,
|
||||
)
|
||||
register_response_schema_models(console_ns, GeneratorResponse, SimpleDataResponse)
|
||||
|
||||
|
||||
@console_ns.route("/rule-generate")
|
||||
@ -101,11 +59,7 @@ class RuleGenerateApi(Resource):
|
||||
@console_ns.doc("generate_rule_config")
|
||||
@console_ns.doc(description="Generate rule configuration using LLM")
|
||||
@console_ns.expect(console_ns.models[RuleGeneratePayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Rule configuration generated successfully",
|
||||
console_ns.models[GeneratorResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Rule configuration generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@ -134,7 +88,7 @@ class RuleCodeGenerateApi(Resource):
|
||||
@console_ns.doc("generate_rule_code")
|
||||
@console_ns.doc(description="Generate code rules using LLM")
|
||||
@console_ns.expect(console_ns.models[RuleCodeGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Code rules generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(200, "Code rules generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@ -166,7 +120,7 @@ class RuleStructuredOutputGenerateApi(Resource):
|
||||
@console_ns.doc("generate_structured_output")
|
||||
@console_ns.doc(description="Generate structured output rules using LLM")
|
||||
@console_ns.expect(console_ns.models[RuleStructuredOutputPayload.__name__])
|
||||
@console_ns.response(200, "Structured output generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(200, "Structured output generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@ -198,7 +152,7 @@ class InstructionGenerateApi(Resource):
|
||||
@console_ns.doc("generate_instruction")
|
||||
@console_ns.doc(description="Generate instruction for workflow nodes or general use")
|
||||
@console_ns.expect(console_ns.models[InstructionGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Instruction generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(200, "Instruction generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters or flow/workflow not found")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@ -293,7 +247,7 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
@console_ns.doc("get_instruction_template")
|
||||
@console_ns.doc(description="Get instruction generation template")
|
||||
@console_ns.expect(console_ns.models[InstructionTemplatePayload.__name__])
|
||||
@console_ns.response(200, "Template retrieved successfully", console_ns.models[SimpleDataResponse.__name__])
|
||||
@console_ns.response(200, "Template retrieved successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -311,72 +265,3 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
|
||||
case _:
|
||||
raise ValueError(f"Invalid type: {args.type}")
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate")
|
||||
class WorkflowGenerateApi(Resource):
|
||||
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
|
||||
|
||||
Triggered by the cmd+k `/create` slash command. Returns a graph payload
|
||||
shaped exactly like ``WorkflowService.sync_draft_workflow``'s input, so the
|
||||
frontend can hand it straight to ``/apps/{id}/workflows/draft``.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_graph")
|
||||
@console_ns.doc(description="Generate a Dify workflow graph from natural language")
|
||||
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Workflow graph generated successfully", console_ns.models[GeneratorResponse.__name__])
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Reject obviously-empty instructions at the boundary — Pydantic only
|
||||
# validates ``instruction`` is a str, but a whitespace-only string
|
||||
# would still hit the LLM and waste a planner+builder roundtrip on a
|
||||
# response that the postprocess validator would reject anyway.
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
# Bound the prompt at the boundary too: an arbitrarily long
|
||||
# instruction (or pasted document) blows the planner/builder context
|
||||
# window and fails with an opaque provider error after two slow LLM
|
||||
# calls. The cap matches the frontend textarea's maxLength.
|
||||
if len(args.instruction) > _MAX_INSTRUCTION_LENGTH or len(args.ideal_output) > _MAX_INSTRUCTION_LENGTH:
|
||||
return {
|
||||
"error": "Instruction is too long",
|
||||
"errors": [
|
||||
{
|
||||
"code": "INSTRUCTION_TOO_LONG",
|
||||
"detail": f"Instruction and ideal output must each be at most "
|
||||
f"{_MAX_INSTRUCTION_LENGTH} characters",
|
||||
}
|
||||
],
|
||||
}, 400
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
instruction=args.instruction,
|
||||
model_config=args.model_config_data,
|
||||
ideal_output=args.ideal_output,
|
||||
current_graph=args.current_graph,
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
return result
|
||||
|
||||
@ -27,19 +27,13 @@ from models.model import App, AppMCPServer
|
||||
|
||||
class MCPServerCreatePayload(BaseModel):
|
||||
description: str | None = Field(default=None, description="Server description")
|
||||
parameters: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Server parameters configuration",
|
||||
)
|
||||
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
|
||||
|
||||
|
||||
class MCPServerUpdatePayload(BaseModel):
|
||||
id: str = Field(..., description="Server ID")
|
||||
description: str | None = Field(default=None, description="Server description")
|
||||
parameters: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Server parameters configuration",
|
||||
)
|
||||
parameters: dict[str, Any] = Field(..., description="Server parameters configuration")
|
||||
status: str | None = Field(default=None, description="Server status")
|
||||
|
||||
|
||||
|
||||
@ -10,10 +10,9 @@ from sqlalchemy import exists, func, select
|
||||
from werkzeug.exceptions import InternalServerError, NotFound
|
||||
|
||||
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
|
||||
from controllers.common.fields import SimpleResultResponse, TextFileResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.agent.app_helpers import resolve_agent_app_model
|
||||
from controllers.console.app.error import (
|
||||
CompletionRequestError,
|
||||
ProviderModelCurrentlyNotSupportError,
|
||||
@ -26,7 +25,6 @@ from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
@ -168,7 +166,7 @@ register_schema_models(
|
||||
MessageDetailResponse,
|
||||
MessageInfiniteScrollPaginationResponse,
|
||||
)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
|
||||
@ -176,7 +174,7 @@ class ChatMessageListApi(Resource):
|
||||
@console_ns.doc("list_chat_messages")
|
||||
@console_ns.doc(description="Get chat messages for a conversation with pagination")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
|
||||
@console_ns.expect(console_ns.models[ChatMessagesQuery.__name__])
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
|
||||
@console_ns.response(404, "Conversation not found")
|
||||
@login_required
|
||||
@ -185,25 +183,67 @@ class ChatMessageListApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
|
||||
class AgentChatMessageListApi(Resource):
|
||||
@console_ns.doc("list_agent_chat_messages")
|
||||
@console_ns.doc(description="Get Agent App chat messages for a conversation with pagination")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.doc(params=query_params_from_model(ChatMessagesQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPaginationResponse.__name__])
|
||||
@console_ns.response(404, "Agent or conversation not found")
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _list_chat_messages(app_model=app_model)
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
Message.id != first_message.id,
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
current_page_first_message = history_messages[-1]
|
||||
# Check if there are more messages before the current page
|
||||
has_more = db.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < current_page_first_message.created_at,
|
||||
Message.id != current_page_first_message.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# If we don't have a full page, there are no more messages
|
||||
has_more = False
|
||||
|
||||
history_messages = list(reversed(history_messages))
|
||||
attach_message_extra_contents(history_messages)
|
||||
|
||||
return MessageInfiniteScrollPaginationResponse.model_validate(
|
||||
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
|
||||
@ -221,25 +261,44 @@ class MessageFeedbackApi(Resource):
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/feedbacks")
|
||||
class AgentMessageFeedbackApi(Resource):
|
||||
@console_ns.doc("create_agent_message_feedback")
|
||||
@console_ns.doc(description="Create or update Agent App message feedback")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID"})
|
||||
@console_ns.expect(console_ns.models[MessageFeedbackPayload.__name__])
|
||||
@console_ns.response(200, "Feedback updated successfully", console_ns.models[SimpleResultResponse.__name__])
|
||||
@console_ns.response(404, "Agent or message not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _update_message_feedback(current_user=current_user, app_model=app_model)
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
feedback = message.admin_feedback
|
||||
|
||||
if not args.rating and feedback:
|
||||
db.session.delete(feedback)
|
||||
elif args.rating and feedback:
|
||||
feedback.rating = FeedbackRating(args.rating)
|
||||
feedback.content = args.content
|
||||
elif not args.rating and not feedback:
|
||||
raise ValueError("rating cannot be None when feedback not exists")
|
||||
else:
|
||||
rating_value = args.rating
|
||||
if rating_value is None:
|
||||
raise ValueError("rating is required to create feedback")
|
||||
feedback = MessageFeedback(
|
||||
app_id=app_model.id,
|
||||
conversation_id=message.conversation_id,
|
||||
message_id=message.id,
|
||||
rating=FeedbackRating(rating_value),
|
||||
content=args.content,
|
||||
from_source=FeedbackFromSource.ADMIN,
|
||||
from_account_id=current_user.id,
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/annotations/count")
|
||||
@ -281,28 +340,31 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App, message_id: UUID):
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
message_id_str = str(message_id)
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions")
|
||||
class AgentMessageSuggestedQuestionApi(Resource):
|
||||
@console_ns.doc("get_agent_message_suggested_questions")
|
||||
@console_ns.doc(description="Get suggested questions for an Agent App message")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Suggested questions retrieved successfully",
|
||||
console_ns.models[SuggestedQuestionsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Agent, message, or conversation not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_suggested_questions(current_user=current_user, app_model=app_model, message_id=message_id)
|
||||
return {"data": questions}
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/feedbacks/export")
|
||||
@ -310,12 +372,8 @@ class MessageFeedbackExportApi(Resource):
|
||||
@console_ns.doc("export_feedbacks")
|
||||
@console_ns.doc(description="Export user feedback data for Google Sheets")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(FeedbackExportQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Feedback data exported successfully",
|
||||
console_ns.models[TextFileResponse.__name__],
|
||||
)
|
||||
@console_ns.expect(console_ns.models[FeedbackExportQuery.__name__])
|
||||
@console_ns.response(200, "Feedback data exported successfully")
|
||||
@console_ns.response(400, "Invalid parameters")
|
||||
@console_ns.response(500, "Internal server error")
|
||||
@get_app_model
|
||||
@ -361,167 +419,14 @@ class MessageApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App, message_id: UUID):
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
message_id_str = str(message_id)
|
||||
|
||||
|
||||
@console_ns.route("/agent/<uuid:agent_id>/messages/<uuid:message_id>")
|
||||
class AgentMessageApi(Resource):
|
||||
@console_ns.doc("get_agent_message")
|
||||
@console_ns.doc(description="Get Agent App message details by ID")
|
||||
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
|
||||
@console_ns.response(200, "Message retrieved successfully", console_ns.models[MessageDetailResponse.__name__])
|
||||
@console_ns.response(404, "Agent or message not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, agent_id: UUID, message_id: UUID):
|
||||
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
|
||||
return _get_message_detail(app_model=app_model, message_id=message_id)
|
||||
|
||||
|
||||
def _list_chat_messages(*, app_model: App):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
select(Conversation)
|
||||
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
if not conversation:
|
||||
raise NotFound("Conversation Not Exists.")
|
||||
|
||||
if args.first_id:
|
||||
first_message = db.session.scalar(
|
||||
select(Message).where(Message.conversation_id == conversation.id, Message.id == args.first_id).limit(1)
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not first_message:
|
||||
raise NotFound("First message not found")
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < first_message.created_at,
|
||||
Message.id != first_message.id,
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
else:
|
||||
history_messages = db.session.scalars(
|
||||
select(Message)
|
||||
.where(Message.conversation_id == conversation.id)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(args.limit)
|
||||
).all()
|
||||
|
||||
# Initialize has_more based on whether we have a full page
|
||||
if len(history_messages) == args.limit:
|
||||
current_page_first_message = history_messages[-1]
|
||||
# Check if there are more messages before the current page
|
||||
has_more = db.session.scalar(
|
||||
select(
|
||||
exists().where(
|
||||
Message.conversation_id == conversation.id,
|
||||
Message.created_at < current_page_first_message.created_at,
|
||||
Message.id != current_page_first_message.id,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
# If we don't have a full page, there are no more messages
|
||||
has_more = False
|
||||
|
||||
history_messages = list(reversed(history_messages))
|
||||
attach_message_extra_contents(history_messages)
|
||||
|
||||
return MessageInfiniteScrollPaginationResponse.model_validate(
|
||||
InfiniteScrollPagination(data=history_messages, limit=args.limit, has_more=has_more),
|
||||
from_attributes=True,
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def _update_message_feedback(*, current_user: Account, app_model: App):
|
||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||
|
||||
message_id = str(args.message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
feedback = message.admin_feedback
|
||||
|
||||
if not args.rating and feedback:
|
||||
db.session.delete(feedback)
|
||||
elif args.rating and feedback:
|
||||
feedback.rating = FeedbackRating(args.rating)
|
||||
feedback.content = args.content
|
||||
elif not args.rating and not feedback:
|
||||
raise ValueError("rating cannot be None when feedback not exists")
|
||||
else:
|
||||
rating_value = args.rating
|
||||
if rating_value is None:
|
||||
raise ValueError("rating is required to create feedback")
|
||||
feedback = MessageFeedback(
|
||||
app_id=app_model.id,
|
||||
conversation_id=message.conversation_id,
|
||||
message_id=message.id,
|
||||
rating=FeedbackRating(rating_value),
|
||||
content=args.content,
|
||||
from_source=FeedbackFromSource.ADMIN,
|
||||
from_account_id=current_user.id,
|
||||
)
|
||||
db.session.add(feedback)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
|
||||
def _get_message_suggested_questions(*, current_user: Account, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
try:
|
||||
questions = MessageService.get_suggested_questions_after_answer(
|
||||
app_model=app_model, message_id=message_id_str, user=current_user, invoke_from=InvokeFrom.DEBUGGER
|
||||
)
|
||||
except MessageNotExistsError:
|
||||
raise NotFound("Message not found")
|
||||
except ConversationNotExistsError:
|
||||
raise NotFound("Conversation not found")
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
except SuggestedQuestionsAfterAnswerDisabledError:
|
||||
raise AppSuggestedQuestionsAfterAnswerDisabledError()
|
||||
except Exception:
|
||||
logger.exception("internal server error.")
|
||||
raise InternalServerError()
|
||||
|
||||
return {"data": questions}
|
||||
|
||||
|
||||
def _get_message_detail(*, app_model: App, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
select(Message).where(Message.id == message_id_str, Message.app_id == app_model.id).limit(1)
|
||||
)
|
||||
|
||||
if not message:
|
||||
raise NotFound("Message Not Exists.")
|
||||
|
||||
attach_message_extra_contents([message])
|
||||
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
|
||||
attach_message_extra_contents([message])
|
||||
return MessageDetailResponse.model_validate(message, from_attributes=True).model_dump(mode="json")
|
||||
|
||||
@ -5,8 +5,7 @@ from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
@ -30,44 +29,19 @@ from services.app_model_config_service import AppModelConfigService
|
||||
class ModelConfigRequest(BaseModel):
|
||||
provider: str | None = Field(default=None, description="Model provider")
|
||||
model: str | None = Field(default=None, description="Model name")
|
||||
configs: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Model configuration parameters",
|
||||
)
|
||||
configs: dict[str, Any] | None = Field(default=None, description="Model configuration parameters")
|
||||
opening_statement: str | None = Field(default=None, description="Opening statement")
|
||||
suggested_questions: list[str] | None = Field(default=None, description="Suggested questions")
|
||||
more_like_this: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="More like this configuration",
|
||||
)
|
||||
speech_to_text: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Speech to text configuration",
|
||||
)
|
||||
text_to_speech: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Text to speech configuration",
|
||||
)
|
||||
retrieval_model: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Retrieval model configuration",
|
||||
)
|
||||
tools: list[dict[str, Any]] | None = Field(
|
||||
default=None,
|
||||
description="Available tools",
|
||||
)
|
||||
dataset_configs: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Dataset configurations",
|
||||
)
|
||||
agent_mode: dict[str, Any] | None = Field(
|
||||
default=None,
|
||||
description="Agent mode configuration",
|
||||
)
|
||||
more_like_this: dict[str, Any] | None = Field(default=None, description="More like this configuration")
|
||||
speech_to_text: dict[str, Any] | None = Field(default=None, description="Speech to text configuration")
|
||||
text_to_speech: dict[str, Any] | None = Field(default=None, description="Text to speech configuration")
|
||||
retrieval_model: dict[str, Any] | None = Field(default=None, description="Retrieval model configuration")
|
||||
tools: list[dict[str, Any]] | None = Field(default=None, description="Available tools")
|
||||
dataset_configs: dict[str, Any] | None = Field(default=None, description="Dataset configurations")
|
||||
agent_mode: dict[str, Any] | None = Field(default=None, description="Agent mode configuration")
|
||||
|
||||
|
||||
register_schema_models(console_ns, ModelConfigRequest)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/model-config")
|
||||
@ -76,11 +50,7 @@ class ModelConfigResource(Resource):
|
||||
@console_ns.doc(description="Update application model configuration")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[ModelConfigRequest.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Model configuration updated successfully",
|
||||
console_ns.models[SimpleResultResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Model configuration updated successfully")
|
||||
@console_ns.response(400, "Invalid configuration")
|
||||
@console_ns.response(404, "App not found")
|
||||
@setup_required
|
||||
|
||||
@ -1,16 +1,15 @@
|
||||
from typing import Any
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import TracingConfigCheckError, TracingConfigIsExist, TracingConfigNotExist
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.ops_service import OpsService
|
||||
@ -22,27 +21,10 @@ class TraceProviderQuery(BaseModel):
|
||||
|
||||
class TraceConfigPayload(BaseModel):
|
||||
tracing_provider: str = Field(..., description="Tracing provider name")
|
||||
tracing_config: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Tracing configuration data",
|
||||
)
|
||||
|
||||
|
||||
class TraceAppConfigResponse(ResponseModel):
|
||||
result: str | None = None
|
||||
error: str | None = None
|
||||
has_not_configured: bool | None = None
|
||||
id: str | None = None
|
||||
app_id: str | None = None
|
||||
tracing_provider: str | None = None
|
||||
tracing_config: dict[str, Any] | None = Field(default=None)
|
||||
is_active: bool | None = None
|
||||
created_at: str | None = None
|
||||
updated_at: str | None = None
|
||||
tracing_config: dict[str, Any] = Field(..., description="Tracing configuration data")
|
||||
|
||||
|
||||
register_schema_models(console_ns, TraceProviderQuery, TraceConfigPayload)
|
||||
register_response_schema_models(console_ns, TraceAppConfigResponse)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/trace-config")
|
||||
@ -54,11 +36,9 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc("get_trace_app_config")
|
||||
@console_ns.doc(description="Get tracing configuration for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
|
||||
@console_ns.expect(console_ns.models[TraceProviderQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Tracing configuration retrieved successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
200, "Tracing configuration retrieved successfully", fields.Raw(description="Tracing configuration data")
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@setup_required
|
||||
@ -83,9 +63,7 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[TraceConfigPayload.__name__])
|
||||
@console_ns.response(
|
||||
201,
|
||||
"Tracing configuration created successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
201, "Tracing configuration created successfully", fields.Raw(description="Created configuration data")
|
||||
)
|
||||
@console_ns.response(400, "Invalid request parameters or configuration already exists")
|
||||
@setup_required
|
||||
@ -112,11 +90,7 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc(description="Update an existing tracing configuration for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[TraceConfigPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Tracing configuration updated successfully",
|
||||
console_ns.models[TraceAppConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Tracing configuration updated successfully", fields.Raw(description="Success response"))
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -139,7 +113,7 @@ class TraceAppConfigApi(Resource):
|
||||
@console_ns.doc("delete_trace_app_config")
|
||||
@console_ns.doc(description="Delete an existing tracing configuration for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
|
||||
@console_ns.expect(console_ns.models[TraceProviderQuery.__name__])
|
||||
@console_ns.response(204, "Tracing configuration deleted successfully")
|
||||
@console_ns.response(400, "Invalid request parameters or configuration not found")
|
||||
@setup_required
|
||||
|
||||
@ -2,16 +2,15 @@ from decimal import Decimal
|
||||
|
||||
import sqlalchemy as sa
|
||||
from flask import abort, jsonify, request
|
||||
from flask_restx import Resource
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import convert_datetime_to_date
|
||||
from libs.login import login_required
|
||||
@ -32,92 +31,7 @@ class StatisticTimeRangeQuery(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class DailyMessageStatisticItem(ResponseModel):
|
||||
date: str
|
||||
message_count: int
|
||||
|
||||
|
||||
class DailyMessageStatisticResponse(ResponseModel):
|
||||
data: list[DailyMessageStatisticItem]
|
||||
|
||||
|
||||
class DailyConversationStatisticItem(ResponseModel):
|
||||
date: str
|
||||
conversation_count: int
|
||||
|
||||
|
||||
class DailyConversationStatisticResponse(ResponseModel):
|
||||
data: list[DailyConversationStatisticItem]
|
||||
|
||||
|
||||
class DailyTerminalStatisticItem(ResponseModel):
|
||||
date: str
|
||||
terminal_count: int
|
||||
|
||||
|
||||
class DailyTerminalStatisticResponse(ResponseModel):
|
||||
data: list[DailyTerminalStatisticItem]
|
||||
|
||||
|
||||
class DailyTokenCostStatisticItem(ResponseModel):
|
||||
date: str
|
||||
token_count: int
|
||||
total_price: str | float
|
||||
currency: str
|
||||
|
||||
|
||||
class DailyTokenCostStatisticResponse(ResponseModel):
|
||||
data: list[DailyTokenCostStatisticItem]
|
||||
|
||||
|
||||
class AverageSessionInteractionStatisticItem(ResponseModel):
|
||||
date: str
|
||||
interactions: float
|
||||
|
||||
|
||||
class AverageSessionInteractionStatisticResponse(ResponseModel):
|
||||
data: list[AverageSessionInteractionStatisticItem]
|
||||
|
||||
|
||||
class UserSatisfactionRateStatisticItem(ResponseModel):
|
||||
date: str
|
||||
rate: float
|
||||
|
||||
|
||||
class UserSatisfactionRateStatisticResponse(ResponseModel):
|
||||
data: list[UserSatisfactionRateStatisticItem]
|
||||
|
||||
|
||||
class AverageResponseTimeStatisticItem(ResponseModel):
|
||||
date: str
|
||||
latency: float
|
||||
|
||||
|
||||
class AverageResponseTimeStatisticResponse(ResponseModel):
|
||||
data: list[AverageResponseTimeStatisticItem]
|
||||
|
||||
|
||||
class TokensPerSecondStatisticItem(ResponseModel):
|
||||
date: str
|
||||
tps: float
|
||||
|
||||
|
||||
class TokensPerSecondStatisticResponse(ResponseModel):
|
||||
data: list[TokensPerSecondStatisticItem]
|
||||
|
||||
|
||||
register_schema_models(console_ns, StatisticTimeRangeQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
DailyMessageStatisticResponse,
|
||||
DailyConversationStatisticResponse,
|
||||
DailyTerminalStatisticResponse,
|
||||
DailyTokenCostStatisticResponse,
|
||||
AverageSessionInteractionStatisticResponse,
|
||||
UserSatisfactionRateStatisticResponse,
|
||||
AverageResponseTimeStatisticResponse,
|
||||
TokensPerSecondStatisticResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/statistics/daily-messages")
|
||||
@ -125,11 +39,11 @@ class DailyMessageStatistic(Resource):
|
||||
@console_ns.doc("get_daily_message_statistics")
|
||||
@console_ns.doc(description="Get daily message statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily message statistics retrieved successfully",
|
||||
console_ns.models[DailyMessageStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Daily message count data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -185,11 +99,11 @@ class DailyConversationStatistic(Resource):
|
||||
@console_ns.doc("get_daily_conversation_statistics")
|
||||
@console_ns.doc(description="Get daily conversation statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily conversation statistics retrieved successfully",
|
||||
console_ns.models[DailyConversationStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Daily conversation count data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -244,11 +158,11 @@ class DailyTerminalsStatistic(Resource):
|
||||
@console_ns.doc("get_daily_terminals_statistics")
|
||||
@console_ns.doc(description="Get daily terminal/end-user statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily terminal statistics retrieved successfully",
|
||||
console_ns.models[DailyTerminalStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Daily terminal count data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -304,11 +218,11 @@ class DailyTokenCostStatistic(Resource):
|
||||
@console_ns.doc("get_daily_token_cost_statistics")
|
||||
@console_ns.doc(description="Get daily token cost statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily token cost statistics retrieved successfully",
|
||||
console_ns.models[DailyTokenCostStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Daily token cost data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -367,11 +281,11 @@ class AverageSessionInteractionStatistic(Resource):
|
||||
@console_ns.doc("get_average_session_interaction_statistics")
|
||||
@console_ns.doc(description="Get average session interaction statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Average session interaction statistics retrieved successfully",
|
||||
console_ns.models[AverageSessionInteractionStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Average session interaction data")),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -446,11 +360,11 @@ class UserSatisfactionRateStatistic(Resource):
|
||||
@console_ns.doc("get_user_satisfaction_rate_statistics")
|
||||
@console_ns.doc(description="Get user satisfaction rate statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"User satisfaction rate statistics retrieved successfully",
|
||||
console_ns.models[UserSatisfactionRateStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="User satisfaction rate data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@ -515,11 +429,11 @@ class AverageResponseTimeStatistic(Resource):
|
||||
@console_ns.doc("get_average_response_time_statistics")
|
||||
@console_ns.doc(description="Get average response time statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Average response time statistics retrieved successfully",
|
||||
console_ns.models[AverageResponseTimeStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Average response time data")),
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -575,11 +489,11 @@ class TokensPerSecondStatistic(Resource):
|
||||
@console_ns.doc("get_tokens_per_second_statistics")
|
||||
@console_ns.doc(description="Get tokens per second statistics for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(StatisticTimeRangeQuery))
|
||||
@console_ns.expect(console_ns.models[StatisticTimeRangeQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Tokens per second statistics retrieved successfully",
|
||||
console_ns.models[TokensPerSecondStatisticResponse.__name__],
|
||||
fields.List(fields.Raw(description="Tokens per second data")),
|
||||
)
|
||||
@get_app_model
|
||||
@setup_required
|
||||
|
||||
@ -2,38 +2,26 @@ import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any, NotRequired, TypedDict, cast
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
from flask import abort, request
|
||||
from flask_restx import Resource, fields
|
||||
from pydantic import AliasChoices, BaseModel, Field, RootModel, ValidationError, field_validator
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from pydantic import AliasChoices, BaseModel, Field, ValidationError, field_validator
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import BadRequest, Forbidden, InternalServerError, NotFound
|
||||
|
||||
import services
|
||||
from controllers.common.controller_schemas import DefaultBlockConfigQuery, WorkflowListQuery, WorkflowUpdatePayload
|
||||
from controllers.common.errors import InvalidArgumentError
|
||||
from controllers.common.fields import GeneratedAppResponse, NewAppResponse, SimpleResultResponse
|
||||
from controllers.common.fields import NewAppResponse, SimpleResultResponse
|
||||
from controllers.common.schema import (
|
||||
query_params_from_model,
|
||||
register_response_schema_model,
|
||||
register_response_schema_models,
|
||||
register_schema_models,
|
||||
)
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
ConversationCompletedError,
|
||||
DraftWorkflowNotExist,
|
||||
DraftWorkflowNotSync,
|
||||
)
|
||||
from controllers.console.app.error import ConversationCompletedError, DraftWorkflowNotExist, DraftWorkflowNotSync
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
|
||||
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
|
||||
from core.app.apps.base_app_queue_manager import AppQueueManager
|
||||
@ -62,12 +50,11 @@ from graphon.file import helpers as file_helpers
|
||||
from graphon.graph_engine.manager import GraphEngineManager
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from graphon.variables import SecretVariable, SegmentType, VariableBase
|
||||
from graphon.variables.exc import VariableError
|
||||
from libs import helper
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account, App
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import App
|
||||
from models.model import AppMode
|
||||
from models.workflow import Workflow
|
||||
from repositories.workflow_collaboration_repository import WORKFLOW_ONLINE_USERS_PREFIX
|
||||
@ -99,20 +86,16 @@ class SyncDraftWorkflowPayload(BaseModel):
|
||||
graph: dict[str, Any]
|
||||
features: dict[str, Any]
|
||||
hash: str | None = None
|
||||
environment_variables: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
)
|
||||
conversation_variables: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
)
|
||||
environment_variables: list[dict[str, Any]] = Field(default_factory=list)
|
||||
conversation_variables: list[dict[str, Any]] = Field(default_factory=list)
|
||||
|
||||
|
||||
class BaseWorkflowRunPayload(BaseModel):
|
||||
files: list[dict[str, Any]] | None = Field(default=None)
|
||||
files: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload):
|
||||
inputs: dict[str, Any] | None = Field(default=None)
|
||||
inputs: dict[str, Any] | None = None
|
||||
query: str = ""
|
||||
conversation_id: str | None = None
|
||||
parent_message_id: str | None = None
|
||||
@ -126,11 +109,11 @@ class AdvancedChatWorkflowRunPayload(BaseWorkflowRunPayload):
|
||||
|
||||
|
||||
class IterationNodeRunPayload(BaseModel):
|
||||
inputs: dict[str, Any] | None = Field(default=None)
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class LoopNodeRunPayload(BaseModel):
|
||||
inputs: dict[str, Any] | None = Field(default=None)
|
||||
inputs: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class DraftWorkflowRunPayload(BaseWorkflowRunPayload):
|
||||
@ -155,10 +138,7 @@ class ConvertToWorkflowPayload(BaseModel):
|
||||
|
||||
|
||||
class WorkflowFeaturesPayload(BaseModel):
|
||||
features: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Workflow feature configuration",
|
||||
)
|
||||
features: dict[str, Any] = Field(..., description="Workflow feature configuration")
|
||||
|
||||
|
||||
class WorkflowOnlineUsersPayload(BaseModel):
|
||||
@ -174,7 +154,7 @@ class WorkflowConversationVariableResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
value_type: str
|
||||
value: Any
|
||||
value: Any = Field(json_schema_extra={"type": "object"})
|
||||
description: str
|
||||
|
||||
@field_validator("value_type", mode="before")
|
||||
@ -193,7 +173,7 @@ class PipelineVariableResponse(ResponseModel):
|
||||
max_length: int | None = None
|
||||
required: bool
|
||||
unit: str | None = None
|
||||
default_value: Any = Field(default=None)
|
||||
default_value: Any = Field(default=None, json_schema_extra={"type": "object"})
|
||||
options: list[str] | None = None
|
||||
placeholder: str | None = None
|
||||
tooltips: str | None = None
|
||||
@ -210,18 +190,14 @@ class WorkflowEnvironmentVariableResponse(ResponseModel):
|
||||
value_type: str
|
||||
id: str
|
||||
name: str
|
||||
value: Any
|
||||
value: Any = Field(json_schema_extra={"type": "object"})
|
||||
description: str
|
||||
|
||||
|
||||
class WorkflowResponse(ResponseModel):
|
||||
id: str
|
||||
graph: dict[str, Any] = Field(
|
||||
validation_alias=AliasChoices("graph_dict", "graph"),
|
||||
)
|
||||
features: dict[str, Any] = Field(
|
||||
validation_alias=AliasChoices("features_dict", "features"),
|
||||
)
|
||||
graph: dict[str, Any] = Field(validation_alias=AliasChoices("graph_dict", "graph"))
|
||||
features: dict[str, Any] = Field(validation_alias=AliasChoices("features_dict", "features"))
|
||||
hash: str = Field(validation_alias=AliasChoices("unique_hash", "hash"))
|
||||
version: str
|
||||
marked_name: str
|
||||
@ -278,46 +254,6 @@ class WorkflowOnlineUsersResponse(ResponseModel):
|
||||
data: list[WorkflowOnlineUsersByApp]
|
||||
|
||||
|
||||
class WorkflowPublishResponse(ResponseModel):
|
||||
result: str
|
||||
created_at: int
|
||||
|
||||
|
||||
class WorkflowRestoreResponse(ResponseModel):
|
||||
result: str
|
||||
hash: str
|
||||
updated_at: int
|
||||
|
||||
|
||||
class DefaultBlockConfigsResponse(RootModel[list[dict[str, Any]]]):
|
||||
root: list[dict[str, Any]]
|
||||
|
||||
|
||||
class DefaultBlockConfigResponse(RootModel[dict[str, Any]]):
|
||||
root: dict[str, Any]
|
||||
|
||||
|
||||
class HumanInputFormPreviewResponse(ResponseModel):
|
||||
form_id: str
|
||||
node_id: str
|
||||
node_title: str
|
||||
form_content: str
|
||||
inputs: list[dict[str, Any]] = Field(default_factory=list)
|
||||
actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
display_in_ui: bool | None = None
|
||||
form_token: str | None = None
|
||||
resolved_default_values: dict[str, Any] = Field(default_factory=dict)
|
||||
expiration_time: int | None = None
|
||||
|
||||
|
||||
class HumanInputFormSubmitResponse(RootModel[dict[str, Any]]):
|
||||
root: dict[str, Any]
|
||||
|
||||
|
||||
class EmptyObjectResponse(RootModel[dict[str, Any]]):
|
||||
root: dict[str, Any]
|
||||
|
||||
|
||||
class DraftWorkflowTriggerRunPayload(BaseModel):
|
||||
node_id: str
|
||||
|
||||
@ -355,14 +291,6 @@ register_response_schema_models(
|
||||
WorkflowOnlineUser,
|
||||
WorkflowOnlineUsersByApp,
|
||||
WorkflowOnlineUsersResponse,
|
||||
WorkflowPublishResponse,
|
||||
WorkflowRestoreResponse,
|
||||
DefaultBlockConfigsResponse,
|
||||
DefaultBlockConfigResponse,
|
||||
HumanInputFormPreviewResponse,
|
||||
HumanInputFormSubmitResponse,
|
||||
EmptyObjectResponse,
|
||||
GeneratedAppResponse,
|
||||
NewAppResponse,
|
||||
SimpleResultResponse,
|
||||
)
|
||||
@ -449,16 +377,8 @@ class DraftWorkflowApi(Resource):
|
||||
if not workflow:
|
||||
raise DraftWorkflowNotExist()
|
||||
|
||||
from services.agent.workflow_publish_service import WorkflowAgentPublishService
|
||||
|
||||
# Return workflow with response-only Agent node job projection so the
|
||||
# front-end can treat draft graph node data as the editing source.
|
||||
response = WorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json")
|
||||
response["graph"] = WorkflowAgentPublishService.project_draft_bindings_to_graph(
|
||||
session=cast(Session, db.session),
|
||||
draft_workflow=workflow,
|
||||
)
|
||||
return response
|
||||
# return workflow, if not found, return 404
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -481,12 +401,13 @@ class DraftWorkflowApi(Resource):
|
||||
)
|
||||
@console_ns.response(400, "Invalid workflow configuration")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Sync draft workflow
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
content_type = request.headers.get("Content-Type", "")
|
||||
|
||||
if "application/json" in content_type:
|
||||
@ -526,8 +447,6 @@ class DraftWorkflowApi(Resource):
|
||||
)
|
||||
except WorkflowHashNotEqualError:
|
||||
raise DraftWorkflowNotSync()
|
||||
except VariableError as e:
|
||||
raise InvalidArgumentError(description=str(e))
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
@ -542,19 +461,20 @@ class AdvancedChatDraftWorkflowRunApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow for advanced chat application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[AdvancedChatWorkflowRunPayload.__name__])
|
||||
@console_ns.response(200, "Workflow run started successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(200, "Workflow run started successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Run draft workflow
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args_model = AdvancedChatWorkflowRunPayload.model_validate(console_ns.payload or {})
|
||||
args = args_model.model_dump(exclude_none=True)
|
||||
|
||||
@ -587,23 +507,19 @@ class AdvancedChatDraftRunIterationNodeApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow iteration node for advanced chat")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Iteration node run started successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Iteration node run started successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(404, "Node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow iteration node
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
@ -629,23 +545,19 @@ class WorkflowDraftRunIterationNodeApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow iteration node")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[IterationNodeRunPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow iteration node run started successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Workflow iteration node run started successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(404, "Node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow iteration node
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = IterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
|
||||
try:
|
||||
@ -671,19 +583,19 @@ class AdvancedChatDraftRunLoopNodeApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow loop node for advanced chat")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__])
|
||||
@console_ns.response(200, "Loop node run started successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(200, "Loop node run started successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(404, "Node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow loop node
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
@ -709,23 +621,19 @@ class WorkflowDraftRunLoopNodeApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow loop node")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[LoopNodeRunPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow loop node run started successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Workflow loop node run started successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(404, "Node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow loop node
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = LoopNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
try:
|
||||
@ -753,10 +661,7 @@ class HumanInputFormPreviewPayload(BaseModel):
|
||||
|
||||
|
||||
class HumanInputFormSubmitPayload(BaseModel):
|
||||
form_inputs: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Values the user provides for the form's own fields",
|
||||
)
|
||||
form_inputs: dict[str, Any] = Field(..., description="Values the user provides for the form's own fields")
|
||||
inputs: dict[str, Any] = Field(
|
||||
...,
|
||||
description="Values used to fill missing upstream variables referenced in form_content",
|
||||
@ -786,17 +691,16 @@ class AdvancedChatDraftHumanInputFormPreviewApi(Resource):
|
||||
@console_ns.doc(description="Get human input form preview for advanced chat workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
|
||||
@console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Preview human input form content and placeholders
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
|
||||
inputs = args.inputs
|
||||
|
||||
@ -816,21 +720,16 @@ class AdvancedChatDraftHumanInputFormRunApi(Resource):
|
||||
@console_ns.doc(description="Submit human input form preview for advanced chat workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Human input form submission result",
|
||||
console_ns.models[HumanInputFormSubmitResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Submit human input form preview
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
|
||||
workflow_service = WorkflowService()
|
||||
result = workflow_service.submit_human_input_form_preview(
|
||||
@ -850,17 +749,16 @@ class WorkflowDraftHumanInputFormPreviewApi(Resource):
|
||||
@console_ns.doc(description="Get human input form preview for workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[HumanInputFormPreviewPayload.__name__])
|
||||
@console_ns.response(200, "Human input form preview", console_ns.models[HumanInputFormPreviewResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Preview human input form content and placeholders
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = HumanInputFormPreviewPayload.model_validate(console_ns.payload or {})
|
||||
inputs = args.inputs
|
||||
|
||||
@ -880,21 +778,16 @@ class WorkflowDraftHumanInputFormRunApi(Resource):
|
||||
@console_ns.doc(description="Submit human input form preview for workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[HumanInputFormSubmitPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Human input form submission result",
|
||||
console_ns.models[HumanInputFormSubmitResponse.__name__],
|
||||
)
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Submit human input form preview
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
workflow_service = WorkflowService()
|
||||
args = HumanInputFormSubmitPayload.model_validate(console_ns.payload or {})
|
||||
result = workflow_service.submit_human_input_form_preview(
|
||||
@ -914,17 +807,16 @@ class WorkflowDraftHumanInputDeliveryTestApi(Resource):
|
||||
@console_ns.doc(description="Test human input delivery for workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.expect(console_ns.models[HumanInputDeliveryTestPayload.__name__])
|
||||
@console_ns.response(200, "Human input delivery test result", console_ns.models[EmptyObjectResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Test human input delivery
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
workflow_service = WorkflowService()
|
||||
args = HumanInputDeliveryTestPayload.model_validate(console_ns.payload or {})
|
||||
workflow_service.test_human_input_delivery(
|
||||
@ -943,22 +835,18 @@ class DraftWorkflowRunApi(Resource):
|
||||
@console_ns.doc(description="Run draft workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[DraftWorkflowRunPayload.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Draft workflow run started successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Draft workflow run started successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Run draft workflow
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = DraftWorkflowRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True)
|
||||
|
||||
external_trace_id = get_external_trace_id(request)
|
||||
@ -1023,12 +911,12 @@ class DraftWorkflowNodeRunApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Run draft workflow node
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args_model = DraftWorkflowNodeRunPayload.model_validate(console_ns.payload or {})
|
||||
args = args_model.model_dump(exclude_none=True)
|
||||
|
||||
@ -1089,17 +977,16 @@ class PublishedWorkflowApi(Resource):
|
||||
return dump_response(WorkflowResponse, workflow)
|
||||
|
||||
@console_ns.expect(console_ns.models[PublishWorkflowPayload.__name__])
|
||||
@console_ns.response(200, "Workflow published successfully", console_ns.models[WorkflowPublishResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Publish workflow
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = PublishWorkflowPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@ -1133,11 +1020,7 @@ class DefaultBlockConfigsApi(Resource):
|
||||
@console_ns.doc("get_default_block_configs")
|
||||
@console_ns.doc(description="Get default block configurations for workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Default block configurations retrieved successfully",
|
||||
console_ns.models[DefaultBlockConfigsResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Default block configurations retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -1157,13 +1040,9 @@ class DefaultBlockConfigApi(Resource):
|
||||
@console_ns.doc("get_default_block_config")
|
||||
@console_ns.doc(description="Get default block configuration by type")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "block_type": "Block type"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Default block configuration retrieved successfully",
|
||||
console_ns.models[DefaultBlockConfigResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Default block configuration retrieved successfully")
|
||||
@console_ns.response(404, "Block type not found")
|
||||
@console_ns.doc(params=query_params_from_model(DefaultBlockConfigQuery))
|
||||
@console_ns.expect(console_ns.models[DefaultBlockConfigQuery.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -1204,14 +1083,14 @@ class ConvertToWorkflowApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.COMPLETION])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Convert basic mode of chatbot app to workflow mode
|
||||
Convert expert mode of chatbot app to workflow mode
|
||||
Convert Completion App to Workflow App
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
payload = console_ns.payload or {}
|
||||
args = ConvertToWorkflowPayload.model_validate(payload).model_dump(exclude_none=True)
|
||||
@ -1243,9 +1122,9 @@ class WorkflowFeaturesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowFeaturesPayload.model_validate(console_ns.payload or {})
|
||||
features = args.features
|
||||
@ -1258,7 +1137,7 @@ class WorkflowFeaturesApi(Resource):
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows")
|
||||
class PublishedAllWorkflowApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowListQuery))
|
||||
@console_ns.expect(console_ns.models[WorkflowListQuery.__name__])
|
||||
@console_ns.doc("get_all_published_workflows")
|
||||
@console_ns.doc(description="Get all published workflows for an application")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@ -1271,12 +1150,12 @@ class PublishedAllWorkflowApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
Get published workflows
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowListQuery.model_validate(request.args.to_dict(flat=True))
|
||||
page = args.page
|
||||
@ -1313,16 +1192,16 @@ class DraftWorkflowRestoreApi(Resource):
|
||||
@console_ns.doc("restore_workflow_to_draft")
|
||||
@console_ns.doc(description="Restore a published workflow version into the draft workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "workflow_id": "Published workflow ID"})
|
||||
@console_ns.response(200, "Workflow restored successfully", console_ns.models[WorkflowRestoreResponse.__name__])
|
||||
@console_ns.response(200, "Workflow restored successfully")
|
||||
@console_ns.response(400, "Source workflow must be published")
|
||||
@console_ns.response(404, "Workflow not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, workflow_id: str):
|
||||
def post(self, app_model: App, workflow_id: str):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
try:
|
||||
@ -1358,12 +1237,12 @@ class WorkflowByIdApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def patch(self, current_user: Account, app_model: App, workflow_id: str):
|
||||
def patch(self, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Update workflow attributes
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = WorkflowUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
# Prepare update data
|
||||
@ -1398,7 +1277,6 @@ class WorkflowByIdApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@edit_permission_required
|
||||
@console_ns.response(204, "Workflow deleted successfully")
|
||||
def delete(self, app_model: App, workflow_id: str):
|
||||
"""
|
||||
Delete workflow
|
||||
@ -1470,23 +1348,19 @@ class DraftWorkflowTriggerRunApi(Resource):
|
||||
},
|
||||
)
|
||||
)
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Trigger event received and workflow executed successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Trigger event received and workflow executed successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(500, "Internal server error")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Poll for trigger events and execute full workflow when event arrives
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = DraftWorkflowTriggerRunPayload.model_validate(console_ns.payload or {})
|
||||
node_id = args.node_id
|
||||
workflow_service = WorkflowService()
|
||||
@ -1538,23 +1412,19 @@ class DraftWorkflowTriggerNodeApi(Resource):
|
||||
@console_ns.doc("poll_draft_workflow_trigger_node")
|
||||
@console_ns.doc(description="Poll for trigger events and execute single node when event arrives")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "node_id": "Node ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Trigger event received and node executed successfully",
|
||||
console_ns.models[GeneratedAppResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Trigger event received and node executed successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(500, "Internal server error")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App, node_id: str):
|
||||
def post(self, app_model: App, node_id: str):
|
||||
"""
|
||||
Poll for trigger events and execute single node when event arrives
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
draft_workflow = workflow_service.get_draft_workflow(app_model)
|
||||
@ -1622,19 +1492,19 @@ class DraftWorkflowTriggerRunAllApi(Resource):
|
||||
@console_ns.doc(description="Full workflow debug when the start node is a trigger")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.expect(console_ns.models[DraftWorkflowTriggerRunAllPayload.__name__])
|
||||
@console_ns.response(200, "Workflow executed successfully", console_ns.models[GeneratedAppResponse.__name__])
|
||||
@console_ns.response(200, "Workflow executed successfully")
|
||||
@console_ns.response(403, "Permission denied")
|
||||
@console_ns.response(500, "Internal server error")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@edit_permission_required
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""
|
||||
Full workflow debug when the start node is a trigger
|
||||
"""
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = DraftWorkflowTriggerRunAllPayload.model_validate(console_ns.payload or {})
|
||||
node_ids = args.node_ids
|
||||
@ -1695,8 +1565,7 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
def post(self):
|
||||
args = WorkflowOnlineUsersPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_ids = args.app_ids
|
||||
@ -1706,6 +1575,7 @@ class WorkflowOnlineUsersApi(Resource):
|
||||
if not app_ids:
|
||||
return {"data": []}
|
||||
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
workflow_service = WorkflowService()
|
||||
accessible_app_ids = workflow_service.get_accessible_app_ids(app_ids, current_tenant_id)
|
||||
ordered_accessible_app_ids = [app_id for app_id in app_ids if app_id in accessible_app_ids]
|
||||
|
||||
@ -7,7 +7,7 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
@ -166,7 +166,7 @@ class WorkflowAppLogApi(Resource):
|
||||
@console_ns.doc("get_workflow_app_logs")
|
||||
@console_ns.doc(description="Get workflow application execution logs")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery))
|
||||
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow app logs retrieved successfully",
|
||||
@ -209,7 +209,7 @@ class WorkflowArchivedLogApi(Resource):
|
||||
@console_ns.doc("get_workflow_archived_logs")
|
||||
@console_ns.doc(description="Get workflow archived execution logs")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowAppLogQuery))
|
||||
@console_ns.expect(console_ns.models[WorkflowAppLogQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow archived logs retrieved successfully",
|
||||
|
||||
@ -7,18 +7,12 @@ from pydantic import BaseModel, Field, TypeAdapter, computed_field, field_valida
|
||||
from controllers.common.schema import register_response_schema_models, register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from fields.base import ResponseModel
|
||||
from fields.member_fields import AccountWithRole
|
||||
from libs.helper import build_avatar_url, dump_response, to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import Account, App
|
||||
from libs.login import current_user, login_required
|
||||
from models import App
|
||||
from services.account_service import TenantService
|
||||
from services.workflow_comment_service import WorkflowCommentService
|
||||
|
||||
@ -219,10 +213,9 @@ class WorkflowCommentListApi(Resource):
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""Get all comments for a workflow."""
|
||||
comments = WorkflowCommentService.get_comments(tenant_id=current_tenant_id, app_id=app_model.id)
|
||||
comments = WorkflowCommentService.get_comments(tenant_id=current_user.current_tenant_id, app_id=app_model.id)
|
||||
|
||||
return WorkflowCommentBasicList.model_validate({"data": comments}).model_dump(mode="json")
|
||||
|
||||
@ -236,14 +229,12 @@ class WorkflowCommentListApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""Create a new workflow comment."""
|
||||
payload = WorkflowCommentCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
result = WorkflowCommentService.create_comment(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
created_by=current_user.id,
|
||||
content=payload.content,
|
||||
@ -267,11 +258,10 @@ class WorkflowCommentDetailApi(Resource):
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, app_model: App, comment_id: str):
|
||||
def get(self, app_model: App, comment_id: str):
|
||||
"""Get a specific workflow comment."""
|
||||
comment = WorkflowCommentService.get_comment(
|
||||
tenant_id=current_tenant_id, app_id=app_model.id, comment_id=comment_id
|
||||
tenant_id=current_user.current_tenant_id, app_id=app_model.id, comment_id=comment_id
|
||||
)
|
||||
|
||||
return dump_response(WorkflowCommentDetail, comment)
|
||||
@ -286,14 +276,12 @@ class WorkflowCommentDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str):
|
||||
def put(self, app_model: App, comment_id: str):
|
||||
"""Update a workflow comment."""
|
||||
payload = WorkflowCommentUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
result = WorkflowCommentService.update_comment(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
@ -314,12 +302,10 @@ class WorkflowCommentDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str):
|
||||
def delete(self, app_model: App, comment_id: str):
|
||||
"""Delete a workflow comment."""
|
||||
WorkflowCommentService.delete_comment(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
@ -341,12 +327,10 @@ class WorkflowCommentResolveApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str):
|
||||
def post(self, app_model: App, comment_id: str):
|
||||
"""Resolve a workflow comment."""
|
||||
comment = WorkflowCommentService.resolve_comment(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
user_id=current_user.id,
|
||||
@ -369,13 +353,11 @@ class WorkflowCommentReplyApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str):
|
||||
def post(self, app_model: App, comment_id: str):
|
||||
"""Add a reply to a workflow comment."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_tenant_id, app_id=app_model.id
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
|
||||
@ -404,19 +386,17 @@ class WorkflowCommentReplyDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def put(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str):
|
||||
def put(self, app_model: App, comment_id: str, reply_id: str):
|
||||
"""Update a comment reply."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_tenant_id, app_id=app_model.id
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
payload = WorkflowCommentReplyPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
reply = WorkflowCommentService.update_reply(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
reply_id=reply_id,
|
||||
@ -436,17 +416,15 @@ class WorkflowCommentReplyDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@edit_permission_required
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def delete(self, current_tenant_id: str, current_user: Account, app_model: App, comment_id: str, reply_id: str):
|
||||
def delete(self, app_model: App, comment_id: str, reply_id: str):
|
||||
"""Delete a comment reply."""
|
||||
# Validate comment access first
|
||||
WorkflowCommentService.validate_comment_access(
|
||||
comment_id=comment_id, tenant_id=current_tenant_id, app_id=app_model.id
|
||||
comment_id=comment_id, tenant_id=current_user.current_tenant_id, app_id=app_model.id
|
||||
)
|
||||
|
||||
WorkflowCommentService.delete_reply(
|
||||
tenant_id=current_tenant_id,
|
||||
tenant_id=current_user.current_tenant_id,
|
||||
app_id=app_model.id,
|
||||
comment_id=comment_id,
|
||||
reply_id=reply_id,
|
||||
@ -470,13 +448,9 @@ class WorkflowCommentMentionUsersApi(Resource):
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""Get all users in current tenant for mentions."""
|
||||
current_tenant = current_user.current_tenant # need the tenant object here
|
||||
if current_tenant is None:
|
||||
raise ValueError("current tenant is required")
|
||||
members = TenantService.get_tenant_members(current_tenant)
|
||||
members = TenantService.get_tenant_members(current_user.current_tenant)
|
||||
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
|
||||
response = WorkflowCommentMentionUsersPayload(users=users)
|
||||
return response.model_dump(mode="json"), 200
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate, TypedDict, override
|
||||
from typing import Any, Concatenate, TypedDict
|
||||
from uuid import UUID
|
||||
|
||||
from flask import Response, request
|
||||
@ -9,33 +9,26 @@ from flask_restx import Resource, fields, marshal, marshal_with
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.error import (
|
||||
DraftWorkflowNotExist,
|
||||
)
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
edit_permission_required,
|
||||
setup_required,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from controllers.web.error import InvalidArgumentError, NotFoundError
|
||||
from core.app.file_access import DatabaseFileAccessController
|
||||
from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from factories.file_factory import build_from_mapping, build_from_mappings
|
||||
from factories.variable_factory import build_segment_with_type
|
||||
from fields.base import ResponseModel
|
||||
from graphon.file import helpers as file_helpers
|
||||
from graphon.variables.segment_group import SegmentGroup
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, Segment
|
||||
from graphon.variables.types import SegmentType
|
||||
from libs.login import login_required
|
||||
from models import Account, App, AppMode
|
||||
from libs.login import current_user, login_required
|
||||
from models import App, AppMode
|
||||
from models.workflow import WorkflowDraftVariable
|
||||
from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService
|
||||
from services.workflow_service import WorkflowService
|
||||
@ -44,28 +37,6 @@ logger = logging.getLogger(__name__)
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
|
||||
|
||||
class OpaqueRawField(fields.Raw):
|
||||
@override
|
||||
def schema(self) -> dict[str, object]:
|
||||
return {"type": "object"}
|
||||
|
||||
|
||||
class JsonValueRawField(fields.Raw):
|
||||
@override
|
||||
def schema(self) -> dict[str, object]:
|
||||
return {
|
||||
"anyOf": [
|
||||
{"type": "string"},
|
||||
{"type": "integer"},
|
||||
{"type": "number"},
|
||||
{"type": "boolean"},
|
||||
{"type": "object", "additionalProperties": True},
|
||||
{"type": "array", "items": {}},
|
||||
{"type": "null"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class WorkflowDraftVariableListQuery(BaseModel):
|
||||
page: int = Field(default=1, ge=1, le=100_000, description="Page number")
|
||||
limit: int = Field(default=20, ge=1, le=100, description="Items per page")
|
||||
@ -78,33 +49,12 @@ class WorkflowDraftVariableUpdatePayload(BaseModel):
|
||||
|
||||
class ConversationVariableUpdatePayload(BaseModel):
|
||||
conversation_variables: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description="Conversation variables for the draft workflow",
|
||||
..., description="Conversation variables for the draft workflow"
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentVariableUpdatePayload(BaseModel):
|
||||
environment_variables: list[dict[str, Any]] = Field(
|
||||
...,
|
||||
description="Environment variables for the draft workflow",
|
||||
)
|
||||
|
||||
|
||||
class EnvironmentVariableItemResponse(ResponseModel):
|
||||
id: str
|
||||
type: str
|
||||
name: str
|
||||
description: str | None = None
|
||||
selector: list[str]
|
||||
value_type: str
|
||||
value: Any
|
||||
edited: bool
|
||||
visible: bool
|
||||
editable: bool
|
||||
|
||||
|
||||
class EnvironmentVariableListResponse(ResponseModel):
|
||||
items: list[EnvironmentVariableItemResponse]
|
||||
environment_variables: list[dict[str, Any]] = Field(..., description="Environment variables for the draft workflow")
|
||||
|
||||
|
||||
register_schema_models(
|
||||
@ -114,7 +64,6 @@ register_schema_models(
|
||||
ConversationVariableUpdatePayload,
|
||||
EnvironmentVariableUpdatePayload,
|
||||
)
|
||||
register_response_schema_models(console_ns, SimpleResultResponse, EnvironmentVariableListResponse)
|
||||
|
||||
|
||||
def _convert_values_to_json_serializable_object(value: Segment):
|
||||
@ -174,15 +123,14 @@ def _serialize_full_content(variable: WorkflowDraftVariable) -> FullContentDict
|
||||
return result
|
||||
|
||||
|
||||
def ensure_variable_access(
|
||||
def _ensure_variable_access(
|
||||
variable: WorkflowDraftVariable | None,
|
||||
app_id: str,
|
||||
variable_id: str,
|
||||
current_user_id: str,
|
||||
) -> WorkflowDraftVariable:
|
||||
if variable is None:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id}")
|
||||
if variable.app_id != app_id or variable.user_id != current_user_id:
|
||||
if variable.app_id != app_id or variable.user_id != current_user.id:
|
||||
raise NotFoundError(description=f"variable not found, id={variable_id}")
|
||||
return variable
|
||||
|
||||
@ -201,8 +149,8 @@ _WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS = {
|
||||
|
||||
_WORKFLOW_DRAFT_VARIABLE_FIELDS = {
|
||||
**_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS,
|
||||
"value": JsonValueRawField(attribute=_serialize_var_value),
|
||||
"full_content": OpaqueRawField(attribute=_serialize_full_content),
|
||||
"value": fields.Raw(attribute=_serialize_var_value),
|
||||
"full_content": fields.Raw(attribute=_serialize_full_content),
|
||||
}
|
||||
|
||||
_WORKFLOW_DRAFT_ENV_VARIABLE_FIELDS = {
|
||||
@ -227,7 +175,7 @@ def _get_items(var_list: WorkflowDraftVariableList) -> list[WorkflowDraftVariabl
|
||||
|
||||
_WORKFLOW_DRAFT_VARIABLE_LIST_WITHOUT_VALUE_FIELDS = {
|
||||
"items": fields.List(fields.Nested(_WORKFLOW_DRAFT_VARIABLE_WITHOUT_VALUE_FIELDS), attribute=_get_items),
|
||||
"total": fields.Integer,
|
||||
"total": fields.Raw(),
|
||||
}
|
||||
|
||||
_WORKFLOW_DRAFT_VARIABLE_LIST_FIELDS = {
|
||||
@ -267,7 +215,7 @@ workflow_draft_variable_list_model = console_ns.model(
|
||||
|
||||
|
||||
def _api_prerequisite[T, **P, R](
|
||||
f: Callable[Concatenate[T, Account, P], R],
|
||||
f: Callable[Concatenate[T, P], R],
|
||||
) -> Callable[Concatenate[T, P], R | Response]:
|
||||
"""Common prerequisites for all draft workflow variable APIs.
|
||||
|
||||
@ -284,17 +232,16 @@ def _api_prerequisite[T, **P, R](
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
@wraps(f)
|
||||
def wrapper(self: T, current_user: Account, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
return f(self, current_user, *args, **kwargs)
|
||||
def wrapper(self: T, *args: P.args, **kwargs: P.kwargs) -> R | Response:
|
||||
return f(self, *args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/variables")
|
||||
class WorkflowVariableCollectionApi(Resource):
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowDraftVariableListQuery))
|
||||
@console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__])
|
||||
@console_ns.doc("get_workflow_variables")
|
||||
@console_ns.doc(description="Get draft workflow variables")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@ -304,7 +251,7 @@ class WorkflowVariableCollectionApi(Resource):
|
||||
)
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_without_value_model)
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
Get draft workflow
|
||||
"""
|
||||
@ -334,7 +281,7 @@ class WorkflowVariableCollectionApi(Resource):
|
||||
@console_ns.doc(description="Delete all draft workflow variables")
|
||||
@console_ns.response(204, "Workflow variables deleted successfully")
|
||||
@_api_prerequisite
|
||||
def delete(self, current_user: Account, app_model: App):
|
||||
def delete(self, app_model: App):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
@ -368,7 +315,7 @@ class NodeVariableCollectionApi(Resource):
|
||||
@console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model)
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, current_user: Account, app_model: App, node_id: str):
|
||||
def get(self, app_model: App, node_id: str):
|
||||
validate_node_id(node_id)
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
@ -382,7 +329,7 @@ class NodeVariableCollectionApi(Resource):
|
||||
@console_ns.doc(description="Delete all variables for a specific node")
|
||||
@console_ns.response(204, "Node variables deleted successfully")
|
||||
@_api_prerequisite
|
||||
def delete(self, current_user: Account, app_model: App, node_id: str):
|
||||
def delete(self, app_model: App, node_id: str):
|
||||
validate_node_id(node_id)
|
||||
srv = WorkflowDraftVariableService(db.session())
|
||||
srv.delete_node_variables(app_model.id, node_id, user_id=current_user.id)
|
||||
@ -402,16 +349,15 @@ class VariableApi(Resource):
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_model)
|
||||
def get(self, current_user: Account, app_model: App, variable_id: UUID):
|
||||
def get(self, app_model: App, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=app_model.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
return variable
|
||||
|
||||
@ -422,7 +368,7 @@ class VariableApi(Resource):
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_model)
|
||||
def patch(self, current_user: Account, app_model: App, variable_id: UUID):
|
||||
def patch(self, app_model: App, variable_id: UUID):
|
||||
# Request payload for file types:
|
||||
#
|
||||
# Local File:
|
||||
@ -450,11 +396,10 @@ class VariableApi(Resource):
|
||||
args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=app_model.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
|
||||
new_name = args_model.name
|
||||
@ -495,16 +440,15 @@ class VariableApi(Resource):
|
||||
@console_ns.response(204, "Variable deleted successfully")
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_api_prerequisite
|
||||
def delete(self, current_user: Account, app_model: App, variable_id: UUID):
|
||||
def delete(self, app_model: App, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=app_model.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
draft_var_srv.delete_variable(variable)
|
||||
db.session.commit()
|
||||
@ -520,7 +464,7 @@ class VariableResetApi(Resource):
|
||||
@console_ns.response(204, "Variable reset (no content)")
|
||||
@console_ns.response(404, "Variable not found")
|
||||
@_api_prerequisite
|
||||
def put(self, current_user: Account, app_model: App, variable_id: UUID):
|
||||
def put(self, app_model: App, variable_id: UUID):
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=db.session(),
|
||||
)
|
||||
@ -532,11 +476,10 @@ class VariableResetApi(Resource):
|
||||
f"Draft workflow not found, app_id={app_model.id}",
|
||||
)
|
||||
variable_id_str = str(variable_id)
|
||||
variable = ensure_variable_access(
|
||||
variable = _ensure_variable_access(
|
||||
variable=draft_var_srv.get_variable(variable_id=variable_id_str),
|
||||
app_id=app_model.id,
|
||||
variable_id=variable_id_str,
|
||||
current_user_id=current_user.id,
|
||||
)
|
||||
|
||||
resetted = draft_var_srv.reset_variable(draft_workflow, variable)
|
||||
@ -547,20 +490,20 @@ class VariableResetApi(Resource):
|
||||
return marshal(resetted, workflow_draft_variable_model)
|
||||
|
||||
|
||||
def _get_variable_list(app_model: App, node_id: str, current_user_id: str) -> WorkflowDraftVariableList:
|
||||
def _get_variable_list(app_model: App, node_id) -> WorkflowDraftVariableList:
|
||||
with sessionmaker(bind=db.engine, expire_on_commit=False).begin() as session:
|
||||
draft_var_srv = WorkflowDraftVariableService(
|
||||
session=session,
|
||||
)
|
||||
if node_id == CONVERSATION_VARIABLE_NODE_ID:
|
||||
draft_vars = draft_var_srv.list_conversation_variables(app_model.id, user_id=current_user_id)
|
||||
draft_vars = draft_var_srv.list_conversation_variables(app_model.id, user_id=current_user.id)
|
||||
elif node_id == SYSTEM_VARIABLE_NODE_ID:
|
||||
draft_vars = draft_var_srv.list_system_variables(app_model.id, user_id=current_user_id)
|
||||
draft_vars = draft_var_srv.list_system_variables(app_model.id, user_id=current_user.id)
|
||||
else:
|
||||
draft_vars = draft_var_srv.list_node_variables(
|
||||
app_id=app_model.id,
|
||||
node_id=node_id,
|
||||
user_id=current_user_id,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return draft_vars
|
||||
|
||||
@ -574,7 +517,7 @@ class ConversationVariableCollectionApi(Resource):
|
||||
@console_ns.response(404, "Draft workflow not found")
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
# NOTE(QuantumGhost): Prefill conversation variables into the draft variables table
|
||||
# so their IDs can be returned to the caller.
|
||||
workflow_srv = WorkflowService()
|
||||
@ -584,24 +527,19 @@ class ConversationVariableCollectionApi(Resource):
|
||||
draft_var_srv = WorkflowDraftVariableService(db.session())
|
||||
draft_var_srv.prefill_conversation_variable_default_values(draft_workflow, user_id=current_user.id)
|
||||
db.session.commit()
|
||||
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID, current_user.id)
|
||||
return _get_variable_list(app_model, CONVERSATION_VARIABLE_NODE_ID)
|
||||
|
||||
@console_ns.expect(console_ns.models[ConversationVariableUpdatePayload.__name__])
|
||||
@console_ns.doc("update_conversation_variables")
|
||||
@console_ns.doc(description="Update conversation variables for workflow draft")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Conversation variables updated successfully",
|
||||
console_ns.models[SimpleResultResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Conversation variables updated successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
payload = ConversationVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
@ -628,8 +566,8 @@ class SystemVariableCollectionApi(Resource):
|
||||
@console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model)
|
||||
@_api_prerequisite
|
||||
@marshal_with(workflow_draft_variable_list_model)
|
||||
def get(self, current_user: Account, app_model: App):
|
||||
return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID, current_user.id)
|
||||
def get(self, app_model: App):
|
||||
return _get_variable_list(app_model, SYSTEM_VARIABLE_NODE_ID)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/environment-variables")
|
||||
@ -637,14 +575,10 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
@console_ns.doc("get_environment_variables")
|
||||
@console_ns.doc(description="Get environment variables for workflow")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Environment variables retrieved successfully",
|
||||
console_ns.models[EnvironmentVariableListResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Environment variables retrieved successfully")
|
||||
@console_ns.response(404, "Draft workflow not found")
|
||||
@_api_prerequisite
|
||||
def get(self, _current_user: Account, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""
|
||||
Get draft workflow
|
||||
"""
|
||||
@ -679,18 +613,13 @@ class EnvironmentVariableCollectionApi(Resource):
|
||||
@console_ns.doc("update_environment_variables")
|
||||
@console_ns.doc(description="Update environment variables for workflow draft")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Environment variables updated successfully",
|
||||
console_ns.models[SimpleResultResponse.__name__],
|
||||
)
|
||||
@console_ns.response(200, "Environment variables updated successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
def post(self, current_user: Account, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
payload = EnvironmentVariableUpdatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
|
||||
@ -30,8 +30,6 @@ from uuid import UUID
|
||||
from flask import Response
|
||||
from flask_restx import Resource
|
||||
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.schema import register_response_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
@ -40,13 +38,8 @@ from libs.login import login_required
|
||||
from models import App, AppMode
|
||||
from services.workflow import inspector_events
|
||||
from services.workflow.node_output_inspector_service import (
|
||||
CheckResultView,
|
||||
NodeOutputInspectorError,
|
||||
NodeOutputInspectorService,
|
||||
NodeOutputsView,
|
||||
NodeOutputView,
|
||||
OutputPreviewView,
|
||||
WorkflowRunSnapshotView,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -61,16 +54,6 @@ _HEARTBEAT_EVERY_TICKS = 15
|
||||
# many ticks (= seconds).
|
||||
_STREAM_HARD_TIMEOUT_TICKS = 1800 # 30 min
|
||||
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
EventStreamResponse,
|
||||
CheckResultView,
|
||||
NodeOutputView,
|
||||
NodeOutputsView,
|
||||
WorkflowRunSnapshotView,
|
||||
OutputPreviewView,
|
||||
)
|
||||
|
||||
|
||||
def _service() -> NodeOutputInspectorService:
|
||||
"""One-line factory so tests can monkeypatch a stub if needed."""
|
||||
@ -141,7 +124,6 @@ class WorkflowDraftRunNodeOutputsApi(Resource):
|
||||
@console_ns.doc("get_workflow_draft_run_node_outputs")
|
||||
@console_ns.doc(description="Snapshot of every node's declared outputs for a draft workflow run.")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
|
||||
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -164,7 +146,6 @@ class WorkflowDraftRunNodeOutputDetailApi(Resource):
|
||||
"node_id": "Node ID inside the workflow graph",
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
|
||||
@console_ns.response(404, "Workflow run / node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -190,7 +171,6 @@ class WorkflowDraftRunNodeOutputPreviewApi(Resource):
|
||||
"output_name": "Declared output name as exposed by Composer",
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
|
||||
@console_ns.response(404, "Workflow run / node / output not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -329,11 +309,6 @@ class WorkflowDraftRunNodeOutputEventsApi(Resource):
|
||||
@console_ns.doc("stream_workflow_draft_run_node_output_events")
|
||||
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a draft workflow run.")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow run node output event stream",
|
||||
console_ns.models[EventStreamResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -363,7 +338,6 @@ class WorkflowPublishedRunNodeOutputsApi(Resource):
|
||||
@console_ns.doc("get_workflow_published_run_node_outputs")
|
||||
@console_ns.doc(description="Snapshot of every node's declared outputs for a published workflow run.")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
|
||||
@console_ns.response(200, "Workflow run node outputs", console_ns.models[WorkflowRunSnapshotView.__name__])
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -386,7 +360,6 @@ class WorkflowPublishedRunNodeOutputDetailApi(Resource):
|
||||
"node_id": "Node ID inside the workflow graph",
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Workflow run node output detail", console_ns.models[NodeOutputsView.__name__])
|
||||
@console_ns.response(404, "Workflow run / node not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -413,7 +386,6 @@ class WorkflowPublishedRunNodeOutputPreviewApi(Resource):
|
||||
"output_name": "Declared output name as exposed by Composer",
|
||||
}
|
||||
)
|
||||
@console_ns.response(200, "Workflow run node output preview", console_ns.models[OutputPreviewView.__name__])
|
||||
@console_ns.response(404, "Workflow run / node / output not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -430,11 +402,6 @@ class WorkflowPublishedRunNodeOutputEventsApi(Resource):
|
||||
@console_ns.doc("stream_workflow_published_run_node_output_events")
|
||||
@console_ns.doc(description="Server-Sent Events stream of inspector deltas for a published workflow run.")
|
||||
@console_ns.doc(params={"app_id": "Application ID", "run_id": "Workflow run ID"})
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Workflow run node output event stream",
|
||||
console_ns.models[EventStreamResponse.__name__],
|
||||
)
|
||||
@console_ns.response(404, "Workflow run not found")
|
||||
@setup_required
|
||||
@login_required
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Literal
|
||||
from typing import Literal, cast
|
||||
from uuid import UUID
|
||||
|
||||
from flask import request
|
||||
@ -9,16 +9,11 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.errors import NotFoundError
|
||||
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.app.wraps import get_app_model
|
||||
from controllers.console.wraps import (
|
||||
account_initialization_required,
|
||||
setup_required,
|
||||
with_current_tenant_id,
|
||||
with_current_user,
|
||||
)
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from controllers.web.error import NotFoundError
|
||||
from core.workflow.human_input_forms import load_form_tokens_by_form_id as _load_form_tokens_by_form_id
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
@ -35,8 +30,8 @@ from graphon.enums import WorkflowExecutionStatus
|
||||
from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage
|
||||
from libs.custom_inputs import time_duration
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account, App, AppMode, WorkflowArchiveLog, WorkflowRunTriggeredFrom
|
||||
from libs.login import current_user, login_required
|
||||
from models import Account, App, AppMode, EndUser, WorkflowArchiveLog, WorkflowRunTriggeredFrom
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
from services.retention.workflow_run.constants import ARCHIVE_BUNDLE_NAME
|
||||
@ -195,8 +190,8 @@ class WorkflowRunExportApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App, run_id: UUID):
|
||||
tenant_id = app_model.tenant_id
|
||||
app_id = app_model.id
|
||||
tenant_id = str(app_model.tenant_id)
|
||||
app_id = str(app_model.id)
|
||||
run_id_str = str(run_id)
|
||||
|
||||
run_created_at = db.session.scalar(
|
||||
@ -402,18 +397,18 @@ class WorkflowRunNodeExecutionListApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
@with_current_user
|
||||
def get(self, current_user: Account, app_model: App, run_id: UUID):
|
||||
def get(self, app_model: App, run_id: UUID):
|
||||
"""
|
||||
Get workflow run node execution list
|
||||
"""
|
||||
run_id_str = str(run_id)
|
||||
|
||||
workflow_run_service = WorkflowRunService()
|
||||
user = cast("Account | EndUser", current_user)
|
||||
node_executions = workflow_run_service.get_workflow_run_node_executions(
|
||||
app_model=app_model,
|
||||
run_id=run_id_str,
|
||||
user=current_user,
|
||||
user=user,
|
||||
)
|
||||
|
||||
return WorkflowRunNodeExecutionListResponse.model_validate(
|
||||
@ -437,8 +432,7 @@ class ConsoleWorkflowPauseDetailsApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, workflow_run_id: str):
|
||||
def get(self, workflow_run_id: str):
|
||||
"""
|
||||
Get workflow pause details.
|
||||
|
||||
@ -455,7 +449,7 @@ class ConsoleWorkflowPauseDetailsApi(Resource):
|
||||
if not workflow_run:
|
||||
raise NotFoundError("Workflow run not found")
|
||||
|
||||
if workflow_run.tenant_id != current_tenant_id:
|
||||
if workflow_run.tenant_id != current_user.current_tenant_id:
|
||||
raise NotFoundError("Workflow run not found")
|
||||
|
||||
# Check if workflow is suspended
|
||||
|
||||
@ -3,12 +3,11 @@ from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required, with_current_user
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.login import login_required
|
||||
from models.account import Account
|
||||
@ -29,50 +28,7 @@ class WorkflowStatisticQuery(BaseModel):
|
||||
return value
|
||||
|
||||
|
||||
class WorkflowDailyRunsStatisticItem(ResponseModel):
|
||||
date: str
|
||||
runs: int
|
||||
|
||||
|
||||
class WorkflowDailyRunsStatisticResponse(ResponseModel):
|
||||
data: list[WorkflowDailyRunsStatisticItem]
|
||||
|
||||
|
||||
class WorkflowDailyTerminalsStatisticItem(ResponseModel):
|
||||
date: str
|
||||
terminal_count: int
|
||||
|
||||
|
||||
class WorkflowDailyTerminalsStatisticResponse(ResponseModel):
|
||||
data: list[WorkflowDailyTerminalsStatisticItem]
|
||||
|
||||
|
||||
class WorkflowDailyTokenCostStatisticItem(ResponseModel):
|
||||
date: str
|
||||
token_count: int
|
||||
|
||||
|
||||
class WorkflowDailyTokenCostStatisticResponse(ResponseModel):
|
||||
data: list[WorkflowDailyTokenCostStatisticItem]
|
||||
|
||||
|
||||
class WorkflowAverageAppInteractionStatisticItem(ResponseModel):
|
||||
date: str
|
||||
interactions: float
|
||||
|
||||
|
||||
class WorkflowAverageAppInteractionStatisticResponse(ResponseModel):
|
||||
data: list[WorkflowAverageAppInteractionStatisticItem]
|
||||
|
||||
|
||||
register_schema_models(console_ns, WorkflowStatisticQuery)
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
WorkflowDailyRunsStatisticResponse,
|
||||
WorkflowDailyTerminalsStatisticResponse,
|
||||
WorkflowDailyTokenCostStatisticResponse,
|
||||
WorkflowAverageAppInteractionStatisticResponse,
|
||||
)
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflow/statistics/daily-conversations")
|
||||
@ -85,12 +41,8 @@ class WorkflowDailyRunsStatistic(Resource):
|
||||
@console_ns.doc("get_workflow_daily_runs_statistic")
|
||||
@console_ns.doc(description="Get workflow daily runs statistics")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily runs statistics retrieved successfully",
|
||||
console_ns.models[WorkflowDailyRunsStatisticResponse.__name__],
|
||||
)
|
||||
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
|
||||
@console_ns.response(200, "Daily runs statistics retrieved successfully")
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -128,12 +80,8 @@ class WorkflowDailyTerminalsStatistic(Resource):
|
||||
@console_ns.doc("get_workflow_daily_terminals_statistic")
|
||||
@console_ns.doc(description="Get workflow daily terminals statistics")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily terminals statistics retrieved successfully",
|
||||
console_ns.models[WorkflowDailyTerminalsStatisticResponse.__name__],
|
||||
)
|
||||
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
|
||||
@console_ns.response(200, "Daily terminals statistics retrieved successfully")
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -171,12 +119,8 @@ class WorkflowDailyTokenCostStatistic(Resource):
|
||||
@console_ns.doc("get_workflow_daily_token_cost_statistic")
|
||||
@console_ns.doc(description="Get workflow daily token cost statistics")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Daily token cost statistics retrieved successfully",
|
||||
console_ns.models[WorkflowDailyTokenCostStatisticResponse.__name__],
|
||||
)
|
||||
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
|
||||
@console_ns.response(200, "Daily token cost statistics retrieved successfully")
|
||||
@get_app_model
|
||||
@setup_required
|
||||
@login_required
|
||||
@ -214,12 +158,8 @@ class WorkflowAverageAppInteractionStatistic(Resource):
|
||||
@console_ns.doc("get_workflow_average_app_interaction_statistic")
|
||||
@console_ns.doc(description="Get workflow average app interaction statistics")
|
||||
@console_ns.doc(params={"app_id": "Application ID"})
|
||||
@console_ns.doc(params=query_params_from_model(WorkflowStatisticQuery))
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Average app interaction statistics retrieved successfully",
|
||||
console_ns.models[WorkflowAverageAppInteractionStatisticResponse.__name__],
|
||||
)
|
||||
@console_ns.expect(console_ns.models[WorkflowStatisticQuery.__name__])
|
||||
@console_ns.response(200, "Average app interaction statistics retrieved successfully")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
|
||||
@ -9,17 +9,17 @@ from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import NotFound
|
||||
|
||||
from configs import dify_config
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from extensions.ext_database import db
|
||||
from fields.base import ResponseModel
|
||||
from libs.login import login_required
|
||||
from libs.login import current_user, login_required
|
||||
from models.enums import AppTriggerStatus
|
||||
from models.model import App, AppMode
|
||||
from models.model import Account, App, AppMode
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
|
||||
from .. import console_ns
|
||||
from ..app.wraps import get_app_model
|
||||
from ..wraps import account_initialization_required, edit_permission_required, setup_required, with_current_tenant_id
|
||||
from ..wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -86,7 +86,7 @@ register_schema_models(
|
||||
class WebhookTriggerApi(Resource):
|
||||
"""Webhook Trigger API"""
|
||||
|
||||
@console_ns.doc(params=query_params_from_model(Parser))
|
||||
@console_ns.expect(console_ns.models[Parser.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@ -124,16 +124,18 @@ class AppTriggersApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.WORKFLOW)
|
||||
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerListResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, app_model: App):
|
||||
def get(self, app_model: App):
|
||||
"""Get app triggers list"""
|
||||
assert isinstance(current_user, Account)
|
||||
assert current_user.current_tenant_id is not None
|
||||
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
# Get all triggers for this app using select API
|
||||
triggers = (
|
||||
session.execute(
|
||||
select(AppTrigger)
|
||||
.where(
|
||||
AppTrigger.tenant_id == current_tenant_id,
|
||||
AppTrigger.tenant_id == current_user.current_tenant_id,
|
||||
AppTrigger.app_id == app_model.id,
|
||||
)
|
||||
.order_by(AppTrigger.created_at.desc(), AppTrigger.id.desc())
|
||||
@ -164,18 +166,19 @@ class AppTriggerEnableApi(Resource):
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=AppMode.WORKFLOW)
|
||||
@console_ns.response(200, "Success", console_ns.models[WorkflowTriggerResponse.__name__])
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str, app_model: App):
|
||||
def post(self, app_model: App):
|
||||
"""Update app trigger (enable/disable)"""
|
||||
args = ParserEnable.model_validate(console_ns.payload)
|
||||
|
||||
assert current_user.current_tenant_id is not None
|
||||
|
||||
trigger_id = args.trigger_id
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
# Find the trigger using select
|
||||
trigger = session.execute(
|
||||
select(AppTrigger).where(
|
||||
AppTrigger.id == trigger_id,
|
||||
AppTrigger.tenant_id == current_tenant_id,
|
||||
AppTrigger.tenant_id == current_user.current_tenant_id,
|
||||
AppTrigger.app_id == app_model.id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
@ -1,20 +1,16 @@
|
||||
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
|
||||
from controllers.common.schema import query_params_from_model, register_schema_models
|
||||
from controllers.common.schema import register_schema_models
|
||||
from controllers.console import console_ns
|
||||
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
|
||||
from controllers.console.error import AlreadyActivateError
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import EmailStr, timezone
|
||||
from models import AccountStatus
|
||||
from models.account import TenantAccountJoin, TenantAccountRole
|
||||
from services.account_service import RegisterService, TenantService
|
||||
from services.billing_service import BillingService
|
||||
from services.account_service import RegisterService
|
||||
|
||||
|
||||
class ActivateCheckQuery(BaseModel):
|
||||
@ -27,22 +23,18 @@ class ActivatePayload(BaseModel):
|
||||
workspace_id: str | None = Field(default=None)
|
||||
email: EmailStr | None = Field(default=None)
|
||||
token: str
|
||||
name: str | None = Field(default=None, max_length=30)
|
||||
interface_language: str | None = Field(default=None)
|
||||
timezone: str | None = Field(default=None)
|
||||
name: str = Field(..., max_length=30)
|
||||
interface_language: str = Field(...)
|
||||
timezone: str = Field(...)
|
||||
|
||||
@field_validator("interface_language")
|
||||
@classmethod
|
||||
def validate_lang(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
def validate_lang(cls, value: str) -> str:
|
||||
return supported_language(value)
|
||||
|
||||
@field_validator("timezone")
|
||||
@classmethod
|
||||
def validate_tz(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
def validate_tz(cls, value: str) -> str:
|
||||
return timezone(value)
|
||||
|
||||
|
||||
@ -54,8 +46,6 @@ class ActivationCheckData(BaseModel):
|
||||
workspace_name: str | None
|
||||
workspace_id: str | None
|
||||
email: str | None
|
||||
account_status: str | None = None
|
||||
requires_setup: bool | None = None
|
||||
|
||||
|
||||
class ActivationCheckResponse(BaseModel):
|
||||
@ -77,7 +67,7 @@ register_schema_models(
|
||||
class ActivateCheckApi(Resource):
|
||||
@console_ns.doc("check_activation_token")
|
||||
@console_ns.doc(description="Check if activation token is valid")
|
||||
@console_ns.doc(params=query_params_from_model(ActivateCheckQuery))
|
||||
@console_ns.expect(console_ns.models[ActivateCheckQuery.__name__])
|
||||
@console_ns.response(
|
||||
200,
|
||||
"Success",
|
||||
@ -103,20 +93,9 @@ class ActivateCheckApi(Resource):
|
||||
workspace_name = tenant.name if tenant else None
|
||||
workspace_id = tenant.id if tenant else None
|
||||
invitee_email = data.get("email") if data else None
|
||||
account = invitation.get("account")
|
||||
account_status = account.status if account else None
|
||||
requires_setup = data.get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account_status == AccountStatus.PENDING
|
||||
return {
|
||||
"is_valid": invitation is not None,
|
||||
"data": {
|
||||
"workspace_name": workspace_name,
|
||||
"workspace_id": workspace_id,
|
||||
"email": invitee_email,
|
||||
"account_status": account_status,
|
||||
"requires_setup": requires_setup,
|
||||
},
|
||||
"data": {"workspace_name": workspace_name, "workspace_id": workspace_id, "email": invitee_email},
|
||||
}
|
||||
else:
|
||||
return {"is_valid": False}
|
||||
@ -141,49 +120,16 @@ class ActivateApi(Resource):
|
||||
if invitation is None:
|
||||
raise AlreadyActivateError()
|
||||
|
||||
account = invitation["account"]
|
||||
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
|
||||
raise AccountInFreezeError()
|
||||
|
||||
tenant = invitation["tenant"]
|
||||
raw_role = invitation["data"].get("role")
|
||||
try:
|
||||
role = TenantAccountRole(raw_role) if raw_role else TenantAccountRole.NORMAL
|
||||
except ValueError:
|
||||
role = TenantAccountRole.NORMAL
|
||||
if not TenantAccountRole.is_non_owner_role(role):
|
||||
role = TenantAccountRole.NORMAL
|
||||
|
||||
membership_id = db.session.scalar(
|
||||
select(TenantAccountJoin.id).where(
|
||||
TenantAccountJoin.tenant_id == tenant.id,
|
||||
TenantAccountJoin.account_id == account.id,
|
||||
)
|
||||
)
|
||||
|
||||
requires_setup = invitation["data"].get("requires_setup")
|
||||
if requires_setup is None:
|
||||
requires_setup = account.status == AccountStatus.PENDING
|
||||
|
||||
setup_fields: tuple[str, str, str] | None = None
|
||||
if requires_setup:
|
||||
if not args.name or not args.interface_language or not args.timezone:
|
||||
raise AlreadyActivateError()
|
||||
setup_fields = (args.name, args.interface_language, args.timezone)
|
||||
|
||||
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
|
||||
|
||||
if membership_id is None:
|
||||
TenantService.create_tenant_member(tenant, account, str(role))
|
||||
account = invitation["account"]
|
||||
account.name = args.name
|
||||
|
||||
if setup_fields:
|
||||
account.name = setup_fields[0]
|
||||
account.interface_language = setup_fields[1]
|
||||
account.timezone = setup_fields[2]
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
|
||||
TenantService.switch_tenant(account, tenant.id)
|
||||
account.interface_language = args.interface_language
|
||||
account.timezone = args.timezone
|
||||
account.interface_theme = "light"
|
||||
account.status = AccountStatus.ACTIVE
|
||||
account.initialized_at = naive_utc_now()
|
||||
db.session.commit()
|
||||
|
||||
return {"result": "success"}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user