diff --git a/web/app/components/workflow/header/run-and-history.tsx b/web/app/components/workflow/header/run-and-history.tsx index 2745c9ec1a..38619bbe0b 100644 --- a/web/app/components/workflow/header/run-and-history.tsx +++ b/web/app/components/workflow/header/run-and-history.tsx @@ -7,7 +7,6 @@ import { Play } from '@/app/components/base/icons/src/vender/line/mediaAndDevice import { ClockPlay } from '@/app/components/base/icons/src/vender/line/time' import TooltipPlus from '@/app/components/base/tooltip-plus' import { Loading02 } from '@/app/components/base/icons/src/vender/line/general' -import { Mode } from '@/app/components/workflow/types' const RunAndHistory: FC = () => { const { t } = useTranslation() @@ -15,6 +14,11 @@ const RunAndHistory: FC = () => { const mode = useStore(state => state.mode) const showRunHistory = useStore(state => state.showRunHistory) + const handleClick = () => { + if (!isChatMode) + useStore.setState({ showInputsPanel: true }) + } + return (
{ ${mode === 'running' && 'bg-primary-50 !cursor-not-allowed'} ${mode === 'running' && isChatMode && 'opacity-50'} `} - onClick={() => mode !== 'running' && useStore.setState({ mode: Mode.Running })} + onClick={() => mode !== 'running' && handleClick()} > { mode === 'running' diff --git a/web/app/components/workflow/hooks.ts b/web/app/components/workflow/hooks.ts index eb1ab8b886..b83b428cd0 100644 --- a/web/app/components/workflow/hooks.ts +++ b/web/app/components/workflow/hooks.ts @@ -21,6 +21,10 @@ import type { BlockEnum, Node, } from './types' +import { + NodeRunningStatus, + WorkflowRunningStatus, +} from './types' import { NODES_EXTRA_DATA, NODES_INITIAL_DATA, @@ -31,6 +35,7 @@ import type { ToolDefaultValue } from './block-selector/types' import { syncWorkflowDraft } from '@/service/workflow' import { useFeaturesStore } from '@/app/components/base/features/hooks' import { useStore as useAppStore } from '@/app/components/app/store' +import { ssePost } from '@/service/base' export const useIsChatMode = () => { const appDetail = useAppStore(s => s.appDetail) @@ -560,3 +565,51 @@ export const useWorkflow = () => { handleEdgesChange, } } + +export const useWorkflowRun = () => { + const store = useStoreApi() + + return (params: any) => { + const { + getNodes, + setNodes, + } = store.getState() + const appDetail = useAppStore.getState().appDetail + + let url = '' + if (appDetail?.mode === 'advanced-chat') + url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run` + + if (appDetail?.mode === 'workflow') + url = `/apps/${appDetail.id}/workflows/draft/run` + + ssePost( + url, + params, + { + onWorkflowStarted: () => { + useStore.setState({ runningStatus: WorkflowRunningStatus.Running }) + }, + onWorkflowFinished: ({ data }) => { + useStore.setState({ runningStatus: data.status as WorkflowRunningStatus }) + }, + onNodeStarted: ({ data }) => { + const newNodes = produce(getNodes(), (draft) => { + const currentNode = draft.find(node => node.id === data.node_id)! + + currentNode.data._running = NodeRunningStatus.Running + }) + setNodes(newNodes) + }, + onNodeFinished: ({ data }) => { + const newNodes = produce(getNodes(), (draft) => { + const currentNode = draft.find(node => node.id === data.node_id)! + + currentNode.data._running = data.status + }) + setNodes(newNodes) + }, + }, + ) + } +} diff --git a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx index 6b38ff4b12..b64c9ce70d 100644 --- a/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx +++ b/web/app/components/workflow/nodes/_base/components/before-run-form/form-item.tsx @@ -15,12 +15,14 @@ type Props = { payload: InputVar value: any onChange: (value: any) => void + className?: string } const FormItem: FC = ({ payload, value, onChange, + className, }) => { const { type } = payload const handleContextItemChange = useCallback((index: number) => { @@ -41,9 +43,9 @@ const FormItem: FC = ({ } }, [value, onChange]) return ( -
+
{type !== InputVarType.contexts &&
{payload.label}
} -
+
{ type === InputVarType.textInput && ( = ({
= ({ group relative w-[240px] bg-[#fcfdff] shadow-xs border border-transparent rounded-[15px] hover:shadow-lg + ${data._runningStatus === NodeRunningStatus.Running && 'border-primary-500'} + ${data._runningStatus === NodeRunningStatus.Succeeded && 'border-[#12B76A]'} + ${data._runningStatus === NodeRunningStatus.Failed && 'border-[#F04438]'} `} > { @@ -71,10 +82,25 @@ const BaseNode: FC = ({ />
{data.title}
+ { + data._runningStatus === NodeRunningStatus.Running && ( + + ) + } + { + data._runningStatus === NodeRunningStatus.Succeeded && ( + + ) + } + { + data._runningStatus === NodeRunningStatus.Failed && ( + + ) + }
{cloneElement(children, { id, data })} diff --git a/web/app/components/workflow/operator/index.tsx b/web/app/components/workflow/operator/index.tsx index 62b4dcecd4..990b5f1c49 100644 --- a/web/app/components/workflow/operator/index.tsx +++ b/web/app/components/workflow/operator/index.tsx @@ -1,5 +1,6 @@ import { memo } from 'react' import { useTranslation } from 'react-i18next' +import { MiniMap } from 'reactflow' import ZoomInOut from './zoom-in-out' import { OrganizeGrid } from '@/app/components/base/icons/src/vender/line/layout' import TooltipPlus from '@/app/components/base/tooltip-plus' @@ -9,15 +10,24 @@ const Operator = () => { return (
- - -
- -
-
+ +
+ + +
+ +
+
+
) } diff --git a/web/app/components/workflow/panel/index.tsx b/web/app/components/workflow/panel/index.tsx index 9de1f43528..37bd6c3369 100644 --- a/web/app/components/workflow/panel/index.tsx +++ b/web/app/components/workflow/panel/index.tsx @@ -12,6 +12,7 @@ import WorkflowInfo from './workflow-info' import DebugAndPreview from './debug-and-preview' import RunHistory from './run-history' import Record from './record' +import InputsPanel from './inputs-panel' const Panel: FC = () => { const isChatMode = useIsChatMode() @@ -19,6 +20,7 @@ const Panel: FC = () => { const nodes = useNodes() const selectedNode = nodes.find(node => node.data.selected) const showRunHistory = useStore(state => state.showRunHistory) + const showInputsPanel = useStore(s => s.showInputsPanel) const { showWorkflowInfoPanel, showNodePanel, @@ -33,6 +35,11 @@ const Panel: FC = () => { return (
+ { + showInputsPanel && ( + + ) + } { runTaskId && ( diff --git a/web/app/components/workflow/panel/inputs-panel.tsx b/web/app/components/workflow/panel/inputs-panel.tsx new file mode 100644 index 0000000000..597665c991 --- /dev/null +++ b/web/app/components/workflow/panel/inputs-panel.tsx @@ -0,0 +1,79 @@ +import { + memo, + useCallback, + useState, +} from 'react' +import { useTranslation } from 'react-i18next' +import { useNodes } from 'reactflow' +import FormItem from '../nodes/_base/components/before-run-form/form-item' +import { BlockEnum } from '../types' +import { useStore } from '../store' +import { useWorkflowRun } from '../hooks' +import type { StartNodeType } from '../nodes/start/types' +import Button from '@/app/components/base/button' + +const InputsPanel = () => { + const { t } = useTranslation() + const nodes = useNodes() + const run = useWorkflowRun() + const [inputs, setInputs] = useState>({}) + const startNode = nodes.find(node => node.data.type === BlockEnum.Start) + const variables = startNode?.data.variables || [] + + const handleValueChange = (variable: string, v: string) => { + setInputs({ + ...inputs, + [variable]: v, + }) + } + + const handleCancel = useCallback(() => { + useStore.setState({ showInputsPanel: false }) + }, []) + + const handleRun = () => { + run(inputs) + } + + return ( +
+
+ {t('workflow.singleRun.testRun')} +
+
+ { + variables.map(variable => ( +
+ handleValueChange(variable.variable, v)} + /> +
+ )) + } +
+
+ + +
+
+ ) +} + +export default memo(InputsPanel) diff --git a/web/app/components/workflow/store.ts b/web/app/components/workflow/store.ts index 27a2d264e7..25c78e745a 100644 --- a/web/app/components/workflow/store.ts +++ b/web/app/components/workflow/store.ts @@ -9,6 +9,7 @@ import type { ToolsMap, } from './block-selector/types' import { Mode } from './types' +import type { WorkflowRunningStatus } from './types' type State = { mode: Mode @@ -22,6 +23,8 @@ type State = { toolsMap: ToolsMap draftUpdatedAt: number publishedAt: number + runningStatus?: WorkflowRunningStatus + showInputsPanel: boolean } type Action = { @@ -36,6 +39,8 @@ type Action = { setToolsMap: (toolsMap: Record) => void setDraftUpdatedAt: (draftUpdatedAt: number) => void setPublishedAt: (publishedAt: number) => void + setRunningStatus: (runningStatus?: WorkflowRunningStatus) => void + setShowInputsPanel: (showInputsPanel: boolean) => void } export const useStore = create(set => ({ @@ -61,4 +66,8 @@ export const useStore = create(set => ({ setDraftUpdatedAt: draftUpdatedAt => set(() => ({ draftUpdatedAt })), publishedAt: 0, setPublishedAt: publishedAt => set(() => ({ publishedAt })), + runningStatus: undefined, + setRunningStatus: runningStatus => set(() => ({ runningStatus })), + showInputsPanel: false, + setShowInputsPanel: showInputsPanel => set(() => ({ showInputsPanel })), })) diff --git a/web/app/components/workflow/types.ts b/web/app/components/workflow/types.ts index 2d6f0690be..f677307615 100644 --- a/web/app/components/workflow/types.ts +++ b/web/app/components/workflow/types.ts @@ -27,6 +27,7 @@ export type Branch = { export type CommonNodeType = { _targetBranches?: Branch[] _isSingleRun?: boolean + _runningStatus?: NodeRunningStatus selected?: boolean title: string desc: string @@ -38,7 +39,7 @@ export type CommonEdgeType = { _connectedNodeIsHovering: boolean } -export type Node = ReactFlowNode +export type Node = ReactFlowNode> export type SelectedNode = Pick export type NodeProps = { id: string; data: CommonNodeType } export type NodePanelProps = { @@ -147,3 +148,16 @@ export enum Mode { Editing = 'editing', Running = 'running', } + +export enum WorkflowRunningStatus { + Running = 'running', + Succeeded = 'succeeded', + Failed = 'failed', + Stopped = 'stopped', +} + +export enum NodeRunningStatus { + Running = 'running', + Succeeded = 'succeeded', + Failed = 'failed', +} diff --git a/web/service/base.ts b/web/service/base.ts index 8d3ac8188b..8e5c343f4b 100644 --- a/web/service/base.ts +++ b/web/service/base.ts @@ -2,6 +2,14 @@ import { API_PREFIX, IS_CE_EDITION, PUBLIC_API_PREFIX } from '@/config' import Toast from '@/app/components/base/toast' import type { AnnotationReply, MessageEnd, MessageReplace, ThoughtItem } from '@/app/components/app/chat/type' import type { VisionFile } from '@/types/app' +import type { + NodeFinishedResponse, + NodeStartedResponse, + TextChunkResponse, + TextReplaceResponse, + WorkflowFinishedResponse, + WorkflowStartedResponse, +} from '@/types/workflow' const TIME_OUT = 100000 const ContentType = { @@ -39,6 +47,13 @@ export type IOnAnnotationReply = (messageReplace: AnnotationReply) => void export type IOnCompleted = (hasError?: boolean) => void export type IOnError = (msg: string, code?: string) => void +export type IOnWorkflowStarted = (workflowStarted: WorkflowStartedResponse) => void +export type IOnWorkflowFinished = (workflowFinished: WorkflowFinishedResponse) => void +export type IOnNodeStarted = (nodeStarted: NodeStartedResponse) => void +export type IOnNodeFinished = (nodeFinished: NodeFinishedResponse) => void +export type IOnTextChunk = (textChunk: TextChunkResponse) => void +export type IOnTextReplace = (textReplace: TextReplaceResponse) => void + type IOtherOptions = { isPublicAPI?: boolean bodyStringify?: boolean @@ -53,6 +68,13 @@ type IOtherOptions = { onError?: IOnError onCompleted?: IOnCompleted // for stream getAbortController?: (abortController: AbortController) => void + + onWorkflowStarted?: IOnWorkflowStarted + onWorkflowFinished?: IOnWorkflowFinished + onNodeStarted?: IOnNodeStarted + onNodeFinished?: IOnNodeFinished + onTextChunk?: IOnTextChunk + onTextReplace?: IOnTextReplace } type ResponseError = { @@ -83,7 +105,21 @@ export function format(text: string) { return res.replaceAll('\n', '
').replaceAll('```', '') } -const handleStream = (response: Response, onData: IOnData, onCompleted?: IOnCompleted, onThought?: IOnThought, onMessageEnd?: IOnMessageEnd, onMessageReplace?: IOnMessageReplace, onFile?: IOnFile) => { +const handleStream = ( + response: Response, + onData: IOnData, + onCompleted?: IOnCompleted, + onThought?: IOnThought, + onMessageEnd?: IOnMessageEnd, + onMessageReplace?: IOnMessageReplace, + onFile?: IOnFile, + onWorkflowStarted?: IOnWorkflowStarted, + onWorkflowFinished?: IOnWorkflowFinished, + onNodeStarted?: IOnNodeStarted, + onNodeFinished?: IOnNodeFinished, + onTextChunk?: IOnTextChunk, + onTextReplace?: IOnTextReplace, +) => { if (!response.ok) throw new Error('Network response was not ok') @@ -147,6 +183,24 @@ const handleStream = (response: Response, onData: IOnData, onCompleted?: IOnComp else if (bufferObj.event === 'message_replace') { onMessageReplace?.(bufferObj as MessageReplace) } + else if (bufferObj.event === 'workflow_started') { + onWorkflowStarted?.(bufferObj as WorkflowStartedResponse) + } + else if (bufferObj.event === 'workflow_finished') { + onWorkflowFinished?.(bufferObj as WorkflowFinishedResponse) + } + else if (bufferObj.event === 'node_started') { + onNodeStarted?.(bufferObj as NodeStartedResponse) + } + else if (bufferObj.event === 'node_finished') { + onNodeFinished?.(bufferObj as NodeFinishedResponse) + } + else if (bufferObj.event === 'text_chunk') { + onTextChunk?.(bufferObj as TextChunkResponse) + } + else if (bufferObj.event === 'text_replace') { + onTextReplace?.(bufferObj as TextReplaceResponse) + } } }) buffer = lines[lines.length - 1] diff --git a/web/types/workflow.ts b/web/types/workflow.ts index 900921bbf9..2be7494488 100644 --- a/web/types/workflow.ts +++ b/web/types/workflow.ts @@ -46,3 +46,88 @@ export type FetchWorkflowDraftResponse = { export type NodeTracingListResponse = { data: NodeTracing[] } + +export type WorkflowStartedResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + id: string + workflow_id: string + created_at: number + } +} + +export type WorkflowFinishedResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + id: string + workflow_id: string + status: string + outputs: any + error: string + elapsed_time: number + total_tokens: number + total_steps: number + created_at: number + finished_at: number + } +} + +export type NodeStartedResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + id: string + node_id: string + index: number + predecessor_node_id?: string + inputs: any + created_at: number + } +} + +export type NodeFinishedResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + id: string + node_id: string + index: number + predecessor_node_id?: string + inputs: any + process_data: any + outputs: any + status: string + error: string + elapsed_time: number + execution_metadata: { + total_tokens: number + total_price: number + currency: string + } + created_at: number + } +} + +export type TextChunkResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + text: string + } +} + +export type TextReplaceResponse = { + task_id: string + workflow_run_id: string + event: string + data: { + text: string + } +}