diff --git a/api/controllers/console/app/workflow.py b/api/controllers/console/app/workflow.py index 26a3a022d401a4..d0b039e7930914 100644 --- a/api/controllers/console/app/workflow.py +++ b/api/controllers/console/app/workflow.py @@ -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 @@ -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 @@ -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//workflows/draft") api.add_resource(WorkflowConfigApi, "/apps//workflows/draft/config") api.add_resource(AdvancedChatDraftWorkflowRunApi, "/apps//advanced-chat/workflows/draft/run") @@ -454,6 +479,7 @@ def get(self, app_model: App): WorkflowDraftRunIterationNodeApi, "/apps//workflows/draft/iteration/nodes//run" ) api.add_resource(PublishedWorkflowApi, "/apps//workflows/publish") +api.add_resource(PublishedAllWorkflowApi, "/apps//workflows/publish/all") api.add_resource(DefaultBlockConfigsApi, "/apps//workflows/default-workflow-block-configs") api.add_resource( DefaultBlockConfigApi, "/apps//workflows/default-workflow-block-configs/" diff --git a/api/fields/workflow_fields.py b/api/fields/workflow_fields.py index bd093d4063bc2e..32f979a5f2aa08 100644 --- a/api/fields/workflow_fields.py +++ b/api/fields/workflow_fields.py @@ -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), @@ -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"), +} diff --git a/api/services/workflow_service.py b/api/services/workflow_service.py index 81b197a2478992..e8a1d735737384 100644 --- a/api/services/workflow_service.py +++ b/api/services/workflow_service.py @@ -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 @@ -76,6 +78,38 @@ 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, *, diff --git a/web/app/components/workflow/header/index.tsx b/web/app/components/workflow/header/index.tsx index 6e46990df843cd..d15cd92d0661fc 100644 --- a/web/app/components/workflow/header/index.tsx +++ b/web/app/components/workflow/header/index.tsx @@ -20,6 +20,7 @@ import type { StartNodeType } from '../nodes/start/types' import { useChecklistBeforePublish, useIsChatMode, + useNodesInteractions, useNodesReadOnly, useNodesSyncDraft, useWorkflowMode, @@ -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' @@ -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() 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(() => { @@ -76,7 +80,6 @@ const Header: FC = () => { const { handleLoadBackupDraft, handleBackupDraft, - handleRestoreFromPublishedWorkflow, } = useWorkflowRun() const { handleCheckBeforePublish } = useChecklistBeforePublish() const { handleSyncWorkflowDraft } = useNodesSyncDraft() @@ -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) @@ -209,23 +214,27 @@ const Header: FC = () => { } { restoring && ( -
- - - - +
+
+ +
+ + +
+
) } diff --git a/web/app/components/workflow/header/version-history-item.tsx b/web/app/components/workflow/header/version-history-item.tsx new file mode 100644 index 00000000000000..2826da3c197c78 --- /dev/null +++ b/web/app/components/workflow/header/version-history-item.tsx @@ -0,0 +1,52 @@ +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 = ({ 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) + ? ( +
+ {version} +
+ ) + : null + ) + + return ( +
item.version !== WorkflowVersion.Draft && onClick(item)} + > +
+ + {formatTime(item.version === WorkflowVersion.Draft ? item.updated_at : item.created_at)} + +
+ {renderVersionLabel(item.version)} + + published by {item.created_by.name} + +
+
+
+ ) +} + +export default React.memo(VersionHistoryItem) diff --git a/web/app/components/workflow/header/version-history-modal.tsx b/web/app/components/workflow/header/version-history-modal.tsx new file mode 100644 index 00000000000000..9c563d63892f41 --- /dev/null +++ b/web/app/components/workflow/header/version-history-modal.tsx @@ -0,0 +1,86 @@ +'use client' +import React, { useState } 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 ( +
+
+ {(isLoading && page) === 1 + ? ( +
+ +
+ ) + : ( + <> + {versionHistory?.items?.map(item => ( + + ))} + {isLoading && page > 1 && ( +
+ +
+ )} + {!isLoading && versionHistory?.has_more && ( +
+ +
+ )} + {!isLoading && !versionHistory?.items?.length && ( +
+ 暂无历史版本 +
+ )} + + )} +
+
+ ) +} + +export default React.memo(VersionHistoryModal) diff --git a/web/app/components/workflow/hooks/use-workflow-run.ts b/web/app/components/workflow/hooks/use-workflow-run.ts index cc1b0724a9184f..11f0a1973f9ba9 100644 --- a/web/app/components/workflow/hooks/use-workflow-run.ts +++ b/web/app/components/workflow/hooks/use-workflow-run.ts @@ -18,17 +18,14 @@ import { useWorkflowUpdate } from './use-workflow-interactions' import { useStore as useAppStore } from '@/app/components/app/store' import type { IOtherOptions } from '@/service/base' import { ssePost } from '@/service/base' -import { - fetchPublishedWorkflow, - stopWorkflowRun, -} from '@/service/workflow' +import { stopWorkflowRun } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager' import { getFilesInLogs, } from '@/app/components/base/file-uploader/utils' import { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' -import type { NodeTracing } from '@/types/workflow' +import type { NodeTracing, VersionHistory } from '@/types/workflow' export const useWorkflowRun = () => { const store = useStoreApi() @@ -754,24 +751,18 @@ export const useWorkflowRun = () => { stopWorkflowRun(`/apps/${appId}/workflow-runs/tasks/${taskId}/stop`) }, []) - const handleRestoreFromPublishedWorkflow = useCallback(async () => { - const appDetail = useAppStore.getState().appDetail - const publishedWorkflow = await fetchPublishedWorkflow(`/apps/${appDetail?.id}/workflows/publish`) - - if (publishedWorkflow) { - const nodes = publishedWorkflow.graph.nodes - const edges = publishedWorkflow.graph.edges - const viewport = publishedWorkflow.graph.viewport! - - handleUpdateWorkflowCanvas({ - nodes, - edges, - viewport, - }) - featuresStore?.setState({ features: publishedWorkflow.features }) - workflowStore.getState().setPublishedAt(publishedWorkflow.created_at) - workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || []) - } + const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => { + const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } })) + const edges = publishedWorkflow.graph.edges + const viewport = publishedWorkflow.graph.viewport! + handleUpdateWorkflowCanvas({ + nodes, + edges, + viewport, + }) + featuresStore?.setState({ features: publishedWorkflow.features }) + workflowStore.getState().setPublishedAt(publishedWorkflow.created_at) + workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || []) }, [featuresStore, handleUpdateWorkflowCanvas, workflowStore]) return { diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index 23f4188d85a5c7..8e9cdbfc450bb7 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -21,7 +21,7 @@ import type { WorkflowRunningData, } from './types' import { WorkflowContext } from './context' -import type { NodeTracing } from '@/types/workflow' +import type { NodeTracing, VersionHistory } from '@/types/workflow' // #TODO chatVar# // const MOCK_DATA = [ @@ -171,6 +171,8 @@ type Shape = { setIterTimes: (iterTimes: number) => void iterParallelLogMap: Map> setIterParallelLogMap: (iterParallelLogMap: Map>) => void + versionHistory: VersionHistory[] + setVersionHistory: (versionHistory: VersionHistory[]) => void } export const createWorkflowStore = () => { @@ -291,6 +293,8 @@ export const createWorkflowStore = () => { iterParallelLogMap: new Map>(), setIterParallelLogMap: iterParallelLogMap => set(() => ({ iterParallelLogMap })), + versionHistory: [], + setVersionHistory: versionHistory => set(() => ({ versionHistory })), })) } diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 6d0fabd90ef8c1..7c61ca98fe6be2 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -289,6 +289,11 @@ export enum WorkflowRunningStatus { Stopped = 'stopped', } +export enum WorkflowVersion { + Draft = 'draft', + Latest = 'latest', +} + export enum NodeRunningStatus { NotStart = 'not-start', Waiting = 'waiting', diff --git a/web/service/workflow.ts b/web/service/workflow.ts index f50595e0713350..b2c8d323b63662 100644 --- a/web/service/workflow.ts +++ b/web/service/workflow.ts @@ -4,6 +4,7 @@ import type { CommonResponse } from '@/models/common' import type { ChatRunHistoryResponse, ConversationVariableResponse, + FetchWorkflowDraftPageResponse, FetchWorkflowDraftResponse, NodesDefaultConfigsResponse, WorkflowRunHistoryResponse, @@ -14,7 +15,10 @@ export const fetchWorkflowDraft = (url: string) => { return get(url, {}, { silent: true }) as Promise } -export const syncWorkflowDraft = ({ url, params }: { url: string; params: Pick }) => { +export const syncWorkflowDraft = ({ url, params }: { + url: string + params: Pick +}) => { return post(url, { body: params }, { silent: true }) } @@ -46,6 +50,10 @@ export const fetchPublishedWorkflow: Fetcher return get(url) } +export const fetchPublishedAllWorkflow: Fetcher = (url) => { + return get(url) +} + export const stopWorkflowRun = (url: string) => { return post(url) } @@ -61,6 +69,9 @@ export const updateWorkflowDraftFromDSL = (appId: string, data: string) => { return post(`apps/${appId}/workflows/draft/import`, { body: { data } }) } -export const fetchCurrentValueOfConversationVariable: Fetcher = ({ url, params }) => { +export const fetchCurrentValueOfConversationVariable: Fetcher = ({ url, params }) => { return get(url, { params }) } diff --git a/web/types/workflow.ts b/web/types/workflow.ts index cd6e9cfa5f02ac..ee0c1c64543230 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -1,11 +1,5 @@ import type { Viewport } from 'reactflow' -import type { - BlockEnum, - ConversationVariable, - Edge, - EnvironmentVariable, - Node, -} from '@/app/components/workflow/types' +import type { BlockEnum, ConversationVariable, Edge, EnvironmentVariable, Node } from '@/app/components/workflow/types' import type { TransferMethod } from '@/types/app' import type { ErrorHandleTypeEnum } from '@/app/components/workflow/nodes/_base/components/error-handle/types' @@ -79,6 +73,15 @@ export type FetchWorkflowDraftResponse = { tool_published: boolean environment_variables?: EnvironmentVariable[] conversation_variables?: ConversationVariable[] + version: string +} + +export type VersionHistory = FetchWorkflowDraftResponse + +export type FetchWorkflowDraftPageResponse = { + items: VersionHistory[] + has_more: boolean + page: number } export type NodeTracingListResponse = { diff --git a/web/utils/var.ts b/web/utils/var.ts index 236c9debac6b31..70359337585f5b 100644 --- a/web/utils/var.ts +++ b/web/utils/var.ts @@ -1,11 +1,16 @@ import { MAX_VAR_KEY_LENGTH, VAR_ITEM_TEMPLATE, VAR_ITEM_TEMPLATE_IN_WORKFLOW, getMaxVarNameLength } from '@/config' -import { CONTEXT_PLACEHOLDER_TEXT, HISTORY_PLACEHOLDER_TEXT, PRE_PROMPT_PLACEHOLDER_TEXT, QUERY_PLACEHOLDER_TEXT } from '@/app/components/base/prompt-editor/constants' +import { + CONTEXT_PLACEHOLDER_TEXT, + HISTORY_PLACEHOLDER_TEXT, + PRE_PROMPT_PLACEHOLDER_TEXT, + QUERY_PLACEHOLDER_TEXT, +} from '@/app/components/base/prompt-editor/constants' import { InputVarType } from '@/app/components/workflow/types' const otherAllowedRegex = /^[a-zA-Z0-9_]+$/ export const getNewVar = (key: string, type: string) => { - const { max_length, ...rest } = VAR_ITEM_TEMPLATE + const { ...rest } = VAR_ITEM_TEMPLATE if (type !== 'string') { return { ...rest,