This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.66699 1.33366C3.66699 0.965469 3.36852 0.666992 3.00033 0.666992C2.63214 0.666992 2.33366 0.965469 2.33366 1.33366V2.33366H1.33366C0.965469 2.33366 0.666992 2.63214 0.666992 3.00033C0.666992 3.36852 0.965469 3.66699 1.33366 3.66699H2.33366V4.66699C2.33366 5.03518 2.63214 5.33366 3.00033 5.33366C3.36852 5.33366 3.66699 5.03518 3.66699 4.66699V3.66699H4.66699C5.03518 3.66699 5.33366 3.36852 5.33366 3.00033C5.33366 2.63214 5.03518 2.33366 4.66699 2.33366H3.66699V1.33366Z" fill="#444CE7"/>
<path d="M3.66699 11.3337C3.66699 10.9655 3.36852 10.667 3.00033 10.667C2.63214 10.667 2.33366 10.9655 2.33366 11.3337V12.3337H1.33366C0.965469 12.3337 0.666992 12.6321 0.666992 13.0003C0.666992 13.3685 0.965469 13.667 1.33366 13.667H2.33366V14.667C2.33366 15.0352 2.63214 15.3337 3.00033 15.3337C3.36852 15.3337 3.66699 15.0352 3.66699 14.667V13.667H4.66699C5.03518 13.667 5.33366 13.3685 5.33366 13.0003C5.33366 12.6321 5.03518 12.3337 4.66699 12.3337H3.66699V11.3337Z" fill="#444CE7"/>
<path d="M9.28922 1.76101C9.1902 1.50354 8.94284 1.33366 8.66699 1.33366C8.39114 1.33366 8.14378 1.50354 8.04476 1.76101L6.88864 4.76691C6.68837 5.28761 6.62544 5.43766 6.53936 5.55872C6.45299 5.68019 6.34686 5.78632 6.22539 5.87269C6.10432 5.95878 5.95428 6.02171 5.43358 6.22198L2.42767 7.37809C2.17021 7.47712 2.00033 7.72448 2.00033 8.00033C2.00033 8.27617 2.17021 8.52353 2.42767 8.62256L5.43358 9.77867C5.95428 9.97894 6.10432 10.0419 6.22539 10.128C6.34686 10.2143 6.45299 10.3205 6.53936 10.4419C6.62544 10.563 6.68837 10.713 6.88864 11.2337L8.04476 14.2396C8.14379 14.4971 8.39114 14.667 8.66699 14.667C8.94284 14.667 9.1902 14.4971 9.28922 14.2396L10.4453 11.2337C10.6456 10.713 10.7085 10.563 10.7946 10.4419C10.881 10.3205 10.9871 10.2143 11.1086 10.128C11.2297 10.0419 11.3797 9.97894 11.9004 9.77867L14.9063 8.62256C15.1638 8.52353 15.3337 8.27617 15.3337 8.00033C15.3337 7.72448 15.1638 7.47712 14.9063 7.37809L11.9004 6.22198C11.3797 6.02171 11.2297 5.95878 11.1086 5.87269C10.9871 5.78632 10.881 5.68019 10.7946 5.55872C10.7085 5.43766 10.6456 5.28761 10.4453 4.76691L9.28922 1.76101Z" fill="#444CE7"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,648 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiBookmark3Line,
RiErrorWarningFill,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import { useSearchParams } from 'next/navigation'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import ResDownload from './run-batch/res-download'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import RunOnce from '@/app/components/share/text-generation/run-once'
import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share'
import type { SiteInfo } from '@/models/share'
import type {
MoreLikeThisConfig,
PromptConfig,
SavedMessage,
TextToSpeechConfig,
} from '@/models/debug'
import AppIcon from '@/app/components/base/app-icon'
import Badge from '@/app/components/base/badge'
import { changeLanguage } from '@/i18n-config/i18next-config'
import Loading from '@/app/components/base/loading'
import { userInputsFormToPromptVariables } from '@/utils/model-config'
import Res from '@/app/components/share/text-generation/result'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import type { InstalledApp } from '@/models/explore'
import { DEFAULT_VALUE_MAX_LEN, appDefaultIconBackground } from '@/config'
import Toast from '@/app/components/base/toast'
import type { VisionFile, VisionSettings } from '@/types/app'
import { Resolution, TransferMethod } from '@/types/app'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import cn from '@/utils/classnames'
import { AccessMode } from '@/models/access-control'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useWebAppStore } from '@/context/web-app-context'
const GROUP_SIZE = 5 // to avoid RPM(Request per minute) limit. The group task finished then the next group.
enum TaskStatus {
pending = 'pending',
running = 'running',
completed = 'completed',
failed = 'failed',
}
type TaskParam = {
inputs: Record<string, any>
}
type Task = {
id: number
status: TaskStatus
params: TaskParam
}
export type IMainProps = {
isInstalledApp?: boolean
installedAppInfo?: InstalledApp
isWorkflow?: boolean
}
const TextGeneration: FC<IMainProps> = ({
isInstalledApp = false,
installedAppInfo,
isWorkflow = false,
}) => {
const { notify } = Toast
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const searchParams = useSearchParams()
const mode = searchParams.get('mode') || 'create'
const [currentTab, setCurrentTab] = useState<string>(['create', 'batch'].includes(mode) ? mode : 'create')
// Notice this situation isCallBatchAPI but not in batch tab
const [isCallBatchAPI, setIsCallBatchAPI] = useState(false)
const isInBatchTab = currentTab === 'batch'
const [inputs, doSetInputs] = useState<Record<string, any>>({})
const inputsRef = useRef(inputs)
const setInputs = useCallback((newInputs: Record<string, any>) => {
doSetInputs(newInputs)
inputsRef.current = newInputs
}, [])
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const [appId, setAppId] = useState<string>('')
const [siteInfo, setSiteInfo] = useState<SiteInfo | null>(null)
const [customConfig, setCustomConfig] = useState<Record<string, any> | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
// save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = useCallback(async () => {
const res: any = await doFetchSavedMessage(isInstalledApp, appId)
setSavedMessages(res.data)
}, [isInstalledApp, appId])
const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.saved') })
fetchSavedMessage()
}
const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, isInstalledApp, appId)
notify({ type: 'success', message: t('common.api.remove') })
fetchSavedMessage()
}
// send message task
const [controlSend, setControlSend] = useState(0)
const [controlStopResponding, setControlStopResponding] = useState(0)
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
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)
setControlSend(Date.now())
// eslint-disable-next-line ts/no-use-before-define
setAllTaskList([]) // clear batch task running status
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
const [controlRetry, setControlRetry] = useState(0)
const handleRetryAllFailedTask = () => {
setControlRetry(Date.now())
}
const [allTaskList, doSetAllTaskList] = useState<Task[]>([])
const allTaskListRef = useRef<Task[]>([])
const getLatestTaskList = () => allTaskListRef.current
const setAllTaskList = (taskList: Task[]) => {
doSetAllTaskList(taskList)
allTaskListRef.current = taskList
}
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
const noPendingTask = pendingTaskList.length === 0
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
const currGroupNumRef = useRef(0)
const setCurrGroupNum = (num: number) => {
currGroupNumRef.current = num
}
const getCurrGroupNum = () => {
return currGroupNumRef.current
}
const allSuccessTaskList = allTaskList.filter(task => task.status === TaskStatus.completed)
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
const batchCompletionResRef = useRef<Record<string, string>>({})
const setBatchCompletionRes = (res: Record<string, string>) => {
batchCompletionResRef.current = res
}
const getBatchCompletionRes = () => batchCompletionResRef.current
const exportRes = allTaskList.map((task) => {
const batchCompletionResLatest = getBatchCompletionRes()
const res: Record<string, string> = {}
const { inputs } = task.params
promptConfig?.prompt_variables.forEach((v) => {
res[v.name] = inputs[v.key]
})
let result = batchCompletionResLatest[task.id]
// task might return multiple fields, should marshal object to string
if (typeof batchCompletionResLatest[task.id] === 'object')
result = JSON.stringify(result)
res[t('share.generation.completionResult')] = result
return res
})
const checkBatchInputs = (data: string[][]) => {
if (!data || data.length === 0) {
notify({ type: 'error', message: t('share.generation.errorMsg.empty') })
return false
}
const headerData = data[0]
let isMapVarName = true
promptConfig?.prompt_variables.forEach((item, index) => {
if (!isMapVarName)
return
if (item.name !== headerData[index])
isMapVarName = false
})
if (!isMapVarName) {
notify({ type: 'error', message: t('share.generation.errorMsg.fileStructNotMatch') })
return false
}
let payloadData = data.slice(1)
if (payloadData.length === 0) {
notify({ type: 'error', message: t('share.generation.errorMsg.atLeastOne') })
return false
}
// check middle empty line
const allEmptyLineIndexes = payloadData.filter(item => item.every(i => i === '')).map(item => payloadData.indexOf(item))
if (allEmptyLineIndexes.length > 0) {
let hasMiddleEmptyLine = false
let startIndex = allEmptyLineIndexes[0] - 1
allEmptyLineIndexes.forEach((index) => {
if (hasMiddleEmptyLine)
return
if (startIndex + 1 !== index) {
hasMiddleEmptyLine = true
return
}
startIndex++
})
if (hasMiddleEmptyLine) {
notify({ type: 'error', message: t('share.generation.errorMsg.emptyLine', { rowIndex: startIndex + 2 }) })
return false
}
}
// check row format
payloadData = payloadData.filter(item => !item.every(i => i === ''))
// after remove empty rows in the end, checked again
if (payloadData.length === 0) {
notify({ type: 'error', message: t('share.generation.errorMsg.atLeastOne') })
return false
}
let errorRowIndex = 0
let requiredVarName = ''
let moreThanMaxLengthVarName = ''
let maxLength = 0
payloadData.forEach((item, index) => {
if (errorRowIndex !== 0)
return
promptConfig?.prompt_variables.forEach((varItem, varIndex) => {
if (errorRowIndex !== 0)
return
if (varItem.type === 'string') {
const maxLen = varItem.max_length || DEFAULT_VALUE_MAX_LEN
if (item[varIndex].length > maxLen) {
moreThanMaxLengthVarName = varItem.name
maxLength = maxLen
errorRowIndex = index + 1
return
}
}
if (!varItem.required)
return
if (item[varIndex].trim() === '') {
requiredVarName = varItem.name
errorRowIndex = index + 1
}
})
})
if (errorRowIndex !== 0) {
if (requiredVarName)
notify({ type: 'error', message: t('share.generation.errorMsg.invalidLine', { rowIndex: errorRowIndex + 1, varName: requiredVarName }) })
if (moreThanMaxLengthVarName)
notify({ type: 'error', message: t('share.generation.errorMsg.moreThanMaxLengthLine', { rowIndex: errorRowIndex + 1, varName: moreThanMaxLengthVarName, maxLength }) })
return false
}
return true
}
const handleRunBatch = (data: string[][]) => {
if (!checkBatchInputs(data))
return
if (!allTasksFinished) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForBatchResponse') })
return
}
const payloadData = data.filter(item => !item.every(i => i === '')).slice(1)
const varLen = promptConfig?.prompt_variables.length || 0
setIsCallBatchAPI(true)
const allTaskList: Task[] = payloadData.map((item, i) => {
const inputs: Record<string, any> = {}
if (varLen > 0) {
item.slice(0, varLen).forEach((input, index) => {
const varSchema = promptConfig?.prompt_variables[index]
inputs[varSchema?.key as string] = input
if (!input) {
if (varSchema?.type === 'string' || varSchema?.type === 'paragraph')
inputs[varSchema?.key as string] = ''
else
inputs[varSchema?.key as string] = undefined
}
})
}
return {
id: i + 1,
status: i < GROUP_SIZE ? TaskStatus.running : TaskStatus.pending,
params: {
inputs,
},
}
})
setAllTaskList(allTaskList)
setCurrGroupNum(0)
setControlSend(Date.now())
// clear run once task status
setControlStopResponding(Date.now())
// eslint-disable-next-line ts/no-use-before-define
showResultPanel()
}
const handleCompleted = (completionRes: string, taskId?: number, isSuccess?: boolean) => {
const allTaskListLatest = getLatestTaskList()
const batchCompletionResLatest = getBatchCompletionRes()
const pendingTaskList = allTaskListLatest.filter(task => task.status === TaskStatus.pending)
const runTasksCount = 1 + allTaskListLatest.filter(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status)).length
const needToAddNextGroupTask = (getCurrGroupNum() !== runTasksCount) && pendingTaskList.length > 0 && (runTasksCount % GROUP_SIZE === 0 || (allTaskListLatest.length - runTasksCount < GROUP_SIZE))
// avoid add many task at the same time
if (needToAddNextGroupTask)
setCurrGroupNum(runTasksCount)
const nextPendingTaskIds = needToAddNextGroupTask ? pendingTaskList.slice(0, GROUP_SIZE).map(item => item.id) : []
const newAllTaskList = allTaskListLatest.map((item) => {
if (item.id === taskId) {
return {
...item,
status: isSuccess ? TaskStatus.completed : TaskStatus.failed,
}
}
if (needToAddNextGroupTask && nextPendingTaskIds.includes(item.id)) {
return {
...item,
status: TaskStatus.running,
}
}
return item
})
setAllTaskList(newAllTaskList)
if (taskId) {
setBatchCompletionRes({
...batchCompletionResLatest,
[`${taskId}`]: completionRes,
})
}
}
const appData = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const accessMode = useWebAppStore(s => s.webAppAccessMode)
useEffect(() => {
(async () => {
if (!appData || !appParams)
return
if (!isWorkflow)
fetchSavedMessage()
const { app_id: appId, site: siteInfo, custom_config } = appData
setAppId(appId)
setSiteInfo(siteInfo as SiteInfo)
setCustomConfig(custom_config)
await changeLanguage(siteInfo.default_language)
const { user_input_form, more_like_this, file_upload, text_to_speech }: any = appParams
setVisionConfig({
// legacy of image upload compatible
...file_upload,
transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods,
// legacy of image upload compatible
image_file_size_limit: appParams?.system_parameters.image_file_size_limit,
fileUploadConfig: appParams?.system_parameters,
} as any)
const prompt_variables = userInputsFormToPromptVariables(user_input_form)
setPromptConfig({
prompt_template: '', // placeholder for future
prompt_variables,
} as PromptConfig)
setMoreLikeThisConfig(more_like_this)
setTextToSpeechConfig(text_to_speech)
})()
}, [appData, appParams, fetchSavedMessage, isWorkflow])
// Can Use metadata(https://beta.nextjs.org/docs/api-reference/metadata) to set title. But it only works in server side client.
useDocumentTitle(siteInfo?.title || t('share.generation.title'))
useAppFavicon({
enable: !isInstalledApp,
icon_type: siteInfo?.icon_type,
icon: siteInfo?.icon,
icon_background: siteInfo?.icon_background,
icon_url: siteInfo?.icon_url,
})
const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false)
const showResultPanel = () => {
// fix: useClickAway hideResSidebar will close sidebar
setTimeout(() => {
doShowResultPanel()
}, 0)
}
const [resultExisted, setResultExisted] = useState(false)
const renderRes = (task?: Task) => (<Res
key={task?.id}
isWorkflow={isWorkflow}
isCallBatchAPI={isCallBatchAPI}
isPC={isPC}
isMobile={!isPC}
isInstalledApp={isInstalledApp}
appId={appId}
installedAppInfo={installedAppInfo}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
inputs={isCallBatchAPI ? (task as Task).params.inputs : inputs}
controlSend={controlSend}
controlRetry={task?.status === TaskStatus.failed ? controlRetry : 0}
controlStopResponding={controlStopResponding}
onShowRes={showResultPanel}
handleSaveMessage={handleSaveMessage}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo}
onRunStart={() => setResultExisted(true)}
onRunControlChange={!isCallBatchAPI ? setRunControl : undefined}
hideInlineStopButton={!isCallBatchAPI}
/>)
const renderBatchRes = () => {
return (showTaskList.map(task => renderRes(task)))
}
const renderResWrap = (
<div
className={cn(
'relative flex h-full flex-col',
!isPC && 'h-[calc(100vh_-_36px)] rounded-t-2xl shadow-lg backdrop-blur-sm',
!isPC
? isShowResultPanel
? 'bg-background-default-burn'
: 'border-t-[0.5px] border-divider-regular bg-components-panel-bg'
: 'bg-chatbot-bg',
)}
>
{isCallBatchAPI && (
<div className={cn(
'flex shrink-0 items-center justify-between px-14 pb-2 pt-9',
!isPC && 'px-4 pb-1 pt-3',
)}>
<div className='system-md-semibold-uppercase text-text-primary'>{t('share.generation.executions', { num: allTaskList.length })}</div>
{allSuccessTaskList.length > 0 && (
<ResDownload
isMobile={!isPC}
values={exportRes}
/>
)}
</div>
)}
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto',
isPC && 'px-14 py-8',
isPC && isCallBatchAPI && 'pt-0',
!isPC && 'p-0 pb-2',
)}>
{!isCallBatchAPI ? renderRes() : renderBatchRes()}
{!noPendingTask && (
<div className='mt-4'>
<Loading type='area' />
</div>
)}
</div>
{isCallBatchAPI && allFailedTaskList.length > 0 && (
<div className='absolute bottom-6 left-1/2 z-10 flex -translate-x-1/2 items-center gap-2 rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-sm'>
<RiErrorWarningFill className='h-4 w-4 text-text-destructive' />
<div className='system-sm-medium text-text-secondary'>{t('share.generation.batchFailed.info', { num: allFailedTaskList.length })}</div>
<div className='h-3.5 w-px bg-divider-regular'></div>
<div onClick={handleRetryAllFailedTask} className='system-sm-semibold-uppercase cursor-pointer text-text-accent'>{t('share.generation.batchFailed.retry')}</div>
</div>
)}
</div>
)
if (!appId || !siteInfo || !promptConfig) {
return (
<div className='flex h-screen items-center'>
<Loading type='app' />
</div>)
}
return (
<div className={cn(
'bg-background-default-burn',
isPC && 'flex',
!isPC && 'flex-col',
isInstalledApp ? 'h-full rounded-2xl shadow-md' : 'h-screen',
)}>
{/* Left */}
<div className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%_-_64px)]' : '',
isInstalledApp && 'rounded-l-2xl',
)}>
{/* header */}
<div className={cn('shrink-0 space-y-4 border-b border-divider-subtle', isPC ? 'bg-components-panel-bg p-8 pb-0' : 'p-4 pb-0')}>
<div className='flex items-center gap-3'>
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className='system-md-semibold grow truncate text-text-secondary'>{siteInfo.title}</div>
<MenuDropdown hideLogout={isInstalledApp || accessMode === AccessMode.PUBLIC} data={siteInfo} />
</div>
{siteInfo.description && (
<div className='system-xs-regular text-text-tertiary'>{siteInfo.description}</div>
)}
<TabHeader
items={[
{ id: 'create', name: t('share.generation.tabs.create') },
{ id: 'batch', name: t('share.generation.tabs.batch') },
...(!isWorkflow
? [{
id: 'saved',
name: t('share.generation.tabs.saved'),
isRight: true,
icon: <RiBookmark3Line className='h-4 w-4' />,
extra: savedMessages.length > 0
? (
<Badge className='ml-1'>
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={setCurrentTab}
/>
</div>
{/* form */}
<div className={cn(
'h-0 grow overflow-y-auto bg-components-panel-bg',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
runControl={runControl}
/>
</div>
<div className={cn(isInBatchTab ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={handleRunBatch}
isAllFinished={allTasksRun}
/>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={handleRemoveSavedMessage}
onStartCreateContent={() => setCurrentTab('create')}
/>
)}
</div>
{/* powered by */}
{!customConfig?.remove_webapp_brand && (
<div className={cn(
'flex shrink-0 items-center gap-1.5 bg-components-panel-bg py-3',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
{
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
: customConfig?.replace_webapp_logo
? <img src={`${customConfig?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
: <DifyLogo size='small' />
}
</div>
)}
</div>
{/* Result */}
<div className={cn(
isPC
? 'h-full w-0 grow'
: isShowResultPanel
? 'fixed inset-0 z-50 bg-background-overlay backdrop-blur-sm'
: resultExisted
? 'relative h-16 shrink-0 overflow-hidden bg-background-default-burn pt-2.5'
: '',
)}>
{!isPC && (
<div
className={cn(
isShowResultPanel
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
hideResultPanel()
else
showResultPanel()
}}
>
<div className='h-1 w-8 cursor-grab rounded bg-divider-solid' />
</div>
)}
{renderResWrap}
</div>
</div>
)
}
export default TextGeneration

View File

@@ -0,0 +1,49 @@
import React from 'react'
import cn from 'classnames'
import Modal from '@/app/components/base/modal'
import AppIcon from '@/app/components/base/app-icon'
import type { SiteInfo } from '@/models/share'
import { appDefaultIconBackground } from '@/config'
type Props = {
data?: SiteInfo
isShow: boolean
onClose: () => void
}
const InfoModal = ({
isShow,
onClose,
data,
}: Props) => {
return (
<Modal
isShow={isShow}
onClose={onClose}
className='min-w-[400px] max-w-[400px] !p-0'
closable
>
<div className={cn('flex flex-col items-center gap-4 px-4 pb-8 pt-10')}>
<AppIcon
size='xxl'
iconType={data?.icon_type}
icon={data?.icon}
background={data?.icon_background || appDefaultIconBackground}
imageUrl={data?.icon_url}
/>
<div className='system-xl-semibold text-text-secondary'>{data?.title}</div>
<div className='system-xs-regular text-text-tertiary'>
{/* copyright */}
{data?.copyright && (
<div>© {(new Date()).getFullYear()} {data?.copyright}</div>
)}
{data?.custom_disclaimer && (
<div className='mt-2'>{data.custom_disclaimer}</div>
)}
</div>
</div>
</Modal>
)
}
export default InfoModal

View File

@@ -0,0 +1,132 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Placement } from '@floating-ui/react'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import { usePathname, useRouter } from 'next/navigation'
import Divider from '../../base/divider'
import InfoModal from './info-modal'
import ActionButton from '@/app/components/base/action-button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import ThemeSwitcher from '@/app/components/base/theme-switcher'
import type { SiteInfo } from '@/models/share'
import cn from '@/utils/classnames'
import { AccessMode } from '@/models/access-control'
import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogout } from '@/service/webapp-auth'
type Props = {
data?: SiteInfo
placement?: Placement
hideLogout?: boolean
forceClose?: boolean
}
const MenuDropdown: FC<Props> = ({
data,
placement,
hideLogout,
forceClose,
}) => {
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const router = useRouter()
const pathname = usePathname()
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const shareCode = useWebAppStore(s => s.shareCode)
const handleLogout = useCallback(async () => {
await webAppLogout(shareCode!)
router.replace(`/webapp-signin?redirect_url=${pathname}`)
}, [router, pathname, webAppLogout, shareCode])
const [show, setShow] = useState(false)
useEffect(() => {
if (forceClose)
setOpen(false)
}, [forceClose, setOpen])
return (
<>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={placement || 'bottom-end'}
offset={{
mainAxis: 4,
crossAxis: -4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div>
<ActionButton size='l' className={cn(open && 'bg-state-base-hover')}>
<RiEqualizer2Line className='h-[18px] w-[18px]' />
</ActionButton>
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='w-[224px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
<div className='p-1'>
<div className={cn('system-md-regular flex cursor-pointer items-center rounded-lg py-1.5 pl-3 pr-2 text-text-secondary')}>
<div className='grow'>{t('common.theme.theme')}</div>
<ThemeSwitcher />
</div>
</div>
<Divider type='horizontal' className='my-0' />
<div className='p-1'>
{data?.privacy_policy && (
<a href={data.privacy_policy} target='_blank' className='system-md-regular flex cursor-pointer items-center rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'>
<span className='grow'>{t('share.chat.privacyPolicyMiddle')}</span>
</a>
)}
<div
onClick={() => {
handleTrigger()
setShow(true)
}}
className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
>{t('common.userProfile.about')}</div>
</div>
{!(hideLogout || webAppAccessMode === AccessMode.EXTERNAL_MEMBERS || webAppAccessMode === AccessMode.PUBLIC) && (
<div className='p-1'>
<div
onClick={handleLogout}
className='system-md-regular cursor-pointer rounded-lg px-3 py-1.5 text-text-secondary hover:bg-state-base-hover'
>
{t('common.userProfile.logout')}
</div>
</div>
)}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
{show && (
<InfoModal
isShow={show}
onClose={() => {
setShow(false)
}}
data={data}
/>
)}
</>
)
}
export default React.memo(MenuDropdown)

View File

@@ -0,0 +1,22 @@
import type { FC } from 'react'
import React from 'react'
import {
RiSparklingFill,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
export type INoDataProps = {}
const NoData: FC<INoDataProps> = () => {
const { t } = useTranslation()
return (
<div className='flex h-full w-full flex-col items-center justify-center'>
<RiSparklingFill className='h-12 w-12 text-text-empty-state-icon' />
<div
className='system-sm-regular mt-2 text-text-quaternary'
>
{t('share.generation.noData')}
</div>
</div>
)
}
export default React.memo(NoData)

View File

@@ -0,0 +1,34 @@
import type { FC } from 'react'
import React from 'react'
import Header from './header'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import { format } from '@/service/base'
export type IResultProps = {
content: string
showFeedback: boolean
feedback: FeedbackType
onFeedback: (feedback: FeedbackType) => void
}
const Result: FC<IResultProps> = ({
content,
showFeedback,
feedback,
onFeedback,
}) => {
return (
<div className='h-max basis-3/4'>
<Header result={content} showFeedback={showFeedback} feedback={feedback} onFeedback={onFeedback} />
<div
className='mt-4 flex w-full overflow-scroll text-sm font-normal leading-5 text-gray-900'
style={{
maxHeight: '70vh',
}}
dangerouslySetInnerHTML={{
__html: format(content),
}}
></div>
</div>
)
}
export default React.memo(Result)

View File

@@ -0,0 +1,113 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
import copy from 'copy-to-clipboard'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
type IResultHeaderProps = {
result: string
showFeedback: boolean
feedback: FeedbackType
onFeedback: (feedback: FeedbackType) => void
}
const Header: FC<IResultHeaderProps> = ({
feedback,
showFeedback,
onFeedback,
result,
}) => {
const { t } = useTranslation()
return (
<div className='flex w-full items-center justify-between '>
<div className='text-2xl font-normal leading-4 text-gray-800'>{t('share.generation.resultTitle')}</div>
<div className='flex items-center space-x-2'>
<Button
className='h-7 p-[2px] pr-2'
onClick={() => {
copy(result)
Toast.notify({ type: 'success', message: 'copied' })
}}
>
<>
<ClipboardDocumentIcon className='mr-1 h-3 w-4 text-gray-500' />
<span className='text-xs leading-3 text-gray-500'>{t('share.generation.copy')}</span>
</>
</Button>
{showFeedback && feedback.rating && feedback.rating === 'like' && (
<Tooltip
popupContent="Undo Great Rating"
>
<div
onClick={() => {
onFeedback({
rating: null,
})
}}
className='flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-primary-200 bg-primary-100 !text-primary-600 hover:border-primary-300 hover:bg-primary-200'>
<HandThumbUpIcon width={16} height={16} />
</div>
</Tooltip>
)}
{showFeedback && feedback.rating && feedback.rating === 'dislike' && (
<Tooltip
popupContent="Undo Undesirable Response"
>
<div
onClick={() => {
onFeedback({
rating: null,
})
}}
className='flex h-7 w-7 cursor-pointer items-center justify-center rounded-md border border-red-200 bg-red-100 !text-red-600 hover:border-red-300 hover:bg-red-200'>
<HandThumbDownIcon width={16} height={16} />
</div>
</Tooltip>
)}
{showFeedback && !feedback.rating && (
<div className='flex space-x-1 rounded-lg border border-gray-200 p-[1px]'>
<Tooltip
popupContent="Great Rating"
needsDelay={false}
>
<div
onClick={() => {
onFeedback({
rating: 'like',
})
}}
className='flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-gray-100'>
<HandThumbUpIcon width={16} height={16} />
</div>
</Tooltip>
<Tooltip
popupContent="Undesirable Response"
needsDelay={false}
>
<div
onClick={() => {
onFeedback({
rating: 'dislike',
})
}}
className='flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-gray-100'>
<HandThumbDownIcon width={16} height={16} />
</div>
</Tooltip>
</div>
)}
</div>
</div>
)
}
export default React.memo(Header)

View File

@@ -0,0 +1,607 @@
'use client'
import type { FC } 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 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'
import type { InstalledApp } from '@/models/explore'
import { TransferMethod, type VisionFile, type VisionSettings } from '@/types/app'
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import { sleep } from '@/utils'
import type { SiteInfo } from '@/models/share'
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
import {
getFilesInLogs,
getProcessedFiles,
} from '@/app/components/base/file-uploader/utils'
import type { FileEntity } from '@/app/components/base/file-uploader/types'
import { formatBooleanInputs } from '@/utils/model-config'
export type IResultProps = {
isWorkflow: boolean
isCallBatchAPI: boolean
isPC: boolean
isMobile: boolean
isInstalledApp: boolean
appId: string
installedAppInfo?: InstalledApp
isError: boolean
isShowTextToSpeech: boolean
promptConfig: PromptConfig | null
moreLikeThisEnabled: boolean
inputs: Record<string, any>
controlSend?: number
controlRetry?: number
controlStopResponding?: number
onShowRes: () => void
handleSaveMessage: (messageId: string) => void
taskId?: number
onCompleted: (completionRes: string, taskId?: number, success?: boolean) => void
visionConfig: VisionSettings
completionFiles: VisionFile[]
siteInfo: SiteInfo | null
onRunStart: () => void
onRunControlChange?: (control: { onStop: () => Promise<void> | void; isStopping: boolean } | null) => void
hideInlineStopButton?: boolean
}
const Result: FC<IResultProps> = ({
isWorkflow,
isCallBatchAPI,
isPC,
isMobile,
isInstalledApp,
appId,
installedAppInfo,
isError,
isShowTextToSpeech,
promptConfig,
moreLikeThisEnabled,
inputs,
controlSend,
controlRetry,
controlStopResponding,
onShowRes,
handleSaveMessage,
taskId,
onCompleted,
visionConfig,
completionFiles,
siteInfo,
onRunStart,
onRunControlChange,
hideInlineStopButton = false,
}) => {
const [isResponding, { setTrue: setRespondingTrue, setFalse: setRespondingFalse }] = useBoolean(false)
const [completionRes, doSetCompletionRes] = useState<string>('')
const completionResRef = useRef<string>('')
const setCompletionRes = (res: string) => {
completionResRef.current = res
doSetCompletionRes(res)
}
const getCompletionRes = () => completionResRef.current
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
const setWorkflowProcessData = (data: WorkflowProcess) => {
workflowProcessDataRef.current = data
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
const [messageId, setMessageId] = useState<string | null>(null)
const [feedback, setFeedback] = useState<FeedbackType>({
rating: null,
})
const handleFeedback = async (feedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, installedAppInfo?.id)
setFeedback(feedback)
}
const logError = (message: string) => {
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)
return true
const prompt_variables = promptConfig?.prompt_variables
if (!prompt_variables || prompt_variables?.length === 0) {
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return false
}
return true
}
let hasEmptyInput = ''
const requiredVars = prompt_variables?.filter(({ key, name, required, type }) => {
if(type === 'boolean' || type === 'checkbox')
return false // boolean/checkbox input is not required
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
return res
}) || [] // compatible with old version
requiredVars.forEach(({ key, name }) => {
if (hasEmptyInput)
return
if (!inputs[key])
hasEmptyInput = name
})
if (hasEmptyInput) {
logError(t('appDebug.errorMessage.valueOfVarRequired', { key: hasEmptyInput }))
return false
}
if (completionFiles.find(item => item.transfer_method === TransferMethod.local_file && !item.upload_file_id)) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForFileUpload') })
return false
}
return !hasEmptyInput
}
const handleSend = async () => {
if (isResponding) {
notify({ type: 'info', message: t('appDebug.errorMessage.waitForResponse') })
return false
}
if (!checkCanSend())
return
// Process inputs: convert file entities to API format
const processedInputs = { ...formatBooleanInputs(promptConfig?.prompt_variables, inputs) }
promptConfig?.prompt_variables.forEach((variable) => {
const value = processedInputs[variable.key]
if (variable.type === 'file' && value && typeof value === 'object' && !Array.isArray(value)) {
// Convert single file entity to API format
processedInputs[variable.key] = getProcessedFiles([value as FileEntity])[0]
}
else if (variable.type === 'file-list' && Array.isArray(value) && value.length > 0) {
// Convert file entity array to API format
processedInputs[variable.key] = getProcessedFiles(value as FileEntity[])
}
})
const data: Record<string, any> = {
inputs: processedInputs,
}
if (visionConfig.enabled && completionFiles && completionFiles?.length > 0) {
data.files = completionFiles.map((item) => {
if (item.transfer_method === TransferMethod.local_file) {
return {
...item,
url: '',
}
}
return item
})
}
setMessageId(null)
setFeedback({
rating: null,
})
setCompletionRes('')
resetRunState()
let res: string[] = []
let tempMessageId = ''
if (!isPC) {
onShowRes()
onRunStart()
}
setRespondingTrue()
let isEnd = false
let isTimeout = false;
(async () => {
await sleep(TEXT_GENERATION_TIMEOUT_MS)
if (!isEnd) {
setRespondingFalse()
onCompleted(getCompletionRes(), taskId, false)
resetRunState()
isTimeout = true
}
})()
if (isWorkflow) {
sendWorkflowMessage(
data,
{
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
tempMessageId = workflow_run_id
setCurrentTaskId(task_id || null)
setIsStopping(false)
setWorkflowProcessData({
status: WorkflowRunningStatus.Running,
tracing: [],
expand: false,
resultText: '',
})
},
onIterationStart: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onIterationNext: () => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const iterations = draft.tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
iterations?.details!.push([])
}))
},
onIterationFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const iterationsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[iterationsIndex] = {
...data,
expand: !!data.error,
}
}))
},
onLoopStart: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onLoopNext: () => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loops = draft.tracing.find(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
loops?.details!.push([])
}))
},
onLoopFinish: ({ data }) => {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
const loopsIndex = draft.tracing.findIndex(item => item.node_id === data.node_id
&& (item.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || item.parallel_id === data.execution_metadata?.parallel_id))!
draft.tracing[loopsIndex] = {
...data,
expand: !!data.error,
}
}))
},
onNodeStarted: ({ data }) => {
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.expand = true
draft.tracing!.push({
...data,
status: NodeRunningStatus.Running,
expand: true,
})
}))
},
onNodeFinished: ({ data }) => {
if (data.iteration_id)
return
if (data.loop_id)
return
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
const currentIndex = draft.tracing!.findIndex(trace => trace.node_id === data.node_id
&& (trace.execution_metadata?.parallel_id === data.execution_metadata?.parallel_id || trace.parallel_id === data.execution_metadata?.parallel_id))
if (currentIndex > -1 && draft.tracing) {
draft.tracing[currentIndex] = {
...(draft.tracing[currentIndex].extras
? { extras: draft.tracing[currentIndex].extras }
: {}),
...data,
expand: !!data.error,
}
}
}))
},
onWorkflowFinished: ({ data }) => {
if (isTimeout) {
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
}
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.status = WorkflowRunningStatus.Succeeded
draft.files = getFilesInLogs(data.outputs || []) as any[]
}))
if (!data.outputs) {
setCompletionRes('')
}
else {
setCompletionRes(data.outputs)
const isStringOutput = Object.keys(data.outputs).length === 1 && typeof data.outputs[Object.keys(data.outputs)[0]] === 'string'
if (isStringOutput) {
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText = data.outputs[Object.keys(data.outputs)[0]]
}))
}
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
isEnd = true
},
onTextChunk: (params) => {
const { data: { text } } = params
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText += text
}))
},
onTextReplace: (params) => {
const { data: { text } } = params
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
draft.resultText = text
}))
},
},
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, taskId }) => {
tempMessageId = messageId
if (taskId && typeof taskId === 'string' && taskId.trim() !== '')
setCurrentTaskId(prev => prev ?? taskId)
res.push(data)
setCompletionRes(res.join(''))
},
onCompleted: () => {
if (isTimeout) {
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return
}
setRespondingFalse()
resetRunState()
setMessageId(tempMessageId)
onCompleted(getCompletionRes(), taskId, true)
isEnd = true
},
onMessageReplace: (messageReplace) => {
res = [messageReplace.answer]
setCompletionRes(res.join(''))
},
onError() {
if (isTimeout) {
notify({ type: 'warning', message: t('appDebug.warningMessage.timeoutExceeded') })
return
}
setRespondingFalse()
resetRunState()
onCompleted(getCompletionRes(), taskId, false)
isEnd = true
},
getAbortController: (abortController) => {
abortControllerRef.current = abortController
},
}, isInstalledApp, installedAppInfo?.id)
}
}
const [controlClearMoreLikeThis, setControlClearMoreLikeThis] = useState(0)
useEffect(() => {
if (controlSend) {
handleSend()
setControlClearMoreLikeThis(Date.now())
}
}, [controlSend])
useEffect(() => {
if (controlRetry)
handleSend()
}, [controlRetry])
const renderTextGenerationRes = () => (
<>
{!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 (
<>
{!isCallBatchAPI && !isWorkflow && (
(isResponding && !completionRes)
? (
<div className='flex h-full w-full items-center justify-center'>
<Loading type='area' />
</div>)
: (
<>
{(isNoData)
? <NoData />
: renderTextGenerationRes()
}
</>
)
)}
{!isCallBatchAPI && isWorkflow && (
(isResponding && !workflowProcessData)
? (
<div className='flex h-full w-full items-center justify-center'>
<Loading type='area' />
</div>
)
: !workflowProcessData
? <NoData />
: renderTextGenerationRes()
)}
{isCallBatchAPI && renderTextGenerationRes()}
</>
)
}
export default React.memo(Result)

View File

@@ -0,0 +1,70 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useTranslation } from 'react-i18next'
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
export type ICSVDownloadProps = {
vars: { name: string }[]
}
const CSVDownload: FC<ICSVDownloadProps> = ({
vars,
}) => {
const { t } = useTranslation()
const { CSVDownloader, Type } = useCSVDownloader()
const addQueryContentVars = [...vars]
const template = (() => {
const res: Record<string, string> = {}
addQueryContentVars.forEach((item) => {
res[item.name] = ''
})
return res
})()
return (
<div className='mt-6'>
<div className='system-sm-medium text-text-primary'>{t('share.generation.csvStructureTitle')}</div>
<div className='mt-2 max-h-[500px] overflow-auto'>
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-regular text-xs'>
<thead className='text-text-tertiary'>
<tr>
{addQueryContentVars.map((item, i) => (
<td key={i} className='h-9 border-b border-divider-regular pl-3 pr-2'>{item.name}</td>
))}
</tr>
</thead>
<tbody className='text-text-secondary'>
<tr>
{addQueryContentVars.map((item, i) => (
<td key={i} className='h-9 pl-4'>{item.name} {t('share.generation.field')}</td>
))}
</tr>
</tbody>
</table>
</div>
<CSVDownloader
className="mt-2 block cursor-pointer"
type={Type.Link}
filename={'template'}
bom={true}
config={{
// delimiter: ';',
}}
data={[
template,
]}
>
<div className='system-xs-medium flex h-[18px] items-center space-x-1 text-text-accent'>
<DownloadIcon className='h-3 w-3' />
<span>{t('share.generation.downloadTemplate')}</span>
</div>
</CSVDownloader>
</div>
)
}
export default React.memo(CSVDownload)

View File

@@ -0,0 +1,73 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import {
useCSVReader,
} from 'react-papaparse'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
export type Props = {
onParsed: (data: string[][]) => void
}
const CSVReader: FC<Props> = ({
onParsed,
}) => {
const { t } = useTranslation()
const { CSVReader } = useCSVReader()
const [zoneHover, setZoneHover] = useState(false)
return (
<CSVReader
onUploadAccepted={(results: any) => {
onParsed(results.data)
setZoneHover(false)
}}
onDragOver={(event: DragEvent) => {
event.preventDefault()
setZoneHover(true)
}}
onDragLeave={(event: DragEvent) => {
event.preventDefault()
setZoneHover(false)
}}
>
{({
getRootProps,
acceptedFile,
}: any) => (
<>
<div
{...getRootProps()}
className={cn(
'system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg',
acceptedFile && 'border-solid border-components-panel-border bg-components-panel-on-panel-item-bg px-6 hover:border-components-panel-bg-blur hover:bg-components-panel-on-panel-item-bg-hover',
zoneHover && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent',
)}
>
{
acceptedFile
? (
<div className='flex w-full items-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='flex w-0 grow'>
<span className='max-w-[calc(100%_-_30px)] truncate text-text-secondary'>{acceptedFile.name.replace(/.csv$/, '')}</span>
<span className='shrink-0 text-text-tertiary'>.csv</span>
</div>
</div>
)
: (
<div className='flex w-full items-center justify-center space-x-2'>
<CSVIcon className="shrink-0" />
<div className='text-text-tertiary'>{t('share.generation.csvUploadTitle')}<span className='cursor-pointer text-text-accent'>{t('share.generation.browse')}</span></div>
</div>
)}
</div>
</>
)}
</CSVReader>
)
}
export default React.memo(CSVReader)

View File

@@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import {
RiLoader2Line,
RiPlayLargeLine,
} from '@remixicon/react'
import CSVReader from './csv-reader'
import CSVDownload from './csv-download'
import Button from '@/app/components/base/button'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import cn from '@/utils/classnames'
export type IRunBatchProps = {
vars: { name: string }[]
onSend: (data: string[][]) => void
isAllFinished: boolean
}
const RunBatch: FC<IRunBatchProps> = ({
vars,
onSend,
isAllFinished,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const [csvData, setCsvData] = React.useState<string[][]>([])
const [isParsed, setIsParsed] = React.useState(false)
const handleParsed = (data: string[][]) => {
setCsvData(data)
// console.log(data)
setIsParsed(true)
}
const handleSend = () => {
onSend(csvData)
}
const Icon = isAllFinished ? RiPlayLargeLine : RiLoader2Line
return (
<div className='pt-4'>
<CSVReader onParsed={handleParsed} />
<CSVDownload vars={vars} />
<div className='flex justify-end'>
<Button
variant="primary"
className={cn('mt-4 pl-3 pr-4', !isPC && 'grow')}
onClick={handleSend}
disabled={!isParsed || !isAllFinished}
>
<Icon className={cn(!isAllFinished && 'animate-spin', 'mr-1 h-4 w-4 shrink-0')} aria-hidden="true" />
<span className='text-[13px] uppercase'>{t('share.generation.run')}</span>
</Button>
</div>
</div>
)
}
export default React.memo(RunBatch)

View File

@@ -0,0 +1,50 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { RiDownloadLine } from '@remixicon/react'
import {
useCSVDownloader,
} from 'react-papaparse'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
import cn from '@/utils/classnames'
export type IResDownloadProps = {
isMobile: boolean
values: Record<string, string>[]
}
const ResDownload: FC<IResDownloadProps> = ({
isMobile,
values,
}) => {
const { t } = useTranslation()
const { CSVDownloader, Type } = useCSVDownloader()
return (
<CSVDownloader
className="block cursor-pointer"
type={Type.Link}
filename={'result'}
bom={true}
config={{
// delimiter: ';',
}}
data={values}
>
{isMobile && (
<ActionButton>
<RiDownloadLine className='h-4 w-4' />
</ActionButton>
)}
{!isMobile && (
<Button className={cn('space-x-1')}>
<RiDownloadLine className='h-4 w-4' />
<span>{t('common.operation.download')}</span>
</Button>
)}
</CSVDownloader>
)
}
export default React.memo(ResDownload)

View File

@@ -0,0 +1,256 @@
import type { ChangeEvent, FC, FormEvent } from 'react'
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'
import type { SiteInfo } from '@/models/share'
import type { PromptConfig } from '@/models/debug'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Input from '@/app/components/base/input'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
import type { VisionFile, VisionSettings } from '@/types/app'
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
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
promptConfig: PromptConfig
inputs: Record<string, any>
inputsRef: React.RefObject<Record<string, any>>
onInputsChange: (inputs: Record<string, any>) => void
onSend: () => void
visionConfig: VisionSettings
onVisionFilesChange: (files: VisionFile[]) => void
runControl?: {
onStop: () => Promise<void> | void
isStopping: boolean
} | null
}
const RunOnce: FC<IRunOnceProps> = ({
promptConfig,
inputs,
inputsRef,
onInputsChange,
onSend,
visionConfig,
onVisionFilesChange,
runControl,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const [isInitialized, setIsInitialized] = useState(false)
const onClear = () => {
const newInputs: Record<string, any> = {}
promptConfig.prompt_variables.forEach((item) => {
if (item.type === 'string' || item.type === 'paragraph')
newInputs[item.key] = ''
else if (item.type === 'checkbox')
newInputs[item.key] = false
else
newInputs[item.key] = undefined
})
onInputsChange(newInputs)
}
const onSubmit = (e: FormEvent<HTMLFormElement>) => {
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)
inputsRef.current = newInputs
}, [onInputsChange, inputsRef])
useEffect(() => {
if (isInitialized) return
const newInputs: Record<string, any> = {}
promptConfig.prompt_variables.forEach((item) => {
if (item.type === 'select')
newInputs[item.key] = item.default
else if (item.type === 'string' || item.type === 'paragraph')
newInputs[item.key] = item.default || ''
else if (item.type === 'number')
newInputs[item.key] = item.default
else if (item.type === 'checkbox')
newInputs[item.key] = item.default || false
else if (item.type === 'file')
newInputs[item.key] = undefined
else if (item.type === 'file-list')
newInputs[item.key] = []
else
newInputs[item.key] = undefined
})
onInputsChange(newInputs)
setIsInitialized(true)
}, [promptConfig.prompt_variables, onInputsChange])
return (
<div className="">
<section>
{/* input form */}
<form onSubmit={onSubmit}>
{(inputs === null || inputs === undefined || Object.keys(inputs).length === 0) || !isInitialized ? null
: promptConfig.prompt_variables.filter(item => item.hide !== true).map(item => (
<div className='mt-4 w-full' key={item.key}>
{item.type !== 'checkbox' && (
<div className='system-md-semibold flex h-6 items-center gap-1 text-text-secondary'>
<div className='truncate'>{item.name}</div>
{!item.required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>}
</div>
)}
<div className='mt-1'>
{item.type === 'select' && (
<Select
className='w-full'
defaultValue={inputs[item.key]}
onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
/>
)}
{item.type === 'string' && (
<Input
type="text"
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
maxLength={item.max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
{item.type === 'paragraph' && (
<Textarea
className='h-[104px] sm:text-xs'
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'number' && (
<Input
type="number"
placeholder={item.name}
value={inputs[item.key]}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/>
)}
{item.type === 'checkbox' && (
<BoolInput
name={item.name || item.key}
value={!!inputs[item.key]}
required={item.required}
onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
/>
)}
{item.type === 'file' && (
<FileUploaderInAttachmentWrapper
value={(inputs[item.key] && typeof inputs[item.key] === 'object') ? [inputs[item.key]] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files[0] }) }}
fileConfig={{
...item.config,
fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
{item.type === 'file-list' && (
<FileUploaderInAttachmentWrapper
value={Array.isArray(inputs[item.key]) ? inputs[item.key] : []}
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }}
fileConfig={{
...item.config,
fileUploadConfig: (visionConfig as any).fileUploadConfig,
}}
/>
)}
{item.type === 'json_object' && (
<CodeEditor
language={CodeLanguage.json}
value={inputs[item.key]}
onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
noWrapper
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
placeholder={
<div className='whitespace-pre'>{item.json_schema}</div>
}
/>
)}
</div>
</div>
))}
{
visionConfig?.enabled && (
<div className="mt-4 w-full">
<div className="system-md-semibold flex h-6 items-center text-text-secondary">{t('common.imageUploader.imageUpload')}</div>
<div className='mt-1'>
<TextGenerationImageUploader
settings={visionConfig}
onFilesChange={files => onVisionFilesChange(files.filter(file => file.progress !== -1).map(fileItem => ({
type: 'image',
transfer_method: fileItem.type,
url: fileItem.url,
upload_file_id: fileItem.fileId,
})))}
/>
</div>
</div>
)
}
<div className='mb-3 mt-6 w-full'>
<div className="flex items-center justify-between gap-2">
<Button
onClick={onClear}
disabled={false}
>
<span className='text-[13px]'>{t('common.operation.clear')}</span>
</Button>
<Button
className={cn(!isPC && 'grow')}
type={isRunning ? 'button' : 'submit'}
variant={isRunning ? 'secondary' : 'primary'}
disabled={isRunning && runControl?.isStopping}
onClick={handlePrimaryClick}
>
{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>
</form>
</section>
</div>
)
}
export default React.memo(RunOnce)

View File

@@ -0,0 +1,7 @@
export const isTokenV1 = (token: Record<string, any>) => {
return !token.version
}
export const getInitialTokenV2 = (): Record<string, any> => ({
version: 2,
})