Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] Support Multi-Version Workflows #11990

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 20 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions api/controllers/console/app/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging

from flask import abort, request
from flask_restful import Resource, marshal_with, reqparse # type: ignore
from flask_restful import Resource, inputs, marshal_with, reqparse # type: ignore
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound

import services
Expand All @@ -14,7 +14,7 @@
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from factories import variable_factory
from fields.workflow_fields import workflow_fields
from fields.workflow_fields import workflow_fields, workflow_pagination_fields
from fields.workflow_run_fields import workflow_run_node_execution_fields
from libs import helper
from libs.helper import TimestampField, uuid_value
Expand Down Expand Up @@ -440,6 +440,31 @@ def get(self, app_model: App):
}


class PublishedAllWorkflowApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
@marshal_with(workflow_pagination_fields)
def get(self, app_model: App):
"""
Get published workflows
"""
if not current_user.is_editor:
raise Forbidden()

parser = reqparse.RequestParser()
parser.add_argument("page", type=inputs.int_range(1, 99999), required=False, default=1, location="args")
parser.add_argument("limit", type=inputs.int_range(1, 100), required=False, default=20, location="args")
args = parser.parse_args()
page = args.get("page")
limit = args.get("limit")
workflow_service = WorkflowService()
workflows, has_more = workflow_service.get_all_published_workflow(app_model=app_model, page=page, limit=limit)

return {"items": workflows, "page": page, "limit": limit, "has_more": has_more}


