diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 98804c7311..f5cb7005b8 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -125,6 +125,12 @@ const TextGeneration: FC = ({ transfer_methods: [TransferMethod.local_file], }) const [completionFiles, setCompletionFiles] = useState([]) + const [runControl, setRunControl] = useState<{ onStop: () => Promise | void; isStopping: boolean } | null>(null) + + useEffect(() => { + if (isCallBatchAPI) + setRunControl(null) + }, [isCallBatchAPI]) const handleSend = () => { setIsCallBatchAPI(false) @@ -417,6 +423,7 @@ const TextGeneration: FC = ({ isPC={isPC} isMobile={!isPC} isInstalledApp={isInstalledApp} + appId={appId} installedAppInfo={installedAppInfo} isError={task?.status === TaskStatus.failed} promptConfig={promptConfig} @@ -434,6 +441,8 @@ const TextGeneration: FC = ({ isShowTextToSpeech={!!textToSpeechConfig?.enabled} siteInfo={siteInfo} onRunStart={() => setResultExisted(true)} + onRunControlChange={!isCallBatchAPI ? setRunControl : undefined} + hideInlineStopButton={!isCallBatchAPI} />) const renderBatchRes = () => { @@ -565,6 +574,7 @@ const TextGeneration: FC = ({ onSend={handleSend} visionConfig={visionConfig} onVisionFilesChange={setCompletionFiles} + runControl={runControl} />
diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index 7d21df448d..8cf5494bc9 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -1,13 +1,16 @@ 'use client' import type { FC } from 'react' -import React, { useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react' import { useBoolean } from 'ahooks' import { t } from 'i18next' import { produce } from 'immer' import TextGenerationRes from '@/app/components/app/text-generate/item' import NoData from '@/app/components/share/text-generation/no-data' import Toast from '@/app/components/base/toast' -import { sendCompletionMessage, sendWorkflowMessage, updateFeedback } from '@/service/share' +import Button from '@/app/components/base/button' +import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' +import { RiLoader2Line } from '@remixicon/react' +import { sendCompletionMessage, sendWorkflowMessage, stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share' import type { FeedbackType } from '@/app/components/base/chat/chat/type' import Loading from '@/app/components/base/loading' import type { PromptConfig } from '@/models/debug' @@ -31,6 +34,7 @@ export type IResultProps = { isPC: boolean isMobile: boolean isInstalledApp: boolean + appId: string installedAppInfo?: InstalledApp isError: boolean isShowTextToSpeech: boolean @@ -48,6 +52,8 @@ export type IResultProps = { completionFiles: VisionFile[] siteInfo: SiteInfo | null onRunStart: () => void + onRunControlChange?: (control: { onStop: () => Promise | void; isStopping: boolean } | null) => void + hideInlineStopButton?: boolean } const Result: FC = ({ @@ -56,6 +62,7 @@ const Result: FC = ({ isPC, isMobile, isInstalledApp, + appId, installedAppInfo, isError, isShowTextToSpeech, @@ -73,13 +80,10 @@ const Result: FC = ({ completionFiles, siteInfo, onRunStart, + onRunControlChange, + hideInlineStopButton = false, }) => { const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) - useEffect(() => { - if (controlStopResponding) - setRespondingFalse() - }, [controlStopResponding]) - const [completionRes, doSetCompletionRes] = useState('') const completionResRef = useRef('') const setCompletionRes = (res: string) => { @@ -94,6 +98,29 @@ const Result: FC = ({ doSetWorkflowProcessData(data) } const getWorkflowProcessData = () => workflowProcessDataRef.current + const [currentTaskId, setCurrentTaskId] = useState(null) + const [isStopping, setIsStopping] = useState(false) + const abortControllerRef = useRef(null) + const resetRunState = useCallback(() => { + setCurrentTaskId(null) + setIsStopping(false) + abortControllerRef.current = null + onRunControlChange?.(null) + }, [onRunControlChange]) + + useEffect(() => { + const abortCurrentRequest = () => { + abortControllerRef.current?.abort() + } + + if (controlStopResponding) { + abortCurrentRequest() + setRespondingFalse() + resetRunState() + } + + return abortCurrentRequest + }, [controlStopResponding, resetRunState, setRespondingFalse]) const { notify } = Toast const isNoData = !completionRes @@ -112,6 +139,40 @@ const Result: FC = ({ notify({ type: 'error', message }) } + const handleStop = useCallback(async () => { + if (!currentTaskId || isStopping) + return + setIsStopping(true) + try { + if (isWorkflow) + await stopWorkflowMessage(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + else + await stopChatMessageResponding(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + abortControllerRef.current?.abort() + } + catch (error) { + const message = error instanceof Error ? error.message : String(error) + notify({ type: 'error', message }) + } + finally { + setIsStopping(false) + } + }, [appId, currentTaskId, installedAppInfo?.id, isInstalledApp, isStopping, isWorkflow, notify]) + + useEffect(() => { + if (!onRunControlChange) + return + if (isResponding && currentTaskId) { + onRunControlChange({ + onStop: handleStop, + isStopping, + }) + } + else { + onRunControlChange(null) + } + }, [currentTaskId, handleStop, isResponding, isStopping, onRunControlChange]) + const checkCanSend = () => { // batch will check outer if (isCallBatchAPI) @@ -196,6 +257,7 @@ const Result: FC = ({ rating: null, }) setCompletionRes('') + resetRunState() let res: string[] = [] let tempMessageId = '' @@ -213,6 +275,7 @@ const Result: FC = ({ if (!isEnd) { setRespondingFalse() onCompleted(getCompletionRes(), taskId, false) + resetRunState() isTimeout = true } })() @@ -221,8 +284,10 @@ const Result: FC = ({ sendWorkflowMessage( data, { - onWorkflowStarted: ({ workflow_run_id }) => { + onWorkflowStarted: ({ workflow_run_id, task_id }) => { tempMessageId = workflow_run_id + setCurrentTaskId(task_id || null) + setIsStopping(false) setWorkflowProcessData({ status: WorkflowRunningStatus.Running, tracing: [], @@ -330,12 +395,38 @@ const Result: FC = ({ notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') }) return } + const workflowStatus = data.status as WorkflowRunningStatus | undefined + const markNodesStopped = (traces?: WorkflowProcess['tracing']) => { + if (!traces) + return + const markTrace = (trace: WorkflowProcess['tracing'][number]) => { + if ([NodeRunningStatus.Running, NodeRunningStatus.Waiting].includes(trace.status as NodeRunningStatus)) + trace.status = NodeRunningStatus.Stopped + trace.details?.forEach(detailGroup => detailGroup.forEach(markTrace)) + trace.retryDetail?.forEach(markTrace) + trace.parallelDetail?.children?.forEach(markTrace) + } + traces.forEach(markTrace) + } + if (workflowStatus === WorkflowRunningStatus.Stopped) { + setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { + draft.status = WorkflowRunningStatus.Stopped + markNodesStopped(draft.tracing) + })) + setRespondingFalse() + resetRunState() + onCompleted(getCompletionRes(), taskId, false) + isEnd = true + return + } if (data.error) { notify({ type: 'error', message: data.error }) setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { draft.status = WorkflowRunningStatus.Failed + markNodesStopped(draft.tracing) })) setRespondingFalse() + resetRunState() onCompleted(getCompletionRes(), taskId, false) isEnd = true return @@ -357,6 +448,7 @@ const Result: FC = ({ } } setRespondingFalse() + resetRunState() setMessageId(tempMessageId) onCompleted(getCompletionRes(), taskId, true) isEnd = true @@ -376,12 +468,19 @@ const Result: FC = ({ }, isInstalledApp, installedAppInfo?.id, - ) + ).catch((error) => { + setRespondingFalse() + resetRunState() + const message = error instanceof Error ? error.message : String(error) + notify({ type: 'error', message }) + }) } else { sendCompletionMessage(data, { - onData: (data: string, _isFirstMessage: boolean, { messageId }) => { + onData: (data: string, _isFirstMessage: boolean, { messageId, taskId }) => { tempMessageId = messageId + if (taskId && typeof taskId === 'string' && taskId.trim() !== '') + setCurrentTaskId(prev => prev ?? taskId) res.push(data) setCompletionRes(res.join('')) }, @@ -391,6 +490,7 @@ const Result: FC = ({ return } setRespondingFalse() + resetRunState() setMessageId(tempMessageId) onCompleted(getCompletionRes(), taskId, true) isEnd = true @@ -405,9 +505,13 @@ const Result: FC = ({ return } setRespondingFalse() + resetRunState() onCompleted(getCompletionRes(), taskId, false) isEnd = true }, + getAbortController: (abortController) => { + abortControllerRef.current = abortController + }, }, isInstalledApp, installedAppInfo?.id) } } @@ -426,28 +530,46 @@ const Result: FC = ({ }, [controlRetry]) const renderTextGenerationRes = () => ( - + <> + {!hideInlineStopButton && isResponding && currentTaskId && ( +
+ +
+ )} + + ) return ( diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index 112f08a1d7..379d885ff1 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useState } from 'react' import React, { useCallback } from 'react' import { useTranslation } from 'react-i18next' import { + RiLoader2Line, RiPlayLargeLine, } from '@remixicon/react' import Select from '@/app/components/base/select' @@ -20,6 +21,7 @@ import cn from '@/utils/classnames' import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input' import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' +import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' export type IRunOnceProps = { siteInfo: SiteInfo @@ -30,6 +32,10 @@ export type IRunOnceProps = { onSend: () => void visionConfig: VisionSettings onVisionFilesChange: (files: VisionFile[]) => void + runControl?: { + onStop: () => Promise | void + isStopping: boolean + } | null } const RunOnce: FC = ({ promptConfig, @@ -39,6 +45,7 @@ const RunOnce: FC = ({ onSend, visionConfig, onVisionFilesChange, + runControl, }) => { const { t } = useTranslation() const media = useBreakpoints() @@ -62,6 +69,14 @@ const RunOnce: FC = ({ e.preventDefault() onSend() } + const isRunning = !!runControl + const stopLabel = t('share.generation.stopRun', { defaultValue: 'Stop Run' }) + const handlePrimaryClick = useCallback((e: React.MouseEvent) => { + if (!isRunning) + return + e.preventDefault() + runControl?.onStop?.() + }, [isRunning, runControl]) const handleInputsChange = useCallback((newInputs: Record) => { onInputsChange(newInputs) @@ -211,12 +226,25 @@ const RunOnce: FC = ({
diff --git a/web/app/components/workflow-app/hooks/use-workflow-run.ts b/web/app/components/workflow-app/hooks/use-workflow-run.ts index c8949651e5..6164969b3d 100644 --- a/web/app/components/workflow-app/hooks/use-workflow-run.ts +++ b/web/app/components/workflow-app/hooks/use-workflow-run.ts @@ -7,6 +7,7 @@ import { produce } from 'immer' import { v4 as uuidV4 } from 'uuid' import { usePathname } from 'next/navigation' import { useWorkflowStore } from '@/app/components/workflow/store' +import type { Node } from '@/app/components/workflow/types' import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event' @@ -152,7 +153,7 @@ export const useWorkflowRun = () => { getNodes, setNodes, } = store.getState() - const newNodes = produce(getNodes(), (draft) => { + const newNodes = produce(getNodes(), (draft: Node[]) => { draft.forEach((node) => { node.data.selected = false node.data._runningStatus = undefined diff --git a/web/i18n/en-US/share.ts b/web/i18n/en-US/share.ts index ab589ffb76..461a35d7bc 100644 --- a/web/i18n/en-US/share.ts +++ b/web/i18n/en-US/share.ts @@ -63,6 +63,7 @@ const translation = { csvStructureTitle: 'The CSV file must conform to the following structure:', downloadTemplate: 'Download the template here', field: 'Field', + stopRun: 'Stop Run', batchFailed: { info: '{{num}} failed executions', retry: 'Retry', diff --git a/web/service/share.ts b/web/service/share.ts index df08f0f3d6..dffd3aecb7 100644 --- a/web/service/share.ts +++ b/web/service/share.ts @@ -78,18 +78,19 @@ export const stopChatMessageResponding = async (appId: string, taskId: string, i return getAction('post', isInstalledApp)(getUrl(`chat-messages/${taskId}/stop`, isInstalledApp, installedAppId)) } -export const sendCompletionMessage = async (body: Record, { onData, onCompleted, onError, onMessageReplace }: { +export const sendCompletionMessage = async (body: Record, { onData, onCompleted, onError, onMessageReplace, getAbortController }: { onData: IOnData onCompleted: IOnCompleted onError: IOnError onMessageReplace: IOnMessageReplace + getAbortController?: (abortController: AbortController) => void }, isInstalledApp: boolean, installedAppId = '') => { return ssePost(getUrl('completion-messages', isInstalledApp, installedAppId), { body: { ...body, response_mode: 'streaming', }, - }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, onMessageReplace }) + }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, onMessageReplace, getAbortController }) } export const sendWorkflowMessage = async ( @@ -146,6 +147,12 @@ export const sendWorkflowMessage = async ( }) } +export const stopWorkflowMessage = async (_appId: string, taskId: string, isInstalledApp: boolean, installedAppId = '') => { + if (!taskId) + return + return getAction('post', isInstalledApp)(getUrl(`workflows/tasks/${taskId}/stop`, isInstalledApp, installedAppId)) +} + export const fetchAppInfo = async () => { return get('/site') as Promise }