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:
55Kamiryo 2025-11-21 23:26:30 +09:00 committed by GitHub
parent a6c6bcf95c
commit 6d3ed468d8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 209 additions and 40 deletions

View File

@ -125,6 +125,12 @@ const TextGeneration: FC<IMainProps> = ({
transfer_methods: [TransferMethod.local_file], transfer_methods: [TransferMethod.local_file],
}) })
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([]) 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 = () => { const handleSend = () => {
setIsCallBatchAPI(false) setIsCallBatchAPI(false)
@ -417,6 +423,7 @@ const TextGeneration: FC<IMainProps> = ({
isPC={isPC} isPC={isPC}
isMobile={!isPC} isMobile={!isPC}
isInstalledApp={isInstalledApp} isInstalledApp={isInstalledApp}
appId={appId}
installedAppInfo={installedAppInfo} installedAppInfo={installedAppInfo}
isError={task?.status === TaskStatus.failed} isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig} promptConfig={promptConfig}
@ -434,6 +441,8 @@ const TextGeneration: FC<IMainProps> = ({
isShowTextToSpeech={!!textToSpeechConfig?.enabled} isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo} siteInfo={siteInfo}
onRunStart={() => setResultExisted(true)} onRunStart={() => setResultExisted(true)}
onRunControlChange={!isCallBatchAPI ? setRunControl : undefined}
hideInlineStopButton={!isCallBatchAPI}
/>) />)
const renderBatchRes = () => { const renderBatchRes = () => {
@ -565,6 +574,7 @@ const TextGeneration: FC<IMainProps> = ({
onSend={handleSend} onSend={handleSend}
visionConfig={visionConfig} visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles} onVisionFilesChange={setCompletionFiles}
runControl={runControl}
/> />
</div> </div>
<div className={cn(isInBatchTab ? 'block' : 'hidden')}> <div className={cn(isInBatchTab ? 'block' : 'hidden')}>

View File

@ -1,13 +1,16 @@
'use client' 'use client'
import type { FC } from 'react' 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 { useBoolean } from 'ahooks'
import { t } from 'i18next' import { t } from 'i18next'
import { produce } from 'immer' import { produce } from 'immer'
import TextGenerationRes from '@/app/components/app/text-generate/item' import TextGenerationRes from '@/app/components/app/text-generate/item'
import NoData from '@/app/components/share/text-generation/no-data' import NoData from '@/app/components/share/text-generation/no-data'
import Toast from '@/app/components/base/toast' 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 type { FeedbackType } from '@/app/components/base/chat/chat/type'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import type { PromptConfig } from '@/models/debug' import type { PromptConfig } from '@/models/debug'
@ -31,6 +34,7 @@ export type IResultProps = {
isPC: boolean isPC: boolean
isMobile: boolean isMobile: boolean
isInstalledApp: boolean isInstalledApp: boolean
appId: string
installedAppInfo?: InstalledApp installedAppInfo?: InstalledApp
isError: boolean isError: boolean
isShowTextToSpeech: boolean isShowTextToSpeech: boolean
@ -48,6 +52,8 @@ export type IResultProps = {
completionFiles: VisionFile[] completionFiles: VisionFile[]
siteInfo: SiteInfo | null siteInfo: SiteInfo | null
onRunStart: () => void onRunStart: () => void
onRunControlChange?: (control: { onStop: () => Promise<void> | void; isStopping: boolean } | null) => void
hideInlineStopButton?: boolean
} }
const Result: FC<IResultProps> = ({ const Result: FC<IResultProps> = ({
@ -56,6 +62,7 @@ const Result: FC<IResultProps> = ({
isPC, isPC,
isMobile, isMobile,
isInstalledApp, isInstalledApp,
appId,
installedAppInfo, installedAppInfo,
isError, isError,
isShowTextToSpeech, isShowTextToSpeech,
@ -73,13 +80,10 @@ const Result: FC<IResultProps> = ({
completionFiles, completionFiles,
siteInfo, siteInfo,
onRunStart, onRunStart,
onRunControlChange,
hideInlineStopButton = false,
}) => { }) => {
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false) const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
useEffect(() => {
if (controlStopResponding)
setRespondingFalse()
}, [controlStopResponding])
const [completionRes, doSetCompletionRes] = useState<string>('') const [completionRes, doSetCompletionRes] = useState<string>('')
const completionResRef = useRef<string>('') const completionResRef = useRef<string>('')
const setCompletionRes = (res: string) => { const setCompletionRes = (res: string) => {
@ -94,6 +98,29 @@ const Result: FC<IResultProps> = ({
doSetWorkflowProcessData(data) doSetWorkflowProcessData(data)
} }
const getWorkflowProcessData = () => workflowProcessDataRef.current 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 { notify } = Toast
const isNoData = !completionRes const isNoData = !completionRes
@ -112,6 +139,40 @@ const Result: FC<IResultProps> = ({
notify({ type: 'error', message }) 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 = () => { const checkCanSend = () => {
// batch will check outer // batch will check outer
if (isCallBatchAPI) if (isCallBatchAPI)
@ -196,6 +257,7 @@ const Result: FC<IResultProps> = ({
rating: null, rating: null,
}) })
setCompletionRes('') setCompletionRes('')
resetRunState()
let res: string[] = [] let res: string[] = []
let tempMessageId = '' let tempMessageId = ''
@ -213,6 +275,7 @@ const Result: FC<IResultProps> = ({
if (!isEnd) { if (!isEnd) {
setRespondingFalse() setRespondingFalse()
onCompleted(getCompletionRes(), taskId, false) onCompleted(getCompletionRes(), taskId, false)
resetRunState()
isTimeout = true isTimeout = true
} }
})() })()
@ -221,8 +284,10 @@ const Result: FC<IResultProps> = ({
sendWorkflowMessage( sendWorkflowMessage(
data, data,
{ {
onWorkflowStarted: ({ workflow_run_id }) => { onWorkflowStarted: ({ workflow_run_id, task_id }) => {
tempMessageId = workflow_run_id tempMessageId = workflow_run_id
setCurrentTaskId(task_id || null)
setIsStopping(false)
setWorkflowProcessData({ setWorkflowProcessData({
status: WorkflowRunningStatus.Running, status: WorkflowRunningStatus.Running,
tracing: [], tracing: [],
@ -330,12 +395,38 @@ const Result: FC<IResultProps> = ({
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') }) notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return 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) { if (data.error) {
notify({ type: 'error', message: data.error }) notify({ type: 'error', message: data.error })
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => { setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.status = WorkflowRunningStatus.Failed draft.status = WorkflowRunningStatus.Failed
markNodesStopped(draft.tracing)
})) }))
setRespondingFalse() setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false) onCompleted(getCompletionRes(), taskId, false)
isEnd = true isEnd = true
return return
@ -357,6 +448,7 @@ const Result: FC<IResultProps> = ({
} }
} }
setRespondingFalse() setRespondingFalse()
resetRunState()
setMessageId(tempMessageId) setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true) onCompleted(getCompletionRes(), taskId, true)
isEnd = true isEnd = true
@ -376,12 +468,19 @@ const Result: FC<IResultProps> = ({
}, },
isInstalledApp, isInstalledApp,
installedAppInfo?.id, installedAppInfo?.id,
) ).catch((error) => {
setRespondingFalse()
resetRunState()
const message = error instanceof Error ? error.message : String(error)
notify({ type: 'error', message })
})
} }
else { else {
sendCompletionMessage(data, { sendCompletionMessage(data, {
onData: (data: string, _isFirstMessage: boolean, { messageId }) => { onData: (data: string, _isFirstMessage: boolean, { messageId, taskId }) => {
tempMessageId = messageId tempMessageId = messageId
if (taskId && typeof taskId === 'string' && taskId.trim() !== '')
setCurrentTaskId(prev => prev ?? taskId)
res.push(data) res.push(data)
setCompletionRes(res.join('')) setCompletionRes(res.join(''))
}, },
@ -391,6 +490,7 @@ const Result: FC<IResultProps> = ({
return return
} }
setRespondingFalse() setRespondingFalse()
resetRunState()
setMessageId(tempMessageId) setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true) onCompleted(getCompletionRes(), taskId, true)
isEnd = true isEnd = true
@ -405,9 +505,13 @@ const Result: FC<IResultProps> = ({
return return
} }
setRespondingFalse() setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false) onCompleted(getCompletionRes(), taskId, false)
isEnd = true isEnd = true
}, },
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
}, isInstalledApp, installedAppInfo?.id) }, isInstalledApp, installedAppInfo?.id)
} }
} }
@ -426,28 +530,46 @@ const Result: FC<IResultProps> = ({
}, [controlRetry]) }, [controlRetry])
const renderTextGenerationRes = () => ( const renderTextGenerationRes = () => (
<TextGenerationRes <>
isWorkflow={isWorkflow} {!hideInlineStopButton && isResponding && currentTaskId && (
workflowProcessData={workflowProcessData} <div className={`mb-3 flex ${isPC ? 'justify-end' : 'justify-center'}`}>
isError={isError} <Button
onRetry={handleSend} variant='secondary'
content={completionRes} disabled={isStopping}
messageId={messageId} onClick={handleStop}
isInWebApp >
moreLikeThis={moreLikeThisEnabled} {
onFeedback={handleFeedback} isStopping
feedback={feedback} ? <RiLoader2Line className='mr-[5px] h-3.5 w-3.5 animate-spin' />
onSave={handleSaveMessage} : <StopCircle className='mr-[5px] h-3.5 w-3.5' />
isMobile={isMobile} }
isInstalledApp={isInstalledApp} <span className='text-xs font-normal'>{t('appDebug.operation.stopResponding')}</span>
installedAppId={installedAppInfo?.id} </Button>
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false} </div>
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined} )}
controlClearMoreLikeThis={controlClearMoreLikeThis} <TextGenerationRes
isShowTextToSpeech={isShowTextToSpeech} isWorkflow={isWorkflow}
hideProcessDetail workflowProcessData={workflowProcessData}
siteInfo={siteInfo} 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 ( return (

View File

@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import React, { useCallback } from 'react' import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { import {
RiLoader2Line,
RiPlayLargeLine, RiPlayLargeLine,
} from '@remixicon/react' } from '@remixicon/react'
import Select from '@/app/components/base/select' 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 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 CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types' import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
export type IRunOnceProps = { export type IRunOnceProps = {
siteInfo: SiteInfo siteInfo: SiteInfo
@ -30,6 +32,10 @@ export type IRunOnceProps = {
onSend: () => void onSend: () => void
visionConfig: VisionSettings visionConfig: VisionSettings
onVisionFilesChange: (files: VisionFile[]) => void onVisionFilesChange: (files: VisionFile[]) => void
runControl?: {
onStop: () => Promise<void> | void
isStopping: boolean
} | null
} }
const RunOnce: FC<IRunOnceProps> = ({ const RunOnce: FC<IRunOnceProps> = ({
promptConfig, promptConfig,
@ -39,6 +45,7 @@ const RunOnce: FC<IRunOnceProps> = ({
onSend, onSend,
visionConfig, visionConfig,
onVisionFilesChange, onVisionFilesChange,
runControl,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const media = useBreakpoints() const media = useBreakpoints()
@ -62,6 +69,14 @@ const RunOnce: FC<IRunOnceProps> = ({
e.preventDefault() e.preventDefault()
onSend() 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>) => { const handleInputsChange = useCallback((newInputs: Record<string, any>) => {
onInputsChange(newInputs) onInputsChange(newInputs)
@ -211,12 +226,25 @@ const RunOnce: FC<IRunOnceProps> = ({
</Button> </Button>
<Button <Button
className={cn(!isPC && 'grow')} className={cn(!isPC && 'grow')}
type='submit' type={isRunning ? 'button' : 'submit'}
variant="primary" variant={isRunning ? 'secondary' : 'primary'}
disabled={false} disabled={isRunning && runControl?.isStopping}
onClick={handlePrimaryClick}
> >
<RiPlayLargeLine className="mr-1 h-4 w-4 shrink-0" aria-hidden="true" /> {isRunning ? (
<span className='text-[13px]'>{t('share.generation.run')}</span> <>
{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> </Button>
</div> </div>
</div> </div>

View File

@ -7,6 +7,7 @@ import { produce } from 'immer'
import { v4 as uuidV4 } from 'uuid' import { v4 as uuidV4 } from 'uuid'
import { usePathname } from 'next/navigation' import { usePathname } from 'next/navigation'
import { useWorkflowStore } from '@/app/components/workflow/store' import { useWorkflowStore } from '@/app/components/workflow/store'
import type { Node } from '@/app/components/workflow/types'
import { WorkflowRunningStatus } from '@/app/components/workflow/types' import { WorkflowRunningStatus } from '@/app/components/workflow/types'
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions' 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' import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
@ -152,7 +153,7 @@ export const useWorkflowRun = () => {
getNodes, getNodes,
setNodes, setNodes,
} = store.getState() } = store.getState()
const newNodes = produce(getNodes(), (draft) => { const newNodes = produce(getNodes(), (draft: Node[]) => {
draft.forEach((node) => { draft.forEach((node) => {
node.data.selected = false node.data.selected = false
node.data._runningStatus = undefined node.data._runningStatus = undefined

View File

@ -63,6 +63,7 @@ const translation = {
csvStructureTitle: 'The CSV file must conform to the following structure:', csvStructureTitle: 'The CSV file must conform to the following structure:',
downloadTemplate: 'Download the template here', downloadTemplate: 'Download the template here',
field: 'Field', field: 'Field',
stopRun: 'Stop Run',
batchFailed: { batchFailed: {
info: '{{num}} failed executions', info: '{{num}} failed executions',
retry: 'Retry', retry: 'Retry',

View File

@ -78,18 +78,19 @@ export const stopChatMessageResponding = async (appId: string, taskId: string, i
return getAction('post', isInstalledApp)(getUrl(`chat-messages/${taskId}/stop`, isInstalledApp, installedAppId)) 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 onData: IOnData
onCompleted: IOnCompleted onCompleted: IOnCompleted
onError: IOnError onError: IOnError
onMessageReplace: IOnMessageReplace onMessageReplace: IOnMessageReplace
getAbortController?: (abortController: AbortController) => void
}, isInstalledApp: boolean, installedAppId = '') => { }, isInstalledApp: boolean, installedAppId = '') => {
return ssePost(getUrl('completion-messages', isInstalledApp, installedAppId), { return ssePost(getUrl('completion-messages', isInstalledApp, installedAppId), {
body: { body: {
...body, ...body,
response_mode: 'streaming', response_mode: 'streaming',
}, },
}, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, onMessageReplace }) }, { onData, onCompleted, isPublicAPI: !isInstalledApp, onError, onMessageReplace, getAbortController })
} }
export const sendWorkflowMessage = async ( 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 () => { export const fetchAppInfo = async () => {
return get('/site') as Promise<AppData> return get('/site') as Promise<AppData>
} }