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],
})
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')}>

View File

@ -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 (

View File

@ -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>

View File

@ -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

View File

@ -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',

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))
}
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>
}