mirror of
https://github.com/langgenius/dify.git
synced 2025-12-03 14:27:43 +00:00
feat: add a stop run button to the published app UI (#27509)
Co-authored-by: crazywoola <100913391+crazywoola@users.noreply.github.com>
This commit is contained in:
parent
a6c6bcf95c
commit
6d3ed468d8
@ -125,6 +125,12 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
transfer_methods: [TransferMethod.local_file],
|
||||
})
|
||||
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
|
||||
const [runControl, setRunControl] = useState<{ onStop: () => Promise<void> | void; isStopping: boolean } | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (isCallBatchAPI)
|
||||
setRunControl(null)
|
||||
}, [isCallBatchAPI])
|
||||
|
||||
const handleSend = () => {
|
||||
setIsCallBatchAPI(false)
|
||||
@ -417,6 +423,7 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
isPC={isPC}
|
||||
isMobile={!isPC}
|
||||
isInstalledApp={isInstalledApp}
|
||||
appId={appId}
|
||||
installedAppInfo={installedAppInfo}
|
||||
isError={task?.status === TaskStatus.failed}
|
||||
promptConfig={promptConfig}
|
||||
@ -434,6 +441,8 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
|
||||
siteInfo={siteInfo}
|
||||
onRunStart={() => setResultExisted(true)}
|
||||
onRunControlChange={!isCallBatchAPI ? setRunControl : undefined}
|
||||
hideInlineStopButton={!isCallBatchAPI}
|
||||
/>)
|
||||
|
||||
const renderBatchRes = () => {
|
||||
@ -565,6 +574,7 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
onSend={handleSend}
|
||||
visionConfig={visionConfig}
|
||||
onVisionFilesChange={setCompletionFiles}
|
||||
runControl={runControl}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(isInBatchTab ? 'block' : 'hidden')}>
|
||||
|
||||
@ -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> | void; isStopping: boolean } | null) => void
|
||||
hideInlineStopButton?: boolean
|
||||
}
|
||||
|
||||
const Result: FC<IResultProps> = ({
|
||||
@ -56,6 +62,7 @@ const Result: FC<IResultProps> = ({
|
||||
isPC,
|
||||
isMobile,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
installedAppInfo,
|
||||
isError,
|
||||
isShowTextToSpeech,
|
||||
@ -73,13 +80,10 @@ const Result: FC<IResultProps> = ({
|
||||
completionFiles,
|
||||
siteInfo,
|
||||
onRunStart,
|
||||
onRunControlChange,
|
||||
hideInlineStopButton = false,
|
||||
}) => {
|
||||
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
|
||||
useEffect(() => {
|
||||
if (controlStopResponding)
|
||||
setRespondingFalse()
|
||||
}, [controlStopResponding])
|
||||
|
||||
const [completionRes, doSetCompletionRes] = useState<string>('')
|
||||
const completionResRef = useRef<string>('')
|
||||
const setCompletionRes = (res: string) => {
|
||||
@ -94,6 +98,29 @@ const Result: FC<IResultProps> = ({
|
||||
doSetWorkflowProcessData(data)
|
||||
}
|
||||
const getWorkflowProcessData = () => workflowProcessDataRef.current
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
const abortControllerRef = useRef<AbortController | null>(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<IResultProps> = ({
|
||||
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<IResultProps> = ({
|
||||
rating: null,
|
||||
})
|
||||
setCompletionRes('')
|
||||
resetRunState()
|
||||
|
||||
let res: string[] = []
|
||||
let tempMessageId = ''
|
||||
@ -213,6 +275,7 @@ const Result: FC<IResultProps> = ({
|
||||
if (!isEnd) {
|
||||
setRespondingFalse()
|
||||
onCompleted(getCompletionRes(), taskId, false)
|
||||
resetRunState()
|
||||
isTimeout = true
|
||||
}
|
||||
})()
|
||||
@ -221,8 +284,10 @@ const Result: FC<IResultProps> = ({
|
||||
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<IResultProps> = ({
|
||||
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<IResultProps> = ({
|
||||
}
|
||||
}
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
setMessageId(tempMessageId)
|
||||
onCompleted(getCompletionRes(), taskId, true)
|
||||
isEnd = true
|
||||
@ -376,12 +468,19 @@ const Result: FC<IResultProps> = ({
|
||||
},
|
||||
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<IResultProps> = ({
|
||||
return
|
||||
}
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
setMessageId(tempMessageId)
|
||||
onCompleted(getCompletionRes(), taskId, true)
|
||||
isEnd = true
|
||||
@ -405,9 +505,13 @@ const Result: FC<IResultProps> = ({
|
||||
return
|
||||
}
|
||||
setRespondingFalse()
|
||||
resetRunState()
|
||||
onCompleted(getCompletionRes(), taskId, false)
|
||||
isEnd = true
|
||||
},
|
||||
getAbortController: (abortController) => {
|
||||
abortControllerRef.current = abortController
|
||||
},
|
||||
}, isInstalledApp, installedAppInfo?.id)
|
||||
}
|
||||
}
|
||||
@ -426,28 +530,46 @@ const Result: FC<IResultProps> = ({
|
||||
}, [controlRetry])
|
||||
|
||||
const renderTextGenerationRes = () => (
|
||||
<TextGenerationRes
|
||||
isWorkflow={isWorkflow}
|
||||
workflowProcessData={workflowProcessData}
|
||||
isError={isError}
|
||||
onRetry={handleSend}
|
||||
content={completionRes}
|
||||
messageId={messageId}
|
||||
isInWebApp
|
||||
moreLikeThis={moreLikeThisEnabled}
|
||||
onFeedback={handleFeedback}
|
||||
feedback={feedback}
|
||||
onSave={handleSaveMessage}
|
||||
isMobile={isMobile}
|
||||
isInstalledApp={isInstalledApp}
|
||||
installedAppId={installedAppInfo?.id}
|
||||
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
|
||||
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
|
||||
controlClearMoreLikeThis={controlClearMoreLikeThis}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
hideProcessDetail
|
||||
siteInfo={siteInfo}
|
||||
/>
|
||||
<>
|
||||
{!hideInlineStopButton && isResponding && currentTaskId && (
|
||||
<div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}>
|
||||
<Button
|
||||
variant='secondary'
|
||||
disabled={isStopping}
|
||||
onClick={handleStop}
|
||||
>
|
||||
{
|
||||
isStopping
|
||||
? <RiLoader2Line className='mr-[5px] h-3.5 w-3.5 animate-spin' />
|
||||
: <StopCircle className='mr-[5px] h-3.5 w-3.5' />
|
||||
}
|
||||
<span className='text-xs font-normal'>{t('appDebug.operation.stopResponding')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<TextGenerationRes
|
||||
isWorkflow={isWorkflow}
|
||||
workflowProcessData={workflowProcessData}
|
||||
isError={isError}
|
||||
onRetry={handleSend}
|
||||
content={completionRes}
|
||||
messageId={messageId}
|
||||
isInWebApp
|
||||
moreLikeThis={moreLikeThisEnabled}
|
||||
onFeedback={handleFeedback}
|
||||
feedback={feedback}
|
||||
onSave={handleSaveMessage}
|
||||
isMobile={isMobile}
|
||||
isInstalledApp={isInstalledApp}
|
||||
installedAppId={installedAppInfo?.id}
|
||||
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
|
||||
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
|
||||
controlClearMoreLikeThis={controlClearMoreLikeThis}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
hideProcessDetail
|
||||
siteInfo={siteInfo}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@ -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> | void
|
||||
isStopping: boolean
|
||||
} | null
|
||||
}
|
||||
const RunOnce: FC<IRunOnceProps> = ({
|
||||
promptConfig,
|
||||
@ -39,6 +45,7 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
onSend,
|
||||
visionConfig,
|
||||
onVisionFilesChange,
|
||||
runControl,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
@ -62,6 +69,14 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
e.preventDefault()
|
||||
onSend()
|
||||
}
|
||||
const isRunning = !!runControl
|
||||
const stopLabel = t('share.generation.stopRun', { defaultValue: 'Stop Run' })
|
||||
const handlePrimaryClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (!isRunning)
|
||||
return
|
||||
e.preventDefault()
|
||||
runControl?.onStop?.()
|
||||
}, [isRunning, runControl])
|
||||
|
||||
const handleInputsChange = useCallback((newInputs: Record<string, any>) => {
|
||||
onInputsChange(newInputs)
|
||||
@ -211,12 +226,25 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
</Button>
|
||||
<Button
|
||||
className={cn(!isPC && 'grow')}
|
||||
type='submit'
|
||||
variant="primary"
|
||||
disabled={false}
|
||||
type={isRunning ? 'button' : 'submit'}
|
||||
variant={isRunning ? 'secondary' : 'primary'}
|
||||
disabled={isRunning && runControl?.isStopping}
|
||||
onClick={handlePrimaryClick}
|
||||
>
|
||||
<RiPlayLargeLine className="mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className='text-[13px]'>{t('share.generation.run')}</span>
|
||||
{isRunning ? (
|
||||
<>
|
||||
{runControl?.isStopping
|
||||
? <RiLoader2Line className='mr-1 h-4 w-4 shrink-0 animate-spin' aria-hidden="true" />
|
||||
: <StopCircle className='mr-1 h-4 w-4 shrink-0' aria-hidden="true" />
|
||||
}
|
||||
<span className='text-[13px]'>{stopLabel}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RiPlayLargeLine className="mr-1 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<span className='text-[13px]'>{t('share.generation.run')}</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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<string, any>, { onData, onCompleted, onError, onMessageReplace }: {
|
||||
export const sendCompletionMessage = async (body: Record<string, any>, { 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<AppData>
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user