api.add_resource(DraftWorkflowApi, "/apps/<uuid:app_id>/workflows/draft")
api.add_resource(WorkflowConfigApi, "/apps/<uuid:app_id>/workflows/draft/config")
api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps/<uuid:app_id>/advanced-chat/workflows/draft/run")
Expand All @@ -454,6 +479,7 @@ def get(self, app_model: App):
WorkflowDraftRunIterationNodeApi, "/apps/<uuid:app_id>/workflows/draft/iteration/nodes/<string:node_id>/run"
)
api.add_resource(PublishedWorkflowApi, "/apps/<uuid:app_id>/workflows/publish")
api.add_resource(PublishedAllWorkflowApi, "/apps/<uuid:app_id>/workflows/publish/all")
api.add_resource(DefaultBlockConfigsApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs")
api.add_resource(
DefaultBlockConfigApi, "/apps/<uuid:app_id>/workflows/default-workflow-block-configs/<string:block_type>"
Expand Down
8 changes: 8 additions & 0 deletions api/fields/workflow_fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ def format(self, value):
"graph": fields.Raw(attribute="graph_dict"),
"features": fields.Raw(attribute="features_dict"),
"hash": fields.String(attribute="unique_hash"),
"version": fields.String(attribute="version"),
"created_by": fields.Nested(simple_account_fields, attribute="created_by_account"),
"created_at": TimestampField,
"updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True),
Expand All @@ -61,3 +62,10 @@ def format(self, value):
"updated_by": fields.String,
"updated_at": TimestampField,
}

workflow_pagination_fields = {
"items": fields.List(fields.Nested(workflow_fields), attribute="items"),
"page": fields.Integer,
"limit": fields.Integer(attribute="limit"),
"has_more": fields.Boolean(attribute="has_more"),
}
37 changes: 37 additions & 0 deletions api/services/workflow_service.py
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please avoid using comments in any language other than English.

Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
from typing import Any, Optional, cast
from uuid import uuid4

from sqlalchemy import desc

from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from core.model_runtime.utils.encoders import jsonable_encoder
Expand Down Expand Up @@ -76,6 +78,41 @@ def get_published_workflow(self, app_model: App) -> Optional[Workflow]:

return workflow

def get_all_published_workflow(self, app_model: App, page: int, limit: int) -> tuple[list[Workflow], bool]:
"""
Get published workflow with pagination
"""
if not app_model.workflow_id:
return [], False

# 多查询一条数据来判断是否还有下一页
workflows = (
db.session.query(Workflow)
.filter(Workflow.app_id == app_model.id)
.order_by(desc(Workflow.version))
.offset((page - 1) * limit)
.limit(limit + 1) # 多查一条
.all()
)

# 判断是否还有更多数据
has_more = len(workflows) > limit
# 如果多查到了数据,则移除最后一条
if has_more:
workflows = workflows[:-1]

if len(workflows) > 1:
workflows[1].version = "latest"

for workflow in workflows:
try:
version_datetime = datetime.strptime(workflow.version, "%Y-%m-%d %H:%M:%S.%f")
workflow.version = version_datetime.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
pass

return workflows, has_more

def sync_draft_workflow(
self,
*,
Expand Down
49 changes: 29 additions & 20 deletions web/app/components/workflow/header/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { StartNodeType } from '../nodes/start/types'
import {
useChecklistBeforePublish,
useIsChatMode,
useNodesInteractions,
useNodesReadOnly,
useNodesSyncDraft,
useWorkflowMode,
Expand All @@ -35,6 +36,7 @@ import RestoringTitle from './restoring-title'
import ViewHistory from './view-history'
import ChatVariableButton from './chat-variable-button'
import EnvButton from './env-button'
import VersionHistoryModal from './version-history-modal'
import Button from '@/app/components/base/button'
import { useStore as useAppStore } from '@/app/components/app/store'
import { publishWorkflow } from '@/service/workflow'
Expand All @@ -49,11 +51,13 @@ const Header: FC = () => {
const appID = appDetail?.id
const isChatMode = useIsChatMode()
const { nodesReadOnly, getNodesReadOnly } = useNodesReadOnly()
const { handleNodeSelect } = useNodesInteractions()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const toolPublished = useStore(s => s.toolPublished)
const nodes = useNodes<StartNodeType>()
const startNode = nodes.find(node => node.data.type === BlockEnum.Start)
const selectedNode = nodes.find(node => node.data.selected)
const startVariables = startNode?.data.variables
const fileSettings = useFeatures(s => s.features.file)
const variables = useMemo(() => {
Expand All @@ -76,7 +80,6 @@ const Header: FC = () => {
const {
handleLoadBackupDraft,
handleBackupDraft,
handleRestoreFromPublishedWorkflow,
} = useWorkflowRun()
const { handleCheckBeforePublish } = useChecklistBeforePublish()
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
Expand Down Expand Up @@ -126,8 +129,10 @@ const Header: FC = () => {
const onStartRestoring = useCallback(() => {
workflowStore.setState({ isRestoring: true })
handleBackupDraft()
handleRestoreFromPublishedWorkflow()
}, [handleBackupDraft, handleRestoreFromPublishedWorkflow, workflowStore])
// clear right panel
if (selectedNode)
handleNodeSelect(selectedNode.id, true)
}, [handleBackupDraft, workflowStore, handleNodeSelect, selectedNode])

const onPublisherToggle = useCallback((state: boolean) => {
if (state)
Expand Down Expand Up @@ -209,23 +214,27 @@ const Header: FC = () => {
}
{
restoring && (
<div className='flex items-center space-x-2'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
<Divider type='vertical' className='h-3.5 mx-auto' />
<Button
onClick={handleCancelRestore}
>
{t('common.operation.cancel')}
</Button>
<Button
onClick={handleRestore}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
<div className='flex flex-col mt-auto'>
<div className='flex items-center justify-end my-4'>
<Button className='text-components-button-secondary-text' onClick={handleShowFeatures}>
<RiApps2AddLine className='w-4 h-4 mr-1 text-components-button-secondary-text' />
{t('workflow.common.features')}
</Button>
<div className='mx-2 w-[1px] h-3.5 bg-gray-200'></div>
<Button
className='mr-2'
onClick={handleCancelRestore}
>
{t('common.operation.cancel')}
</Button>
<Button
onClick={handleRestore}
variant='primary'
>
{t('workflow.common.restore')}
</Button>
</div>
<VersionHistoryModal />
</div>
)
}
Expand Down
41 changes: 41 additions & 0 deletions web/app/components/workflow/header/version-history-item.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import React from 'react'
import dayjs from 'dayjs'
import { WorkflowVersion } from '../types'
import cn from '@/utils/classnames'
import type { VersionHistory } from '@/types/workflow'

type VersionHistoryItemProps = {
item: VersionHistory
selectedVersion: string
onClick: (item: VersionHistory) => void
}

const VersionHistoryItem: React.FC<VersionHistoryItemProps> = ({ item, selectedVersion, onClick }) => {
const formatTime = (time: number) => dayjs.unix(time).format('YYYY-MM-DD HH:mm:ss')

const renderVersionLabel = (version: string) => (
(version === WorkflowVersion.Draft || version === WorkflowVersion.Latest)
? (
<div className="shrink-0 px-1 border bg-white border-[rgba(0,0,0,0.08)] rounded-[5px] truncate">
{version}
</div>
)
: null
)

return (
<div
className={cn(
'flex items-center p-2 h-9 text-xs font-medium text-gray-700 justify-between',
item.version === selectedVersion ? '' : 'hover:bg-gray-100',
item.version === WorkflowVersion.Draft ? 'cursor-not-allowed' : 'cursor-pointer',
)}
onClick={() => item.version !== WorkflowVersion.Draft && onClick(item)}
>
<div>{formatTime(item.version === WorkflowVersion.Draft ? item.updated_at : item.created_at)}</div>
{renderVersionLabel(item.version)}
</div>
)
}

export default React.memo(VersionHistoryItem)
85 changes: 85 additions & 0 deletions web/app/components/workflow/header/version-history-modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'use client'
import React, { useState, useCallback } from 'react'
import useSWR from 'swr'
import { useWorkflowRun } from '../hooks'
import VersionHistoryItem from './version-history-item'
import type { VersionHistory } from '@/types/workflow'
import { useStore as useAppStore } from '@/app/components/app/store'
import { fetchPublishedAllWorkflow } from '@/service/workflow'
import Loading from '@/app/components/base/loading'
import Button from '@/app/components/base/button'

const limit = 10

const VersionHistoryModal = () => {
const [selectedVersion, setSelectedVersion] = useState('draft')
const [page, setPage] = useState(1)
const { handleRestoreFromPublishedWorkflow } = useWorkflowRun()
const appDetail = useAppStore.getState().appDetail

const {
data: versionHistory,
isLoading,
} = useSWR(
`/apps/${appDetail?.id}/workflows/publish/all?page=${page}&limit=${limit}`,
fetchPublishedAllWorkflow
)

const handleVersionClick = (item: VersionHistory) => {
if (item.version !== selectedVersion) {
setSelectedVersion(item.version)
handleRestoreFromPublishedWorkflow(item)
}
}

const handleNextPage = () => {
if (versionHistory?.has_more) {
setPage(page => page + 1)
}
}

return (
<div className='w-[336px] bg-white rounded-2xl border-[0.5px] border-gray-200 shadow-xl p-2'>
<div className="max-h-[400px] overflow-auto">
{isLoading && page === 1 ? (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
) : (
<>
{versionHistory?.items?.map(item => (
<VersionHistoryItem
key={item.version}
item={item}
selectedVersion={selectedVersion}
onClick={handleVersionClick}
/>
))}
{isLoading && page > 1 && (
<div className='flex items-center justify-center h-10'>
<Loading/>
</div>
)}
{!isLoading && versionHistory?.has_more && (
<div className='flex items-center justify-center h-10 mt-2'>
<Button
className='text-sm'
onClick={handleNextPage}
>
加载更多
</Button>
</div>
)}
{!isLoading && !versionHistory?.items?.length && (
<div className='flex items-center justify-center h-10 text-gray-500'>
暂无历史版本
</div>
)}
</>
)}
</div>
</div>
)
}

export default React.memo(VersionHistoryModal)
Loading
Loading