mirror of https://github.com/langgenius/dify.git
feat: Human Input node (Frontend Part) (#31631)
Co-authored-by: JzoNg <jzongcode@gmail.com> Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: yessenia <yessenia.contact@gmail.com> Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
This commit is contained in:
parent
5bf0251554
commit
fedd097f63
|
|
@ -35,6 +35,7 @@ export const baseProviderContextValue: ProviderContextState = {
|
|||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
export const createMockProviderContextValue = (overrides: Partial<ProviderContextState> = {}): ProviderContextState => {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ describe('SVG Attribute Error Reproduction', () => {
|
|||
// Capture console errors
|
||||
const originalError = console.error
|
||||
let errorMessages: string[] = []
|
||||
|
||||
beforeEach(() => {
|
||||
errorMessages = []
|
||||
console.error = vi.fn((message) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,289 @@
|
|||
'use client'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { FormInputItem, UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { HumanInputFormError } from '@/service/use-share'
|
||||
import {
|
||||
RiCheckboxCircleFill,
|
||||
RiErrorWarningFill,
|
||||
RiInformation2Fill,
|
||||
} from '@remixicon/react'
|
||||
import { produce } from 'immer'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from '@/app/components/base/chat/chat/answer/human-input-content/content-item'
|
||||
import ExpirationTime from '@/app/components/base/chat/chat/answer/human-input-content/expiration-time'
|
||||
import { getButtonStyle } from '@/app/components/base/chat/chat/answer/human-input-content/utils'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGetHumanInputForm, useSubmitHumanInputForm } from '@/service/use-share'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export type FormData = {
|
||||
site: { site: SiteInfo }
|
||||
form_content: string
|
||||
inputs: FormInputItem[]
|
||||
resolved_default_values: Record<string, string>
|
||||
user_actions: UserAction[]
|
||||
expiration_time: number
|
||||
}
|
||||
|
||||
const FormContent = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { token } = useParams<{ token: string }>()
|
||||
useDocumentTitle('')
|
||||
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [success, setSuccess] = useState(false)
|
||||
|
||||
const { mutate: submitForm, isPending: isSubmitting } = useSubmitHumanInputForm()
|
||||
|
||||
const { data: formData, isLoading, error } = useGetHumanInputForm(token)
|
||||
|
||||
const expired = (error as HumanInputFormError | null)?.code === 'human_input_form_expired'
|
||||
const submitted = (error as HumanInputFormError | null)?.code === 'human_input_form_submitted'
|
||||
const rateLimitExceeded = (error as HumanInputFormError | null)?.code === 'web_form_rate_limit_exceeded'
|
||||
|
||||
const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
const contentList = useMemo(() => {
|
||||
if (!formData?.form_content)
|
||||
return []
|
||||
return splitByOutputVar(formData.form_content)
|
||||
}, [formData?.form_content])
|
||||
|
||||
useEffect(() => {
|
||||
if (!formData?.inputs)
|
||||
return
|
||||
const initialInputs: Record<string, string> = {}
|
||||
formData.inputs.forEach((item) => {
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? formData.resolved_default_values[item.output_variable_name] || '' : item.default.value
|
||||
})
|
||||
setInputs(initialInputs)
|
||||
}, [formData?.inputs, formData?.resolved_default_values])
|
||||
|
||||
// use immer
|
||||
const handleInputsChange = (name: string, value: string) => {
|
||||
const newInputs = produce(inputs, (draft) => {
|
||||
draft[name] = value
|
||||
})
|
||||
setInputs(newInputs)
|
||||
}
|
||||
|
||||
const submit = (actionID: string) => {
|
||||
submitForm(
|
||||
{ token, data: { inputs, action: actionID } },
|
||||
{
|
||||
onSuccess: () => {
|
||||
setSuccess(true)
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Loading type="app" />
|
||||
)
|
||||
}
|
||||
|
||||
if (success) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="h-[56px] w-[56px] shrink-0 rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiCheckboxCircleFill className="h-8 w-8 text-text-success" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.thanks', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.recorded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (expired) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.expired', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiInformation2Fill className="h-8 w-8 text-text-accent" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.sorry', { ns: 'share' })}</div>
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.completed', { ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="system-2xs-regular-uppercase shrink-0 text-text-tertiary">{t('humanInput.submissionID', { id: token, ns: 'share' })}</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (rateLimitExceeded) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.rateLimitExceeded', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!formData) {
|
||||
return (
|
||||
<div className={cn('flex h-full w-full flex-col items-center justify-center')}>
|
||||
<div className="min-w-[480px] max-w-[640px]">
|
||||
<div className="border-components-divider-subtle flex h-[320px] flex-col gap-4 rounded-[20px] border bg-chat-bubble-bg p-10 pb-9 shadow-lg backdrop-blur-sm">
|
||||
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge p-3">
|
||||
<RiErrorWarningFill className="h-8 w-8 text-text-destructive" />
|
||||
</div>
|
||||
<div className="grow">
|
||||
<div className="title-4xl-semi-bold text-text-primary">{t('humanInput.formNotFound', { ns: 'share' })}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const site = formData.site.site
|
||||
|
||||
return (
|
||||
<div className={cn('mx-auto flex h-full w-full max-w-[720px] flex-col items-center')}>
|
||||
<div className="mt-4 flex w-full shrink-0 items-center gap-3 py-3">
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={site.icon_type}
|
||||
icon={site.icon}
|
||||
background={site.icon_background}
|
||||
imageUrl={site.icon_url}
|
||||
/>
|
||||
<div className="system-xl-semibold grow text-text-primary">{site.title}</div>
|
||||
</div>
|
||||
<div className="h-0 w-full grow overflow-y-auto">
|
||||
<div className="border-components-divider-subtle rounded-[20px] border bg-chat-bubble-bg p-4 shadow-lg backdrop-blur-sm">
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.user_actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(action.id)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<ExpirationTime expirationTime={formData.expiration_time * 1000} />
|
||||
</div>
|
||||
<div className="flex flex-row-reverse px-2 py-3">
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}
|
||||
>
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('chat.poweredBy', { ns: 'share' })}</div>
|
||||
<DifyLogo size="small" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormContent)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
'use client'
|
||||
import * as React from 'react'
|
||||
import FormContent from './form'
|
||||
|
||||
const FormPage = () => {
|
||||
return (
|
||||
<div className="h-full min-w-[300px] bg-chatbot-bg pb-[env(safe-area-inset-bottom)]">
|
||||
<FormContent />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(FormPage)
|
||||
|
|
@ -47,7 +47,7 @@ const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
|
|||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ const Splash: FC<PropsWithChildren> = ({ children }) => {
|
|||
await webAppLogout(shareCode!)
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router, webAppLogout, shareCode])
|
||||
}, [getSigninUrl, router, shareCode])
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ export type AppPublisherProps = {
|
|||
missingStartNode?: boolean
|
||||
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
|
||||
startNodeLimitExceeded?: boolean
|
||||
hasHumanInputNode?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
|
@ -138,13 +139,14 @@ const AppPublisher = ({
|
|||
missingStartNode = false,
|
||||
hasTriggerNode = false,
|
||||
startNodeLimitExceeded = false,
|
||||
hasHumanInputNode = false,
|
||||
}: AppPublisherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
|
|
@ -161,6 +163,13 @@ const AppPublisher = ({
|
|||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
const openAsyncWindow = useAsyncWindowOpen()
|
||||
|
||||
const isAppAccessSet = useMemo(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
return !(appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
}
|
||||
return true
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
||||
|
|
@ -171,25 +180,13 @@ const AppPublisher = ({
|
|||
return t('noUserInputNode', { ns: 'app' })
|
||||
if (noAccessPermission)
|
||||
return t('noAccessPermission', { ns: 'app' })
|
||||
}, [missingStartNode, noAccessPermission, publishedAt])
|
||||
}, [missingStartNode, noAccessPermission, publishedAt, t])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
refetch()
|
||||
}, [open, appDetail, refetch, systemFeatures])
|
||||
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
|
|
@ -461,7 +458,7 @@ const AppPublisher = ({
|
|||
{t('common.accessAPIReference', { ns: 'workflow' })}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ type IDrawerContext = {
|
|||
}
|
||||
|
||||
type StatusCount = {
|
||||
paused: number
|
||||
success: number
|
||||
failed: number
|
||||
partial_success: number
|
||||
|
|
@ -93,7 +94,15 @@ const statusTdRender = (statusCount: StatusCount) => {
|
|||
if (!statusCount)
|
||||
return null
|
||||
|
||||
if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
if (statusCount.paused > 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
else if (statusCount.partial_success + statusCount.failed === 0) {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="green" />
|
||||
|
|
@ -296,7 +305,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
|||
if (abortControllerRef.current === controller)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
}, [detail.id, hasMore, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, timezone, t, appDetail])
|
||||
|
||||
// Derive chatItemTree, threadChatItems, and oldestAnswerIdRef from allChatItems
|
||||
useEffect(() => {
|
||||
|
|
@ -411,7 +420,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
|||
notify({ type: 'error', message: t('actionMsg.modifiedUnsuccessfully', { ns: 'common' }) })
|
||||
return false
|
||||
}
|
||||
}, [allChatItems, appDetail?.id, t])
|
||||
}, [allChatItems, appDetail?.id, notify, t])
|
||||
|
||||
const fetchInitiated = useRef(false)
|
||||
|
||||
|
|
@ -504,7 +513,7 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
|||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail, detail?.model_config?.configs?.introduction])
|
||||
}, [detail.id, hasMore, isLoading, timezone, t, appDetail])
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
const scrollableDiv = document.getElementById('scrollableDiv')
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ const defaultProviderContext = {
|
|||
refreshLicenseLimit: noop,
|
||||
isAllowTransferWorkspace: false,
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: false,
|
||||
humanInputEmailDeliveryEnabled: false,
|
||||
}
|
||||
|
||||
const defaultModalContext: ModalContextState = {
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
RiClipboardLine,
|
||||
RiFileList3Line,
|
||||
RiPlayList2Line,
|
||||
RiReplay15Line,
|
||||
RiResetLeftLine,
|
||||
RiSparklingFill,
|
||||
RiSparklingLine,
|
||||
RiThumbDownLine,
|
||||
|
|
@ -18,10 +18,12 @@ import { useBoolean } from 'ahooks'
|
|||
import copy from 'copy-to-clipboard'
|
||||
import { useParams } from 'next/navigation'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import HumanInputFilledFormList from '@/app/components/base/chat/chat/answer/human-input-filled-form-list'
|
||||
import HumanInputFormList from '@/app/components/base/chat/chat/answer/human-input-form-list'
|
||||
import WorkflowProcessItem from '@/app/components/base/chat/chat/answer/workflow-process'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
|
|
@ -29,7 +31,8 @@ import { Markdown } from '@/app/components/base/markdown'
|
|||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { fetchTextGenerationMessage } from '@/service/debug'
|
||||
import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share'
|
||||
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ResultTab from './result-tab'
|
||||
|
||||
|
|
@ -121,7 +124,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
|
||||
|
||||
const childProps = {
|
||||
isInWebApp: true,
|
||||
isInWebApp,
|
||||
content: completionRes,
|
||||
messageId: childMessageId,
|
||||
depth: depth + 1,
|
||||
|
|
@ -202,16 +205,22 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
}
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length
|
||||
const showResultTabs = !!workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0)
|
||||
const switchTab = async (tab: string) => {
|
||||
setCurrentTab(tab)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length)
|
||||
if (workflowProcessData?.resultText || !!workflowProcessData?.files?.length || (workflowProcessData?.humanInputFormDataList && workflowProcessData?.humanInputFormDataList.length > 0) || (workflowProcessData?.humanInputFilledFormDataList && workflowProcessData?.humanInputFilledFormDataList.length > 0))
|
||||
switchTab('RESULT')
|
||||
else
|
||||
switchTab('DETAIL')
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText])
|
||||
}, [workflowProcessData?.files?.length, workflowProcessData?.resultText, workflowProcessData?.humanInputFormDataList, workflowProcessData?.humanInputFilledFormDataList])
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: { inputs: Record<string, string>, action: string }) => {
|
||||
if (appSourceType === AppSourceType.installedApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [appSourceType])
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -275,7 +284,24 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
)}
|
||||
</div>
|
||||
{!isError && (
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
<>
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={workflowProcessData.humanInputFormDataList}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && (
|
||||
<div className="px-4 pt-4">
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={workflowProcessData.humanInputFilledFormDataList}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<ResultTab data={workflowProcessData} content={content} currentTab={currentTab} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
|
@ -348,7 +374,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
|
|||
)}
|
||||
{isInWebApp && isError && (
|
||||
<ActionButton onClick={onRetry}>
|
||||
<RiReplay15Line className="h-4 w-4" />
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{isInWebApp && !isWorkflow && !isTryApp && (
|
||||
|
|
|
|||
|
|
@ -81,6 +81,14 @@ const WorkflowAppLogList: FC<ILogs> = ({ logs, appDetail, onRefresh }) => {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'paused') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
<Indicator color="yellow" />
|
||||
<span className="text-util-colors-warning-warning-600">Pending</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (status === 'running') {
|
||||
return (
|
||||
<div className="system-xs-semibold-uppercase inline-flex items-center gap-1">
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@
|
|||
@apply p-0.5 w-6 h-6 rounded-lg
|
||||
}
|
||||
|
||||
.action-btn-s {
|
||||
@apply w-5 h-5 rounded-[6px]
|
||||
}
|
||||
|
||||
.action-btn-xs {
|
||||
@apply p-0 w-4 h-4 rounded
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const actionButtonVariants = cva(
|
|||
variants: {
|
||||
size: {
|
||||
xs: 'action-btn-xs',
|
||||
s: 'action-btn-s',
|
||||
m: 'action-btn-m',
|
||||
l: 'action-btn-l',
|
||||
xl: 'action-btn-xl',
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
|||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
|
@ -16,7 +17,9 @@ import {
|
|||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
|
|
@ -73,9 +76,9 @@ const ChatWrapper = () => {
|
|||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
|
|
@ -122,8 +125,11 @@ const ChatWrapper = () => {
|
|||
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
|
||||
if (chatList.some(item => item.isAnswer && item.humanInputFormDataList && item.humanInputFormDataList.length > 0))
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
}, [allInputsHidden, inputsForms, chatList, inputsFormValue])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
|
|
@ -134,6 +140,40 @@ const ChatWrapper = () => {
|
|||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatTree || appPrevChatTree.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatTree)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
|
|
@ -149,10 +189,10 @@ const ChatWrapper = () => {
|
|||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
}, [inputsForms, currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, isHistoryConversation, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
|
|
@ -160,12 +200,27 @@ const ChatWrapper = () => {
|
|||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, currentConversationId, handleNewConversationCompleted, appSourceType, appId])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
// Without messages we are in the welcome screen, so hide the opening statement from chatlist
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList])
|
||||
}, [chatList, currentConversationId])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
|
|
@ -274,6 +329,7 @@ const ChatWrapper = () => {
|
|||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
|
|
@ -286,7 +342,7 @@ const ChatWrapper = () => {
|
|||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
sidebarCollapseState={sidebarCollapseState}
|
||||
questionIcon={
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ExtraContent } from '../chat/type'
|
||||
import type {
|
||||
Callback,
|
||||
ChatConfig,
|
||||
|
|
@ -9,6 +10,7 @@ import type {
|
|||
AppData,
|
||||
ConversationItem,
|
||||
} from '@/models/share'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
import { useLocalStorageState } from 'ahooks'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
|
|
@ -57,6 +59,24 @@ function getFormattedChatList(messages: any[]) {
|
|||
parentMessageId: item.parent_message_id || undefined,
|
||||
})
|
||||
const answerFiles = item.message_files?.filter((file: any) => file.belongs_to === 'assistant') || []
|
||||
const humanInputFormDataList: HumanInputFormData[] = []
|
||||
const humanInputFilledFormDataList: HumanInputFilledFormData[] = []
|
||||
let workflowRunId = ''
|
||||
if (item.status === 'paused') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && !content.submitted) {
|
||||
humanInputFormDataList.push(content.form_definition)
|
||||
workflowRunId = content.workflow_run_id
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (item.status === 'normal') {
|
||||
item.extra_contents?.forEach((content: ExtraContent) => {
|
||||
if (content.type === 'human_input' && content.submitted) {
|
||||
humanInputFilledFormDataList.push(content.form_submission_data)
|
||||
}
|
||||
})
|
||||
}
|
||||
newChatList.push({
|
||||
id: item.id,
|
||||
content: item.answer,
|
||||
|
|
@ -66,6 +86,9 @@ function getFormattedChatList(messages: any[]) {
|
|||
citation: item.retriever_resources,
|
||||
message_files: getProcessedFilesFromResponse(answerFiles.map((item: any) => ({ ...item, related_id: item.id, upload_file_id: item.upload_file_id }))),
|
||||
parentMessageId: `question-${item.id}`,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
workflow_run_id: workflowRunId,
|
||||
})
|
||||
})
|
||||
return newChatList
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import type { ContentItemProps } from './type'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
formInputFields,
|
||||
inputs,
|
||||
onInputChange,
|
||||
}: ContentItemProps) => {
|
||||
const isInputField = (field: string) => {
|
||||
const outputVarRegex = /\{\{#\$output\.[^#]+#\}\}/
|
||||
return outputVarRegex.test(field)
|
||||
}
|
||||
|
||||
const extractFieldName = (str: string): string => {
|
||||
const outputVarRegex = /\{\{#\$output\.([^#]+)#\}\}/
|
||||
const match = str.match(outputVarRegex)
|
||||
return match ? match[1] : ''
|
||||
}
|
||||
|
||||
const fieldName = useMemo(() => {
|
||||
return extractFieldName(content)
|
||||
}, [content])
|
||||
|
||||
const formInputField = useMemo(() => {
|
||||
return formInputFields.find(field => field.output_variable_name === fieldName)
|
||||
}, [formInputFields, fieldName])
|
||||
|
||||
if (!isInputField(content)) {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
if (!formInputField)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ContentItem)
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import { RiArrowDownSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
type ContentWrapperProps = {
|
||||
nodeTitle: string
|
||||
children: React.ReactNode
|
||||
showExpandIcon?: boolean
|
||||
className?: string
|
||||
expanded?: boolean
|
||||
}
|
||||
|
||||
const ContentWrapper = ({
|
||||
nodeTitle,
|
||||
children,
|
||||
showExpandIcon = false,
|
||||
className,
|
||||
expanded = false,
|
||||
}: ContentWrapperProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(expanded)
|
||||
|
||||
const handleToggleExpand = useCallback(() => {
|
||||
setIsExpanded(!isExpanded)
|
||||
}, [isExpanded])
|
||||
|
||||
return (
|
||||
<div className={cn('rounded-2xl border-[0.5px] border-components-panel-border bg-background-section p-2 shadow-md', className)}>
|
||||
<div className="flex items-center gap-2 p-2">
|
||||
{/* node icon */}
|
||||
<BlockIcon type={BlockEnum.HumanInput} className="shrink-0" />
|
||||
{/* node name */}
|
||||
<div
|
||||
className="system-sm-semibold-uppercase grow truncate text-text-primary"
|
||||
title={nodeTitle}
|
||||
>
|
||||
{nodeTitle}
|
||||
</div>
|
||||
{showExpandIcon && (
|
||||
<div className="shrink-0 cursor-pointer" onClick={handleToggleExpand}>
|
||||
{
|
||||
isExpanded
|
||||
? (
|
||||
<RiArrowDownSLine className="size-4" />
|
||||
)
|
||||
: (
|
||||
<RiArrowRightSLine className="size-4" />
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{(!showExpandIcon || isExpanded) && (
|
||||
<div className="px-2 py-1">
|
||||
{/* human input form content */}
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ContentWrapper
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import type { ExecutedAction as ExecutedActionType } from './type'
|
||||
import { memo } from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { TriggerAll } from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
type ExecutedActionProps = {
|
||||
executedAction: ExecutedActionType
|
||||
}
|
||||
|
||||
const ExecutedAction = ({
|
||||
executedAction,
|
||||
}: ExecutedActionProps) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-y-1 py-1">
|
||||
<Divider className="mb-2 mt-1 w-[30px]" />
|
||||
<div className="system-xs-regular flex items-center gap-x-1 text-text-tertiary">
|
||||
<TriggerAll className="size-3.5 shrink-0" />
|
||||
<Trans
|
||||
i18nKey="nodes.humanInput.userActions.triggered"
|
||||
ns="workflow"
|
||||
components={{ strong: <span className="system-xs-medium text-text-secondary"></span> }}
|
||||
values={{ actionName: executedAction.id }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(ExecutedAction)
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
'use client'
|
||||
import { RiAlertFill, RiTimeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { getRelativeTime, isRelativeTimeSameOrAfter } from './utils'
|
||||
|
||||
type ExpirationTimeProps = {
|
||||
expirationTime: number
|
||||
}
|
||||
|
||||
const ExpirationTime = ({
|
||||
expirationTime,
|
||||
}: ExpirationTimeProps) => {
|
||||
const { t } = useTranslation()
|
||||
const locale = useLocale()
|
||||
const relativeTime = getRelativeTime(expirationTime, locale)
|
||||
const isSameOrAfter = isRelativeTimeSameOrAfter(expirationTime)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular mt-1 flex items-center gap-x-1 text-text-tertiary',
|
||||
!isSameOrAfter && 'text-text-warning',
|
||||
)}
|
||||
>
|
||||
{
|
||||
isSameOrAfter
|
||||
? (
|
||||
<>
|
||||
<RiTimeLine className="size-3.5" />
|
||||
<span>{t('humanInput.expirationTimeNowOrFuture', { relativeTime, ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<RiAlertFill className="size-3.5" />
|
||||
<span>{t('humanInput.expiredTip', { ns: 'share' })}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExpirationTime
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
'use client'
|
||||
import type { HumanInputFormProps } from './type'
|
||||
import type { ButtonProps } from '@/app/components/base/button'
|
||||
import type { UserAction } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ContentItem from './content-item'
|
||||
import { getButtonStyle, initializeInputs, splitByOutputVar } from './utils'
|
||||
|
||||
const HumanInputForm = ({
|
||||
formData,
|
||||
onSubmit,
|
||||
}: HumanInputFormProps) => {
|
||||
const formToken = formData.form_token
|
||||
const defaultInputs = initializeInputs(formData.inputs, formData.resolved_default_values || {})
|
||||
const contentList = splitByOutputVar(formData.form_content)
|
||||
const [inputs, setInputs] = useState(defaultInputs)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const handleInputsChange = useCallback((name: string, value: string) => {
|
||||
setInputs(prev => ({
|
||||
...prev,
|
||||
[name]: value,
|
||||
}))
|
||||
}, [])
|
||||
|
||||
const submit = async (formToken: string, actionID: string, inputs: Record<string, string>) => {
|
||||
setIsSubmitting(true)
|
||||
await onSubmit?.(formToken, { inputs, action: actionID })
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{contentList.map((content, index) => (
|
||||
<ContentItem
|
||||
key={index}
|
||||
content={content}
|
||||
formInputFields={formData.inputs}
|
||||
inputs={inputs}
|
||||
onInputChange={handleInputsChange}
|
||||
/>
|
||||
))}
|
||||
<div className="flex flex-wrap gap-1 py-1">
|
||||
{formData.actions.map((action: UserAction) => (
|
||||
<Button
|
||||
key={action.id}
|
||||
disabled={isSubmitting}
|
||||
variant={getButtonStyle(action.button_style) as ButtonProps['variant']}
|
||||
onClick={() => submit(formToken, action.id, inputs)}
|
||||
>
|
||||
{action.title}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HumanInputForm)
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
|
||||
type SubmittedContentProps = {
|
||||
content: string
|
||||
}
|
||||
|
||||
const SubmittedContent = ({
|
||||
content,
|
||||
}: SubmittedContentProps) => {
|
||||
return (
|
||||
<Markdown content={content} />
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(SubmittedContent)
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import type { SubmittedHumanInputContentProps } from './type'
|
||||
import { useMemo } from 'react'
|
||||
import ExecutedAction from './executed-action'
|
||||
import SubmittedContent from './submitted-content'
|
||||
|
||||
export const SubmittedHumanInputContent = ({
|
||||
formData,
|
||||
}: SubmittedHumanInputContentProps) => {
|
||||
const { rendered_content, action_id, action_text } = formData
|
||||
|
||||
const executedAction = useMemo(() => {
|
||||
return {
|
||||
id: action_id,
|
||||
title: action_text,
|
||||
}
|
||||
}, [action_id, action_text])
|
||||
|
||||
return (
|
||||
<>
|
||||
<SubmittedContent content={rendered_content} />
|
||||
{/* Executed Action */}
|
||||
<ExecutedAction executedAction={executedAction} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { memo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { useSelector as useAppSelector } from '@/context/app-context'
|
||||
|
||||
type TipsProps = {
|
||||
showEmailTip: boolean
|
||||
isEmailDebugMode: boolean
|
||||
showDebugModeTip: boolean
|
||||
}
|
||||
|
||||
const Tips = ({
|
||||
showEmailTip,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip,
|
||||
}: TipsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const email = useAppSelector(s => s.userProfile.email)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Divider className="!my-2 w-[30px]" />
|
||||
<div className="space-y-1 pt-1">
|
||||
{showEmailTip && !isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">{t('common.humanInputEmailTip', { ns: 'workflow' })}</div>
|
||||
)}
|
||||
{showEmailTip && isEmailDebugMode && (
|
||||
<div className="system-xs-regular text-text-secondary">
|
||||
<Trans
|
||||
i18nKey="common.humanInputEmailTipInDebugMode"
|
||||
ns="workflow"
|
||||
components={{ email: <span className="system-xs-semibold"></span> }}
|
||||
values={{ email }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showDebugModeTip && <div className="system-xs-medium text-text-warning">{t('common.humanInputWebappTip', { ns: 'workflow' })}</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Tips)
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData } from '@/types/workflow'
|
||||
|
||||
export type ExecutedAction = {
|
||||
id: string
|
||||
title: string
|
||||
}
|
||||
|
||||
export type UnsubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFormData
|
||||
showEmailTip?: boolean
|
||||
isEmailDebugMode?: boolean
|
||||
showDebugModeTip?: boolean
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type SubmittedHumanInputContentProps = {
|
||||
formData: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type HumanInputFormProps = {
|
||||
formData: HumanInputFormData
|
||||
onSubmit?: (formToken: string, data: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
}
|
||||
|
||||
export type ContentItemProps = {
|
||||
content: string
|
||||
formInputFields: FormInputItem[]
|
||||
inputs: Record<string, string>
|
||||
onInputChange: (name: string, value: string) => void
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { UnsubmittedHumanInputContentProps } from './type'
|
||||
import ExpirationTime from './expiration-time'
|
||||
import HumanInputForm from './human-input-form'
|
||||
import Tips from './tips'
|
||||
|
||||
export const UnsubmittedHumanInputContent = ({
|
||||
formData,
|
||||
showEmailTip = false,
|
||||
isEmailDebugMode = false,
|
||||
showDebugModeTip = false,
|
||||
onSubmit,
|
||||
}: UnsubmittedHumanInputContentProps) => {
|
||||
const { expiration_time } = formData
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Form */}
|
||||
<HumanInputForm
|
||||
formData={formData}
|
||||
onSubmit={onSubmit}
|
||||
/>
|
||||
{/* Tips */}
|
||||
{(showEmailTip || showDebugModeTip) && (
|
||||
<Tips
|
||||
showEmailTip={showEmailTip}
|
||||
isEmailDebugMode={isEmailDebugMode}
|
||||
showDebugModeTip={showDebugModeTip}
|
||||
/>
|
||||
)}
|
||||
{/* Expiration Time */}
|
||||
{typeof expiration_time === 'number' && (
|
||||
<ExpirationTime expirationTime={expiration_time * 1000} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Locale } from '@/i18n-config'
|
||||
import dayjs from 'dayjs'
|
||||
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
|
||||
import relativeTime from 'dayjs/plugin/relativeTime'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import { UserActionButtonType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import 'dayjs/locale/en'
|
||||
import 'dayjs/locale/zh-cn'
|
||||
import 'dayjs/locale/ja'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(relativeTime)
|
||||
dayjs.extend(isSameOrAfter)
|
||||
|
||||
export const getButtonStyle = (style: UserActionButtonType) => {
|
||||
if (style === UserActionButtonType.Primary)
|
||||
return 'primary'
|
||||
if (style === UserActionButtonType.Default)
|
||||
return 'secondary'
|
||||
if (style === UserActionButtonType.Accent)
|
||||
return 'secondary-accent'
|
||||
if (style === UserActionButtonType.Ghost)
|
||||
return 'ghost'
|
||||
}
|
||||
|
||||
export const splitByOutputVar = (content: string): string[] => {
|
||||
const outputVarRegex = /(\{\{#\$output\.[^#]+#\}\})/g
|
||||
const parts = content.split(outputVarRegex)
|
||||
return parts.filter(part => part.length > 0)
|
||||
}
|
||||
|
||||
export const initializeInputs = (formInputs: FormInputItem[], defaultValues: Record<string, string> = {}) => {
|
||||
const initialInputs: Record<string, any> = {}
|
||||
formInputs.forEach((item) => {
|
||||
if (item.type === 'text-input' || item.type === 'paragraph')
|
||||
initialInputs[item.output_variable_name] = item.default.type === 'variable' ? defaultValues[item.output_variable_name] || '' : item.default.value
|
||||
else
|
||||
initialInputs[item.output_variable_name] = undefined
|
||||
})
|
||||
return initialInputs
|
||||
}
|
||||
|
||||
const localeMap: Record<string, string> = {
|
||||
'en-US': 'en',
|
||||
'zh-Hans': 'zh-cn',
|
||||
'ja-JP': 'ja',
|
||||
}
|
||||
|
||||
export const getRelativeTime = (
|
||||
utcTimestamp: string | number,
|
||||
locale: Locale = 'en-US',
|
||||
) => {
|
||||
const dayjsLocale = localeMap[locale] ?? 'en'
|
||||
|
||||
return dayjs
|
||||
.utc(utcTimestamp)
|
||||
.locale(dayjsLocale)
|
||||
.fromNow()
|
||||
}
|
||||
|
||||
export const isRelativeTimeSameOrAfter = (utcTimestamp: string | number) => {
|
||||
return dayjs.utc(utcTimestamp).isSameOrAfter(dayjs())
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { HumanInputFilledFormData } from '@/types/workflow'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { SubmittedHumanInputContent } from './human-input-content/submitted'
|
||||
|
||||
type HumanInputFilledFormListProps = {
|
||||
humanInputFilledFormDataList: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
const HumanInputFilledFormList = ({
|
||||
humanInputFilledFormDataList,
|
||||
}: HumanInputFilledFormListProps) => {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
humanInputFilledFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.node_id}
|
||||
nodeTitle={formData.node_title}
|
||||
showExpandIcon
|
||||
>
|
||||
<SubmittedHumanInputContent
|
||||
key={formData.node_id}
|
||||
formData={formData}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFilledFormList
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import type { DeliveryMethod, HumanInputNodeType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { HumanInputFormData } from '@/types/workflow'
|
||||
import { useMemo } from 'react'
|
||||
import { DeliveryMethodType } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import ContentWrapper from './human-input-content/content-wrapper'
|
||||
import { UnsubmittedHumanInputContent } from './human-input-content/unsubmitted'
|
||||
|
||||
type HumanInputFormListProps = {
|
||||
humanInputFormDataList: HumanInputFormData[]
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => Node<HumanInputNodeType> | undefined
|
||||
}
|
||||
|
||||
const HumanInputFormList = ({
|
||||
humanInputFormDataList,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}: HumanInputFormListProps) => {
|
||||
const deliveryMethodsConfig = useMemo((): Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }> => {
|
||||
if (!humanInputFormDataList.length)
|
||||
return {}
|
||||
return humanInputFormDataList.reduce((acc, formData) => {
|
||||
const deliveryMethodsConfig = getHumanInputNodeData?.(formData.node_id)?.data.delivery_methods || []
|
||||
if (!deliveryMethodsConfig.length) {
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: false,
|
||||
isEmailDebugMode: false,
|
||||
showDebugModeTip: false,
|
||||
}
|
||||
return acc
|
||||
}
|
||||
const isWebappEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.WebApp && method.enabled)
|
||||
const isEmailEnabled = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.enabled)
|
||||
const isEmailDebugMode = deliveryMethodsConfig.some((method: DeliveryMethod) => method.type === DeliveryMethodType.Email && method.config?.debug_mode)
|
||||
acc[formData.node_id] = {
|
||||
showEmailTip: isEmailEnabled,
|
||||
isEmailDebugMode,
|
||||
showDebugModeTip: !isWebappEnabled,
|
||||
}
|
||||
return acc
|
||||
}, {} as Record<string, { showEmailTip: boolean, isEmailDebugMode: boolean, showDebugModeTip: boolean }>)
|
||||
}, [getHumanInputNodeData, humanInputFormDataList])
|
||||
|
||||
const filteredHumanInputFormDataList = humanInputFormDataList.filter(formData => formData.display_in_ui)
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-y-2">
|
||||
{
|
||||
filteredHumanInputFormDataList.map(formData => (
|
||||
<ContentWrapper
|
||||
key={formData.form_id}
|
||||
nodeTitle={formData.node_title}
|
||||
>
|
||||
<UnsubmittedHumanInputContent
|
||||
key={formData.form_id}
|
||||
formData={formData}
|
||||
showEmailTip={!!deliveryMethodsConfig[formData.node_id]?.showEmailTip}
|
||||
isEmailDebugMode={!!deliveryMethodsConfig[formData.node_id]?.isEmailDebugMode}
|
||||
showDebugModeTip={!!deliveryMethodsConfig[formData.node_id]?.showDebugModeTip}
|
||||
onSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
</ContentWrapper>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HumanInputFormList
|
||||
|
|
@ -16,8 +16,11 @@ import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
|
|||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ContentSwitch from '../content-switch'
|
||||
import { useChatContext } from '../context'
|
||||
import AgentContent from './agent-content'
|
||||
import BasicContent from './basic-content'
|
||||
import HumanInputFilledFormList from './human-input-filled-form-list'
|
||||
import HumanInputFormList from './human-input-form-list'
|
||||
import More from './more'
|
||||
import Operation from './operation'
|
||||
import SuggestedQuestions from './suggested-questions'
|
||||
|
|
@ -36,6 +39,8 @@ type AnswerProps = {
|
|||
appData?: AppData
|
||||
noChatInput?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
}
|
||||
const Answer: FC<AnswerProps> = ({
|
||||
item,
|
||||
|
|
@ -50,6 +55,8 @@ const Answer: FC<AnswerProps> = ({
|
|||
appData,
|
||||
noChatInput,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
|
|
@ -61,13 +68,22 @@ const Answer: FC<AnswerProps> = ({
|
|||
workflowProcess,
|
||||
allFiles,
|
||||
message_files,
|
||||
humanInputFormDataList,
|
||||
humanInputFilledFormDataList,
|
||||
} = item
|
||||
const hasAgentThoughts = !!agent_thoughts?.length
|
||||
const hasHumanInputs = !!humanInputFormDataList?.length || !!humanInputFilledFormDataList?.length
|
||||
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const [humanInputFormContainerWidth, setHumanInputFormContainerWidth] = useState(0)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
const humanInputFormContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const {
|
||||
getHumanInputNodeData,
|
||||
} = useChatContext()
|
||||
|
||||
const getContainerWidth = () => {
|
||||
if (containerRef.current)
|
||||
|
|
@ -87,12 +103,23 @@ const Answer: FC<AnswerProps> = ({
|
|||
getContentWidth()
|
||||
}, [responding])
|
||||
|
||||
const getHumanInputFormContainerWidth = () => {
|
||||
if (humanInputFormContainerRef.current)
|
||||
setHumanInputFormContainerWidth(humanInputFormContainerRef.current?.clientWidth)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (hasHumanInputs)
|
||||
getHumanInputFormContainerWidth()
|
||||
}, [hasHumanInputs])
|
||||
|
||||
// Recalculate contentWidth when content changes (e.g., SVG preview/source toggle)
|
||||
useEffect(() => {
|
||||
if (!containerRef.current)
|
||||
return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
getContentWidth()
|
||||
getHumanInputFormContainerWidth()
|
||||
})
|
||||
resizeObserver.observe(containerRef.current)
|
||||
return () => {
|
||||
|
|
@ -115,115 +142,285 @@ const Answer: FC<AnswerProps> = ({
|
|||
|
||||
return (
|
||||
<div className="mb-2 flex last:mb-0">
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
{!hideAvatar && (
|
||||
<div className="relative h-10 w-10 shrink-0">
|
||||
{answerIcon || <AnswerIcon />}
|
||||
{responding && (
|
||||
<div className="absolute left-[-3px] top-[-3px] flex h-4 w-4 items-center rounded-full border-[0.5px] border-divider-subtle bg-background-section-burn pl-[6px] shadow-xs">
|
||||
<LoadingAnim type="avatar" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
{/* Block 1: Workflow Process + Human Input Forms */}
|
||||
{hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={humanInputFormContainerRef}
|
||||
className={cn('body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary')}
|
||||
>
|
||||
{
|
||||
!responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - humanInputFormContainerWidth - 4}
|
||||
contentWidth={humanInputFormContainerWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFormDataList && humanInputFormDataList.length > 0 && (
|
||||
<HumanInputFormList
|
||||
humanInputFormDataList={humanInputFormDataList}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
humanInputFilledFormDataList && humanInputFilledFormDataList.length > 0 && (
|
||||
<HumanInputFilledFormList
|
||||
humanInputFilledFormDataList={humanInputFilledFormDataList}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& !responding
|
||||
&& contentIsEmpty
|
||||
&& !hasAgentThoughts
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="chat-answer-container group ml-4 w-0 grow pb-4" ref={containerRef}>
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
(hasAgentThoughts) && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
!!(item.siblingCount && item.siblingCount > 1 && item.siblingIndex !== undefined) && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{/* Block 2: Response Content (when human inputs exist) */}
|
||||
{hasHumanInputs && (responding || !contentIsEmpty || hasAgentThoughts) && (
|
||||
<div className={cn('group relative mt-2 pr-10', chatAnswerContainerInner)}>
|
||||
<div className="absolute -top-2 left-6 h-3 w-0.5 bg-chat-answer-human-input-form-divider-bg" />
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="body-lg-regular relative inline-block w-full max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary"
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1
|
||||
&& (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Original single block layout (when no human inputs) */}
|
||||
{!hasHumanInputs && (
|
||||
<div className={cn('group relative pr-10', chatAnswerContainerInner)}>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className={cn('body-lg-regular relative inline-block max-w-full rounded-2xl bg-chat-bubble-bg px-4 py-3 text-text-primary', workflowProcess && 'w-full')}
|
||||
>
|
||||
{
|
||||
!responding && (
|
||||
<Operation
|
||||
hasWorkflowProcess={!!workflowProcess}
|
||||
maxSize={containerWidth - contentWidth - 4}
|
||||
contentWidth={contentWidth}
|
||||
item={item}
|
||||
question={question}
|
||||
index={index}
|
||||
showPromptLog={showPromptLog}
|
||||
noChatInput={noChatInput}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{/** Render workflow process */}
|
||||
{
|
||||
workflowProcess && (
|
||||
<WorkflowProcessItem
|
||||
data={workflowProcess}
|
||||
item={item}
|
||||
hideProcessDetail={hideProcessDetail}
|
||||
readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
responding && contentIsEmpty && !hasAgentThoughts && (
|
||||
<div className="flex h-5 w-6 items-center justify-center">
|
||||
<LoadingAnim type="text" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
!contentIsEmpty && !hasAgentThoughts && (
|
||||
<BasicContent item={item} />
|
||||
)
|
||||
}
|
||||
{
|
||||
hasAgentThoughts && (
|
||||
<AgentContent
|
||||
item={item}
|
||||
responding={responding}
|
||||
content={content}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!allFiles?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={allFiles}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
!!message_files?.length && (
|
||||
<FileList
|
||||
className="my-1"
|
||||
files={message_files}
|
||||
showDeleteAction={false}
|
||||
showDownloadAction
|
||||
canPreview
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
annotation?.id && annotation.authorName && (
|
||||
<EditTitle
|
||||
className="mt-1"
|
||||
title={t('editBy', { ns: 'appAnnotation', author: annotation.authorName })}
|
||||
/>
|
||||
)
|
||||
}
|
||||
<SuggestedQuestions item={item} />
|
||||
{
|
||||
!!citation?.length && !responding && (
|
||||
<Citation data={citation} showHitInfo={config?.supportCitationHitInfo} />
|
||||
)
|
||||
}
|
||||
{
|
||||
typeof item.siblingCount === 'number'
|
||||
&& item.siblingCount > 1 && (
|
||||
<ContentSwitch
|
||||
count={item.siblingCount}
|
||||
currentIndex={item.siblingIndex}
|
||||
prevDisabled={!item.prevSibling}
|
||||
nextDisabled={!item.nextSibling}
|
||||
switchSibling={handleSwitchSibling}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<More more={more} />
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ const Operation: FC<OperationProps> = ({
|
|||
feedback,
|
||||
adminFeedback,
|
||||
agent_thoughts,
|
||||
humanInputFormDataList,
|
||||
} = item
|
||||
const [userLocalFeedback, setUserLocalFeedback] = useState(feedback)
|
||||
const [adminLocalFeedback, setAdminLocalFeedback] = useState(adminFeedback)
|
||||
|
|
@ -186,7 +187,7 @@ const Operation: FC<OperationProps> = ({
|
|||
)}
|
||||
style={(!hasWorkflowProcess && positionRight) ? { left: contentWidth + 8 } : {}}
|
||||
>
|
||||
{shouldShowUserFeedbackBar && (
|
||||
{shouldShowUserFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
hasUserFeedback ? 'flex' : 'hidden group-hover:flex',
|
||||
|
|
@ -226,7 +227,7 @@ const Operation: FC<OperationProps> = ({
|
|||
)}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowAdminFeedbackBar && (
|
||||
{shouldShowAdminFeedbackBar && !humanInputFormDataList?.length && (
|
||||
<div className={cn(
|
||||
'ml-1 items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm',
|
||||
(hasAdminFeedback || hasUserFeedback) ? 'flex' : 'hidden group-hover:flex',
|
||||
|
|
@ -305,26 +306,28 @@ const Operation: FC<OperationProps> = ({
|
|||
)}
|
||||
{!isOpeningStatement && (
|
||||
<div className="ml-1 hidden items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex">
|
||||
{(config?.text_to_speech?.enabled) && (
|
||||
{(config?.text_to_speech?.enabled && !humanInputFormDataList?.length) && (
|
||||
<NewAudioButton
|
||||
id={id}
|
||||
value={content}
|
||||
voice={config?.text_to_speech?.voice}
|
||||
/>
|
||||
)}
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
{!humanInputFormDataList?.length && (
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('actionMsg.copySuccessfully', { ns: 'common' }) })
|
||||
}}
|
||||
>
|
||||
<RiClipboardLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{!noChatInput && (
|
||||
<ActionButton onClick={() => onRegenerate?.(item)}>
|
||||
<RiResetLeftLine className="h-4 w-4" />
|
||||
</ActionButton>
|
||||
)}
|
||||
{(config?.supportAnnotation && config.annotation_reply?.enabled) && (
|
||||
{config?.supportAnnotation && config.annotation_reply?.enabled && !humanInputFormDataList?.length && (
|
||||
<AnnotationCtrlButton
|
||||
appId={config?.appId || ''}
|
||||
messageId={id}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import {
|
|||
RiArrowRightSLine,
|
||||
RiErrorWarningFill,
|
||||
RiLoader2Line,
|
||||
RiPauseCircleFill,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
useEffect,
|
||||
|
|
@ -34,6 +35,8 @@ const WorkflowProcessItem = ({
|
|||
const running = data.status === WorkflowRunningStatus.Running
|
||||
const succeeded = data.status === WorkflowRunningStatus.Succeeded
|
||||
const failed = data.status === WorkflowRunningStatus.Failed || data.status === WorkflowRunningStatus.Stopped
|
||||
const paused = data.status === WorkflowRunningStatus.Paused
|
||||
const latestNode = data.tracing[data.tracing.length - 1]
|
||||
|
||||
useEffect(() => {
|
||||
setCollapse(!expand)
|
||||
|
|
@ -50,7 +53,10 @@ const WorkflowProcessItem = ({
|
|||
running && !collapse && 'bg-background-section-burn',
|
||||
succeeded && !collapse && 'bg-state-success-hover',
|
||||
failed && !collapse && 'bg-state-destructive-hover',
|
||||
collapse && 'bg-workflow-process-bg',
|
||||
paused && !collapse && 'bg-state-warning-hover',
|
||||
collapse && !failed && !paused && 'bg-workflow-process-bg',
|
||||
collapse && paused && 'bg-workflow-process-paused-bg',
|
||||
collapse && failed && 'bg-workflow-process-failed-bg',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
|
|
@ -72,8 +78,13 @@ const WorkflowProcessItem = ({
|
|||
<RiErrorWarningFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-destructive" />
|
||||
)
|
||||
}
|
||||
{
|
||||
paused && (
|
||||
<RiPauseCircleFill className="mr-1 h-3.5 w-3.5 shrink-0 text-text-warning-secondary" />
|
||||
)
|
||||
}
|
||||
<div className={cn('system-xs-medium text-text-secondary', !collapse && 'grow')}>
|
||||
{t('common.workflowProcess', { ns: 'workflow' })}
|
||||
{!collapse ? t('common.workflowProcess', { ns: 'workflow' }) : latestNode?.title}
|
||||
</div>
|
||||
<RiArrowRightSLine className={cn('ml-1 h-4 w-4 text-text-tertiary', !collapse && 'rotate-90')} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,8 @@ export type ChatContextValue = Pick<ChatProps, 'config'
|
|||
| 'onAnnotationAdded'
|
||||
| 'onAnnotationRemoved'
|
||||
| 'disableFeedback'
|
||||
| 'onFeedback'> & {
|
||||
| 'onFeedback'
|
||||
| 'getHumanInputNodeData'> & {
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
|
|
@ -45,6 +46,7 @@ export const ChatContextProvider = ({
|
|||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}: ChatContextProviderProps) => {
|
||||
return (
|
||||
<ChatContext.Provider value={{
|
||||
|
|
@ -62,6 +64,7 @@ export const ChatContextProvider = ({
|
|||
onAnnotationRemoved,
|
||||
disableFeedback,
|
||||
onFeedback,
|
||||
getHumanInputNodeData,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -75,6 +75,9 @@ export type ChatProps = {
|
|||
noSpacing?: boolean
|
||||
inputDisabled?: boolean
|
||||
sidebarCollapseState?: boolean
|
||||
hideAvatar?: boolean
|
||||
onHumanInputFormSubmit?: (formToken: string, formData: any) => Promise<void>
|
||||
getHumanInputNodeData?: (nodeID: string) => any
|
||||
}
|
||||
|
||||
const Chat: FC<ChatProps> = ({
|
||||
|
|
@ -116,6 +119,9 @@ const Chat: FC<ChatProps> = ({
|
|||
noSpacing,
|
||||
inputDisabled,
|
||||
sidebarCollapseState,
|
||||
hideAvatar,
|
||||
onHumanInputFormSubmit,
|
||||
getHumanInputNodeData,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { currentLogItem, setCurrentLogItem, showPromptLogModal, setShowPromptLogModal, showAgentLogModal, setShowAgentLogModal } = useAppStore(useShallow(state => ({
|
||||
|
|
@ -265,6 +271,7 @@ const Chat: FC<ChatProps> = ({
|
|||
onAnnotationRemoved={onAnnotationRemoved}
|
||||
disableFeedback={disableFeedback}
|
||||
onFeedback={onFeedback}
|
||||
getHumanInputNodeData={getHumanInputNodeData}
|
||||
>
|
||||
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
|
||||
<div
|
||||
|
|
@ -295,6 +302,8 @@ const Chat: FC<ChatProps> = ({
|
|||
hideProcessDetail={hideProcessDetail}
|
||||
noChatInput={noChatInput}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
onHumanInputFormSubmit={onHumanInputFormSubmit}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -306,6 +315,7 @@ const Chat: FC<ChatProps> = ({
|
|||
theme={themeBuilder?.theme}
|
||||
enableEdit={config?.questionEditEnable}
|
||||
switchSibling={switchSibling}
|
||||
hideAvatar={hideAvatar}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ type QuestionProps = {
|
|||
theme: Theme | null | undefined
|
||||
enableEdit?: boolean
|
||||
switchSibling?: (siblingMessageId: string) => void
|
||||
hideAvatar?: boolean
|
||||
}
|
||||
|
||||
const Question: FC<QuestionProps> = ({
|
||||
|
|
@ -40,6 +41,7 @@ const Question: FC<QuestionProps> = ({
|
|||
theme,
|
||||
enableEdit = true,
|
||||
switchSibling,
|
||||
hideAvatar,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
|
|
@ -174,15 +176,17 @@ const Question: FC<QuestionProps> = ({
|
|||
</div>
|
||||
<div className="mt-1 h-[18px]" />
|
||||
</div>
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{!hideAvatar && (
|
||||
<div className="h-10 w-10 shrink-0">
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<User className="h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,11 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
|||
import type { TypeWithI18N } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import type { Annotation, MessageRating } from '@/models/log'
|
||||
import type { FileResponse } from '@/types/workflow'
|
||||
import type {
|
||||
FileResponse,
|
||||
HumanInputFilledFormData,
|
||||
HumanInputFormData,
|
||||
} from '@/types/workflow'
|
||||
|
||||
export type MessageMore = {
|
||||
time: string
|
||||
|
|
@ -64,6 +68,19 @@ export type CitationItem = {
|
|||
word_count: number
|
||||
}
|
||||
|
||||
export type ExtraContent
|
||||
= {
|
||||
type: 'human_input'
|
||||
submitted: false
|
||||
form_definition: HumanInputFormData
|
||||
workflow_run_id: string
|
||||
}
|
||||
| {
|
||||
type: 'human_input'
|
||||
submitted: true
|
||||
form_submission_data: HumanInputFilledFormData
|
||||
}
|
||||
|
||||
export type IChatItem = {
|
||||
id: string
|
||||
content: string
|
||||
|
|
@ -104,6 +121,10 @@ export type IChatItem = {
|
|||
siblingIndex?: number
|
||||
prevSibling?: string
|
||||
nextSibling?: string
|
||||
// for human input
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
extra_contents?: ExtraContent[]
|
||||
}
|
||||
|
||||
export type Metadata = {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FileEntity } from '../../file-uploader/types'
|
|||
import type {
|
||||
ChatConfig,
|
||||
ChatItem,
|
||||
ChatItemInTree,
|
||||
OnSend,
|
||||
} from '../types'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
|
|
@ -17,7 +18,9 @@ import {
|
|||
fetchSuggestedQuestions,
|
||||
getUrl,
|
||||
stopChatMessageResponding,
|
||||
submitHumanInputForm,
|
||||
} from '@/service/share'
|
||||
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Avatar from '../../avatar'
|
||||
|
|
@ -70,9 +73,9 @@ const ChatWrapper = () => {
|
|||
}, [appParams, currentConversationItem?.introduction])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
handleSend,
|
||||
handleStop,
|
||||
handleSwitchSibling,
|
||||
isResponding: respondingState,
|
||||
suggestedQuestions,
|
||||
} = useChat(
|
||||
|
|
@ -130,6 +133,40 @@ const ChatWrapper = () => {
|
|||
setIsResponding(respondingState)
|
||||
}, [respondingState, setIsResponding])
|
||||
|
||||
// Resume paused workflows when chat history is loaded
|
||||
useEffect(() => {
|
||||
if (!appPrevChatList || appPrevChatList.length === 0)
|
||||
return
|
||||
|
||||
// Find the last answer item with workflow_run_id that needs resumption (DFS - find deepest first)
|
||||
let lastPausedNode: ChatItemInTree | undefined
|
||||
const findLastPausedWorkflow = (nodes: ChatItemInTree[]) => {
|
||||
nodes.forEach((node) => {
|
||||
// DFS: recurse to children first
|
||||
if (node.children && node.children.length > 0)
|
||||
findLastPausedWorkflow(node.children)
|
||||
|
||||
// Track the last node with humanInputFormDataList
|
||||
if (node.isAnswer && node.workflow_run_id && node.humanInputFormDataList && node.humanInputFormDataList.length > 0)
|
||||
lastPausedNode = node
|
||||
})
|
||||
}
|
||||
|
||||
findLastPausedWorkflow(appPrevChatList)
|
||||
|
||||
// Only resume the last paused workflow
|
||||
if (lastPausedNode) {
|
||||
handleSwitchSibling(
|
||||
lastPausedNode.id,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const doSend: OnSend = useCallback((message, files, isRegenerate = false, parentAnswer: ChatItem | null = null) => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
|
|
@ -147,7 +184,7 @@ const ChatWrapper = () => {
|
|||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
},
|
||||
)
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
|
||||
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
|
|
@ -155,6 +192,14 @@ const ChatWrapper = () => {
|
|||
doSend(editedQuestion ? editedQuestion.message : question.content, editedQuestion ? editedQuestion.files : question.message_files, true, isValidGeneratedAnswer(parentAnswer) ? parentAnswer : null)
|
||||
}, [chatList, doSend])
|
||||
|
||||
const doSwitchSibling = useCallback((siblingMessageId: string) => {
|
||||
handleSwitchSibling(siblingMessageId, {
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
})
|
||||
}, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId || chatList.length > 1)
|
||||
return chatList
|
||||
|
|
@ -178,6 +223,13 @@ const ChatWrapper = () => {
|
|||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const handleSubmitHumanInputForm = useCallback(async (formToken: string, formData: any) => {
|
||||
if (isInstalledApp)
|
||||
await submitHumanInputFormService(formToken, formData)
|
||||
else
|
||||
await submitHumanInputForm(formToken, formData)
|
||||
}, [isInstalledApp])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
|
|
@ -223,7 +275,7 @@ const ChatWrapper = () => {
|
|||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
|
||||
|
||||
const answerIcon = isDify()
|
||||
? <LogoAvatar className="relative shrink-0" />
|
||||
|
|
@ -253,6 +305,7 @@ const ChatWrapper = () => {
|
|||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
onHumanInputFormSubmit={handleSubmitHumanInputForm}
|
||||
chatNode={(
|
||||
<>
|
||||
{chatNode}
|
||||
|
|
@ -266,7 +319,7 @@ const ChatWrapper = () => {
|
|||
answerIcon={answerIcon}
|
||||
hideProcessDetail
|
||||
themeBuilder={themeBuilder}
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
switchSibling={doSwitchSibling}
|
||||
inputDisabled={inputDisabled}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import type {
|
|||
ModelConfig,
|
||||
VisionSettings,
|
||||
} from '@/types/app'
|
||||
import type { NodeTracing } from '@/types/workflow'
|
||||
import type { HumanInputFilledFormData, HumanInputFormData, NodeTracing } from '@/types/workflow'
|
||||
|
||||
export type {
|
||||
Inputs,
|
||||
|
|
@ -67,6 +67,8 @@ export type WorkflowProcess = {
|
|||
expand?: boolean // for UI
|
||||
resultText?: string
|
||||
files?: FileEntity[]
|
||||
humanInputFormDataList?: HumanInputFormData[]
|
||||
humanInputFilledFormDataList?: HumanInputFilledFormData[]
|
||||
}
|
||||
|
||||
export type ChatItem = IChatItem & {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="27" height="27" viewBox="0 0 27 27" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z" fill="#44BEDF"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z" fill="#2EB67D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z" fill="#ECB22E"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z" fill="#E01E5A"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
|
|
@ -0,0 +1,19 @@
|
|||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_20307_29670)">
|
||||
<path opacity="0.1" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.1" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z" fill="#5059C9"/>
|
||||
<path d="M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z" fill="#5059C9"/>
|
||||
<path d="M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z" fill="#7B83EB"/>
|
||||
<path d="M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z" fill="#7B83EB"/>
|
||||
<path opacity="0.5" d="M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z" fill="black"/>
|
||||
<path opacity="0.5" d="M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z" fill="black"/>
|
||||
<path d="M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z" fill="#4B53BC"/>
|
||||
<path d="M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_20307_29670">
|
||||
<rect width="28" height="28" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.1 KiB |
|
|
@ -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.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z" fill="white"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
|
|
@ -0,0 +1,61 @@
|
|||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "27",
|
||||
"height": "27",
|
||||
"viewBox": "0 0 27 27",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M9.625 0C9.27992 0.000320434 8.93828 0.0686147 8.6196 0.200983C8.30091 0.333351 8.01142 0.527199 7.76766 0.771458C7.5239 1.01572 7.33064 1.3056 7.19893 1.62456C7.06721 1.94351 6.99962 2.28529 7 2.63037C6.99968 2.97541 7.06732 3.31714 7.19907 3.63603C7.33081 3.95493 7.52408 4.24476 7.76783 4.48897C8.01159 4.73317 8.30105 4.92698 8.61971 5.05932C8.93836 5.19165 9.27996 5.25993 9.625 5.26025H12.25V2.63037C12.2504 2.28529 12.1828 1.94351 12.0511 1.62456C11.9194 1.3056 11.7261 1.01572 11.4823 0.771458C11.2386 0.527199 10.9491 0.333351 10.6304 0.200983C10.3117 0.0686147 9.97008 0.000320434 9.625 0ZM9.625 7.01416H2.625C2.27996 7.01448 1.93836 7.08276 1.61971 7.21509C1.30106 7.34743 1.01159 7.54124 0.767835 7.78544C0.524081 8.02965 0.330814 8.31948 0.199069 8.63838C0.0673241 8.95728 -0.000319124 9.299 1.63476e-06 9.64404C-0.000383292 9.98912 0.0672126 10.3309 0.198929 10.6499C0.330645 10.9688 0.523902 11.2587 0.767662 11.503C1.01142 11.7472 1.30091 11.9411 1.6196 12.0734C1.93828 12.2058 2.27992 12.2741 2.625 12.2744H9.625C9.97008 12.2741 10.3117 12.2058 10.6304 12.0734C10.9491 11.9411 11.2386 11.7472 11.4823 11.503C11.7261 11.2587 11.9194 10.9688 12.0511 10.6499C12.1828 10.3309 12.2504 9.98912 12.25 9.64404C12.2503 9.299 12.1827 8.95728 12.0509 8.63838C11.9192 8.31948 11.7259 8.02965 11.4822 7.78544C11.2384 7.54124 10.9489 7.34743 10.6303 7.21509C10.3116 7.08276 9.97004 7.01448 9.625 7.01416Z",
|
||||
"fill": "#44BEDF"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M26.25 9.64404C26.2503 9.299 26.1827 8.95728 26.0509 8.63838C25.9192 8.31948 25.7259 8.02965 25.4822 7.78544C25.2384 7.54124 24.9489 7.34743 24.6303 7.21509C24.3116 7.08276 23.97 7.01448 23.625 7.01416C23.28 7.01448 22.9384 7.08276 22.6197 7.21509C22.3011 7.34743 22.0116 7.54124 21.7678 7.78544C21.5241 8.02965 21.3308 8.31948 21.1991 8.63838C21.0673 8.95728 20.9997 9.299 21 9.64404V12.2744H23.625C23.9701 12.2741 24.3117 12.2058 24.6304 12.0734C24.9491 11.9411 25.2386 11.7472 25.4823 11.503C25.7261 11.2587 25.9194 10.9688 26.0511 10.6499C26.1828 10.3309 26.2504 9.98912 26.25 9.64404ZM19.25 9.64404V2.63037C19.2504 2.28529 19.1828 1.94351 19.0511 1.62456C18.9194 1.3056 18.7261 1.01572 18.4823 0.771458C18.2386 0.527199 17.9491 0.333351 17.6304 0.200983C17.3117 0.0686147 16.9701 0.000320434 16.625 0C16.2799 0.000320434 15.9383 0.0686147 15.6196 0.200983C15.3009 0.333351 15.0114 0.527199 14.7677 0.771458C14.5239 1.01572 14.3306 1.3056 14.1989 1.62456C14.0672 1.94351 13.9996 2.28529 14 2.63037V9.64404C13.9996 9.98912 14.0672 10.3309 14.1989 10.6499C14.3306 10.9688 14.5239 11.2587 14.7677 11.503C15.0114 11.7472 15.3009 11.9411 15.6196 12.0734C15.9383 12.2058 16.2799 12.2741 16.625 12.2744C16.9701 12.2741 17.3117 12.2058 17.6304 12.0734C17.9491 11.9411 18.2386 11.7472 18.4823 11.503C18.7261 11.2587 18.9194 10.9688 19.0511 10.6499C19.1828 10.3309 19.2504 9.98912 19.25 9.64404Z",
|
||||
"fill": "#2EB67D"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M16.625 26.302C16.9701 26.3017 17.3117 26.2334 17.6304 26.101C17.9491 25.9686 18.2386 25.7748 18.4823 25.5305C18.7261 25.2863 18.9194 24.9964 19.0511 24.6774C19.1828 24.3585 19.2504 24.0167 19.25 23.6716C19.2503 23.3266 19.1827 22.9849 19.0509 22.666C18.9192 22.3471 18.7259 22.0572 18.4822 21.813C18.2384 21.5688 17.9489 21.375 17.6303 21.2427C17.3116 21.1103 16.97 21.0421 16.625 21.0417H14V23.6716C13.9996 24.0167 14.0672 24.3585 14.1989 24.6774C14.3306 24.9964 14.5239 25.2863 14.7677 25.5305C15.0114 25.7748 15.3009 25.9686 15.6196 26.101C15.9383 26.2334 16.2799 26.3017 16.625 26.302ZM16.625 19.2878H23.625C23.97 19.2875 24.3116 19.2192 24.6303 19.0869C24.9489 18.9546 25.2384 18.7608 25.4822 18.5166C25.7259 18.2723 25.9192 17.9825 26.0509 17.6636C26.1827 17.3447 26.2503 17.003 26.25 16.658C26.2504 16.3129 26.1828 15.9711 26.0511 15.6521C25.9194 15.3332 25.7261 15.0433 25.4823 14.799C25.2386 14.5548 24.9491 14.3609 24.6304 14.2286C24.3117 14.0962 23.9701 14.0279 23.625 14.0276H16.625C16.2799 14.0279 15.9383 14.0962 15.6196 14.2286C15.3009 14.3609 15.0114 14.5548 14.7677 14.799C14.5239 15.0433 14.3306 15.3332 14.1989 15.6521C14.0672 15.9711 13.9996 16.3129 14 16.658C13.9997 17.003 14.0673 17.3447 14.1991 17.6636C14.3308 17.9825 14.5241 18.2723 14.7678 18.5166C15.0116 18.7608 15.3011 18.9546 15.6197 19.0869C15.9384 19.2192 16.28 19.2875 16.625 19.2878Z",
|
||||
"fill": "#ECB22E"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M2.22485e-06 16.658C-0.000318534 17.003 0.0673247 17.3447 0.19907 17.6636C0.330815 17.9825 0.524081 18.2723 0.767835 18.5166C1.01159 18.7608 1.30106 18.9546 1.61971 19.0869C1.93836 19.2192 2.27996 19.2875 2.625 19.2878C2.97004 19.2875 3.31164 19.2192 3.63029 19.0869C3.94895 18.9546 4.23841 18.7608 4.48217 18.5166C4.72592 18.2723 4.91919 17.9825 5.05093 17.6636C5.18268 17.3447 5.25032 17.003 5.25 16.658V14.0276H2.625C2.27988 14.0279 1.9382 14.0962 1.61948 14.2286C1.30077 14.361 1.01126 14.5549 0.76749 14.7992C0.523723 15.0435 0.330477 15.3335 0.198789 15.6525C0.0671016 15.9715 -0.000446885 16.3128 2.22485e-06 16.658ZM7 16.658V23.6716C6.99962 24.0167 7.06721 24.3585 7.19893 24.6774C7.33064 24.9964 7.5239 25.2863 7.76766 25.5305C8.01142 25.7748 8.30091 25.9686 8.6196 26.101C8.93828 26.2334 9.27992 26.3017 9.625 26.302C9.97008 26.3017 10.3117 26.2334 10.6304 26.101C10.9491 25.9686 11.2386 25.7748 11.4823 25.5305C11.7261 25.2863 11.9194 24.9964 12.0511 24.6774C12.1828 24.3585 12.2504 24.0167 12.25 23.6716V16.6584C12.2504 16.3134 12.1828 15.9716 12.0511 15.6526C11.9194 15.3337 11.7261 15.0438 11.4823 14.7995C11.2386 14.5553 10.9491 14.3614 10.6304 14.2291C10.3117 14.0967 9.97008 14.0284 9.625 14.0281C9.27992 14.0284 8.93828 14.0967 8.6196 14.2291C8.30091 14.3614 8.01142 14.5553 7.76766 14.7995C7.5239 15.0438 7.33064 15.3337 7.19893 15.6526C7.06721 15.9716 6.99962 16.3129 7 16.658Z",
|
||||
"fill": "#E01E5A"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Slack"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Slack.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Slack'
|
||||
|
||||
export default Icon
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"viewBox": "0 0 28 28",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"clip-path": "url(#clip0_20307_29670)"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.1",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M26.6817 10.5H20.8484L19.2267 11.8183V18.34C19.2846 19.4628 19.7715 20.5204 20.5867 21.2946C21.4019 22.0689 22.4833 22.5005 23.6075 22.5005C24.7318 22.5005 25.8131 22.0689 26.6283 21.2946C27.4436 20.5204 27.9304 19.4628 27.9884 18.34V11.8183C27.9899 11.6458 27.9572 11.4746 27.8923 11.3147C27.8273 11.1548 27.7313 11.0094 27.6098 10.8868C27.4883 10.7643 27.3437 10.667 27.1844 10.6006C27.0251 10.5342 26.8543 10.5 26.6817 10.5Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M23.9167 9.33333C25.5275 9.33333 26.8333 8.0275 26.8333 6.41667C26.8333 4.80584 25.5275 3.5 23.9167 3.5C22.3058 3.5 21 4.80584 21 6.41667C21 8.0275 22.3058 9.33333 23.9167 9.33333Z",
|
||||
"fill": "#5059C9"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M10.64 10.5H20.86C21.2065 10.5 21.5389 10.6377 21.7839 10.8827C22.029 11.1278 22.1666 11.4601 22.1666 11.8067V20.4167C22.1666 22.1185 21.4906 23.7506 20.2872 24.9539C19.0839 26.1573 17.4518 26.8333 15.75 26.8333C14.0482 26.8333 12.4161 26.1573 11.2127 24.9539C10.0094 23.7506 9.33331 22.1185 9.33331 20.4167V11.8067C9.33331 11.4601 9.47098 11.1278 9.71603 10.8827C9.96107 10.6377 10.2934 10.5 10.64 10.5Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M16.3333 9.69477C18.4661 9.69477 20.195 7.96584 20.195 5.8331C20.195 3.70036 18.4661 1.97144 16.3333 1.97144C14.2006 1.97144 12.4717 3.70036 12.4717 5.8331C12.4717 7.96584 14.2006 9.69477 16.3333 9.69477Z",
|
||||
"fill": "#7B83EB"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M9.33332 11.8067V20.4167C9.33204 21.432 9.57619 22.4327 10.045 23.3333H15.8783C16.2912 23.305 16.6807 23.1312 16.9776 22.8428C17.2745 22.5545 17.4596 22.1702 17.5 21.7583V10.5H17.1733H10.64C10.2934 10.5 9.96108 10.6377 9.71603 10.8827C9.47098 11.1278 9.33332 11.4601 9.33332 11.8067Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"opacity": "0.5",
|
||||
"d": "M16.135 7H12.635C12.8805 7.78672 13.3726 8.47355 14.0386 8.9589C14.7047 9.44425 15.5093 9.70234 16.3333 9.695C16.7301 9.68885 17.1235 9.62197 17.5 9.49667V8.33C17.488 7.97504 17.3392 7.63847 17.0849 7.39061C16.8305 7.14276 16.4902 7.00281 16.135 7Z",
|
||||
"fill": "black"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M14.9683 5.83325H1.365C0.611131 5.83325 0 6.44438 0 7.19825V20.8016C0 21.5555 0.611131 22.1666 1.365 22.1666H14.9683C15.7222 22.1666 16.3333 21.5555 16.3333 20.8016V7.19825C16.3333 6.44438 15.7222 5.83325 14.9683 5.83325Z",
|
||||
"fill": "#4B53BC"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M11.8767 11.1766H9.08833V18.6666H7.25666V11.1766H4.45667V9.33325H11.8767V11.1766Z",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "defs",
|
||||
"attributes": {},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "clipPath",
|
||||
"attributes": {
|
||||
"id": "clip0_20307_29670"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "rect",
|
||||
"attributes": {
|
||||
"width": "28",
|
||||
"height": "28",
|
||||
"fill": "white"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Teams"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './Teams.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Teams'
|
||||
|
||||
export default Icon
|
||||
|
|
@ -2,3 +2,5 @@ export { default as DefaultToolIcon } from './DefaultToolIcon'
|
|||
export { default as Icon3Dots } from './Icon3Dots'
|
||||
export { default as Message3Fill } from './Message3Fill'
|
||||
export { default as RowStruct } from './RowStruct'
|
||||
export { default as Slack } from './Slack'
|
||||
export { default as Teams } from './Teams'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M3.66634 2.66675C3.66634 2.29856 3.36787 2.00008 2.99967 2.00008C2.63149 2.00008 2.33301 2.29855 2.33301 2.66675L3.66634 2.66675ZM2.99967 5.33341H2.33301C2.33301 5.51023 2.40325 5.6798 2.52827 5.80482C2.65329 5.92984 2.82286 6.00008 2.99967 6.00008V5.33341ZM5.66641 6.00008C6.03459 6.00008 6.33307 5.7016 6.33307 5.33341C6.33307 4.96523 6.03459 4.66675 5.66641 4.66675L5.66641 6.00008ZM2.41183 5.01659C2.23816 5.34125 2.36056 5.74523 2.68522 5.91889C3.00988 6.09256 3.41385 5.97016 3.58752 5.6455L2.41183 5.01659ZM3.03395 8.58915C2.99109 8.22348 2.6599 7.96175 2.29421 8.00461C1.92853 8.04748 1.66682 8.37868 1.70967 8.74435L3.03395 8.58915ZM12.0439 5.05931C12.2607 5.3569 12.6777 5.42238 12.9753 5.20557C13.2729 4.98876 13.3383 4.57176 13.1215 4.27417L12.0439 5.05931ZM5.02145 13.5907C5.34627 13.7641 5.75013 13.6413 5.92349 13.3165C6.09685 12.9917 5.97407 12.5878 5.64925 12.4145L5.02145 13.5907ZM2.33301 2.66675L2.33301 5.33341H3.66634L3.66634 2.66675L2.33301 2.66675ZM2.99967 6.00008L5.66641 6.00008L5.66641 4.66675H2.99967L2.99967 6.00008ZM3.58752 5.6455C4.43045 4.06972 6.09066 3.00008 7.99968 3.00008V1.66675C5.57951 1.66675 3.47747 3.02445 2.41183 5.01659L3.58752 5.6455ZM7.99968 3.00008C9.66128 3.00008 11.1336 3.80991 12.0439 5.05931L13.1215 4.27417C11.9711 2.69513 10.1055 1.66675 7.99968 1.66675V3.00008ZM5.64925 12.4145C4.23557 11.6599 3.22828 10.2474 3.03395 8.58915L1.70967 8.74435C1.95639 10.8495 3.23403 12.6367 5.02145 13.5907L5.64925 12.4145Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M13.1946 8.13826C12.9027 7.84637 12.4294 7.84638 12.1375 8.13829L9.11842 11.1574C9.01642 11.2594 8.95025 11.3917 8.92985 11.5345C8.92985 11.5345 8.92985 11.5345 8.92985 11.5345L8.78508 12.5479L9.79846 12.4031C9.94127 12.3827 10.0736 12.3165 10.1756 12.2145L13.1947 9.19548C13.4866 8.90359 13.4866 8.43027 13.1946 8.13826C13.1947 8.13827 13.1946 8.13825 13.1946 8.13826ZM11.1947 7.19548C12.0073 6.38286 13.3249 6.38286 14.1375 7.19548C14.95 8.00814 14.9501 9.32565 14.1375 10.1383L11.1184 13.1574C10.8124 13.4633 10.4154 13.6618 9.98703 13.723L8.09369 13.9935C7.88596 14.0232 7.67639 13.9533 7.52801 13.805C7.37963 13.6566 7.30977 13.447 7.33945 13.2393L7.60991 11.3459C7.67111 10.9175 7.86961 10.5205 8.17561 10.2145L11.1947 7.19548Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"fill-rule": "evenodd",
|
||||
"clip-rule": "evenodd",
|
||||
"d": "M12.528 10.8048L10.528 8.80482L11.4708 7.86201L13.4708 9.86201L12.528 10.8048Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "HumanInLoop"
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
import * as React from 'react'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import data from './HumanInLoop.json'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'HumanInLoop'
|
||||
|
||||
export default Icon
|
||||
|
|
@ -10,6 +10,7 @@ export { default as DocsExtractor } from './DocsExtractor'
|
|||
export { default as End } from './End'
|
||||
export { default as Home } from './Home'
|
||||
export { default as Http } from './Http'
|
||||
export { default as HumanInLoop } from './HumanInLoop'
|
||||
export { default as IfElse } from './IfElse'
|
||||
export { default as Iteration } from './Iteration'
|
||||
export { default as IterationStart } from './IterationStart'
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export type MarkdownProps = {
|
|||
content: string
|
||||
className?: string
|
||||
pluginInfo?: SimplePluginInfo
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements'>
|
||||
} & Pick<ReactMarkdownWrapperProps, 'customComponents' | 'customDisallowedElements' | 'rehypePlugins'>
|
||||
|
||||
export const Markdown = (props: MarkdownProps) => {
|
||||
const { customComponents = {}, pluginInfo } = props
|
||||
|
|
@ -29,7 +29,13 @@ export const Markdown = (props: MarkdownProps) => {
|
|||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown pluginInfo={pluginInfo} latexContent={latexContent} customComponents={customComponents} customDisallowedElements={props.customDisallowedElements} />
|
||||
<ReactMarkdown
|
||||
pluginInfo={pluginInfo}
|
||||
latexContent={latexContent}
|
||||
customComponents={customComponents}
|
||||
customDisallowedElements={props.customDisallowedElements}
|
||||
rehypePlugins={props.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type ReactMarkdownWrapperProps = {
|
|||
customDisallowedElements?: string[]
|
||||
customComponents?: Record<string, React.ComponentType<any>>
|
||||
pluginInfo?: SimplePluginInfo
|
||||
rehypePlugins?: any// js: PluggableList[]
|
||||
}
|
||||
|
||||
export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
||||
|
|
@ -55,6 +56,7 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
|
|||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
...(props.rehypePlugins || []),
|
||||
]}
|
||||
urlTransform={customUrlTransform}
|
||||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { SupportUploadFileTypes } from '../../workflow/types'
|
|||
export const CONTEXT_PLACEHOLDER_TEXT = '{{#context#}}'
|
||||
export const HISTORY_PLACEHOLDER_TEXT = '{{#histories#}}'
|
||||
export const QUERY_PLACEHOLDER_TEXT = '{{#query#}}'
|
||||
export const REQUEST_URL_PLACEHOLDER_TEXT = '{{#url#}}'
|
||||
export const CURRENT_PLACEHOLDER_TEXT = '{{#current#}}'
|
||||
export const ERROR_MESSAGE_PLACEHOLDER_TEXT = '{{#error_message#}}'
|
||||
export const LAST_RUN_PLACEHOLDER_TEXT = '{{#last_run#}}'
|
||||
|
|
@ -30,6 +31,12 @@ export const checkHasQueryBlock = (text: string) => {
|
|||
return text.includes(QUERY_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
export const checkHasRequestURLBlock = (text: string) => {
|
||||
if (!text)
|
||||
return false
|
||||
return text.includes(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
}
|
||||
|
||||
/*
|
||||
* {{#1711617514996.name#}} => [1711617514996, name]
|
||||
* {{#1711617514996.sys.query#}} => [sys, query]
|
||||
|
|
|
|||
|
|
@ -2,16 +2,20 @@
|
|||
|
||||
import type {
|
||||
EditorState,
|
||||
LexicalCommand,
|
||||
} from 'lexical'
|
||||
import type { FC } from 'react'
|
||||
import type { Hotkey } from './plugins/shortcuts-popup-plugin'
|
||||
import type {
|
||||
ContextBlockType,
|
||||
CurrentBlockType,
|
||||
ErrorMessageBlockType,
|
||||
ExternalToolBlockType,
|
||||
HistoryBlockType,
|
||||
HITLInputBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from './types'
|
||||
|
|
@ -27,7 +31,7 @@ import {
|
|||
TextNode,
|
||||
} from 'lexical'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import {
|
||||
|
|
@ -46,17 +50,23 @@ import {
|
|||
CurrentBlockReplacementBlock,
|
||||
} from './plugins/current-block'
|
||||
import { CustomTextNode } from './plugins/custom-text/node'
|
||||
import DraggableBlockPlugin from './plugins/draggable-plugin'
|
||||
import {
|
||||
ErrorMessageBlock,
|
||||
ErrorMessageBlockNode,
|
||||
ErrorMessageBlockReplacementBlock,
|
||||
} from './plugins/error-message-block'
|
||||
|
||||
import {
|
||||
HistoryBlock,
|
||||
HistoryBlockNode,
|
||||
HistoryBlockReplacementBlock,
|
||||
} from './plugins/history-block'
|
||||
|
||||
import {
|
||||
HITLInputBlock,
|
||||
HITLInputBlockReplacementBlock,
|
||||
HITLInputNode,
|
||||
} from './plugins/hitl-input-block'
|
||||
import {
|
||||
LastRunBlock,
|
||||
LastRunBlockNode,
|
||||
|
|
@ -70,6 +80,12 @@ import {
|
|||
QueryBlockNode,
|
||||
QueryBlockReplacementBlock,
|
||||
} from './plugins/query-block'
|
||||
import {
|
||||
RequestURLBlock,
|
||||
RequestURLBlockNode,
|
||||
RequestURLBlockReplacementBlock,
|
||||
} from './plugins/request-url-block'
|
||||
import ShortcutsPopupPlugin from './plugins/shortcuts-popup-plugin'
|
||||
import UpdateBlock from './plugins/update-block'
|
||||
import VariableBlock from './plugins/variable-block'
|
||||
import VariableValueBlock from './plugins/variable-value-block'
|
||||
|
|
@ -96,14 +112,17 @@ export type PromptEditorProps = {
|
|||
onFocus?: () => void
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
workflowVariableBlock?: WorkflowVariableBlockType
|
||||
hitlInputBlock?: HITLInputBlockType
|
||||
currentBlock?: CurrentBlockType
|
||||
errorMessageBlock?: ErrorMessageBlockType
|
||||
lastRunBlock?: LastRunBlockType
|
||||
isSupportFileVar?: boolean
|
||||
shortcutPopups?: Array<{ hotkey: Hotkey, Popup: React.ComponentType<{ onClose: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void }> }>
|
||||
}
|
||||
|
||||
const PromptEditor: FC<PromptEditorProps> = ({
|
||||
|
|
@ -121,14 +140,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
onFocus,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
hitlInputBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
isSupportFileVar,
|
||||
shortcutPopups = [],
|
||||
}) => {
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const initialConfig = {
|
||||
|
|
@ -143,8 +165,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
ContextBlockNode,
|
||||
HistoryBlockNode,
|
||||
QueryBlockNode,
|
||||
RequestURLBlockNode,
|
||||
WorkflowVariableBlockNode,
|
||||
VariableValueBlockNode,
|
||||
HITLInputNode,
|
||||
CurrentBlockNode,
|
||||
ErrorMessageBlockNode,
|
||||
LastRunBlockNode, // LastRunBlockNode is used for error message block replacement
|
||||
|
|
@ -176,9 +200,16 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
} as any)
|
||||
}, [eventEmitter, historyBlock?.history])
|
||||
|
||||
const [floatingAnchorElem, setFloatingAnchorElem] = useState(null)
|
||||
|
||||
const onRef = (_floatingAnchorElem: any) => {
|
||||
if (_floatingAnchorElem !== null)
|
||||
setFloatingAnchorElem(_floatingAnchorElem)
|
||||
}
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={{ ...initialConfig, editable }}>
|
||||
<div className={cn('relative', wrapperClassName)}>
|
||||
<div className={cn('relative', wrapperClassName)} ref={onRef}>
|
||||
<RichTextPlugin
|
||||
contentEditable={(
|
||||
<ContentEditable
|
||||
|
|
@ -199,11 +230,17 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
)}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
{shortcutPopups?.map(({ hotkey, Popup }, idx) => (
|
||||
<ShortcutsPopupPlugin key={idx} hotkey={hotkey}>
|
||||
{(closePortal, onInsert) => <Popup onClose={closePortal} onInsert={onInsert} />}
|
||||
</ShortcutsPopupPlugin>
|
||||
))}
|
||||
<ComponentPickerBlock
|
||||
triggerString="/"
|
||||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
|
|
@ -217,6 +254,7 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
contextBlock={contextBlock}
|
||||
historyBlock={historyBlock}
|
||||
queryBlock={queryBlock}
|
||||
requestURLBlock={requestURLBlock}
|
||||
variableBlock={variableBlock}
|
||||
externalToolBlock={externalToolBlock}
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
|
|
@ -265,6 +303,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
hitlInputBlock?.show && (
|
||||
<>
|
||||
<HITLInputBlock {...hitlInputBlock} />
|
||||
<HITLInputBlockReplacementBlock {...hitlInputBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
currentBlock?.show && (
|
||||
<>
|
||||
|
|
@ -273,6 +319,14 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
requestURLBlock?.show && (
|
||||
<>
|
||||
<RequestURLBlock {...requestURLBlock} />
|
||||
<RequestURLBlockReplacementBlock {...requestURLBlock} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
{
|
||||
errorMessageBlock?.show && (
|
||||
<>
|
||||
|
|
@ -298,6 +352,9 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
|||
<OnBlurBlock onBlur={onBlur} onFocus={onFocus} />
|
||||
<UpdateBlock instanceId={instanceId} />
|
||||
<HistoryPlugin />
|
||||
{floatingAnchorElem && (
|
||||
<DraggableBlockPlugin anchorElem={floatingAnchorElem} />
|
||||
)}
|
||||
{/* <TreeView /> */}
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
|
|
|
|||
|
|
@ -6,10 +6,12 @@ import type {
|
|||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { $insertNodes } from 'lexical'
|
||||
import { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
|
@ -27,6 +29,7 @@ import { INSERT_CONTEXT_BLOCK_COMMAND } from '../context-block'
|
|||
import { $createCustomTextNode } from '../custom-text/node'
|
||||
import { INSERT_HISTORY_BLOCK_COMMAND } from '../history-block'
|
||||
import { INSERT_QUERY_BLOCK_COMMAND } from '../query-block'
|
||||
import { INSERT_REQUEST_URL_BLOCK_COMMAND } from '../request-url-block'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '../variable-block'
|
||||
import { PickerBlockMenuOption } from './menu'
|
||||
import { PromptMenuItem } from './prompt-option'
|
||||
|
|
@ -36,6 +39,7 @@ export const usePromptOptions = (
|
|||
contextBlock?: ContextBlockType,
|
||||
queryBlock?: QueryBlockType,
|
||||
historyBlock?: HistoryBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
|
@ -91,6 +95,30 @@ export const usePromptOptions = (
|
|||
)
|
||||
}
|
||||
|
||||
if (requestURLBlock?.show) {
|
||||
promptOptions.push(new PickerBlockMenuOption({
|
||||
key: t('promptEditor.requestURL.item.title', { ns: 'common' }),
|
||||
group: 'request URL',
|
||||
render: ({ isSelected, onSelect, onSetHighlight }) => {
|
||||
return (
|
||||
<PromptMenuItem
|
||||
title={t('promptEditor.requestURL.item.title', { ns: 'common' })}
|
||||
icon={<RiGlobalLine className="h-4 w-4 text-util-colors-violet-violet-600" />}
|
||||
disabled={!requestURLBlock.selectable}
|
||||
isSelected={isSelected}
|
||||
onClick={onSelect}
|
||||
onMouseEnter={onSetHighlight}
|
||||
/>
|
||||
)
|
||||
},
|
||||
onSelect: () => {
|
||||
if (!requestURLBlock?.selectable)
|
||||
return
|
||||
editor.dispatchCommand(INSERT_REQUEST_URL_BLOCK_COMMAND, undefined)
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
if (historyBlock?.show) {
|
||||
promptOptions.push(
|
||||
new PickerBlockMenuOption({
|
||||
|
|
@ -272,12 +300,13 @@ export const useOptions = (
|
|||
variableBlock?: VariableBlockType,
|
||||
externalToolBlockType?: ExternalToolBlockType,
|
||||
workflowVariableBlockType?: WorkflowVariableBlockType,
|
||||
requestURLBlock?: RequestURLBlockType,
|
||||
currentBlockType?: CurrentBlockType,
|
||||
errorMessageBlockType?: ErrorMessageBlockType,
|
||||
lastRunBlockType?: LastRunBlockType,
|
||||
queryString?: string,
|
||||
) => {
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock)
|
||||
const promptOptions = usePromptOptions(contextBlock, queryBlock, historyBlock, requestURLBlock)
|
||||
const variableOptions = useVariableOptions(variableBlock, queryString)
|
||||
const externalToolOptions = useExternalToolOptions(externalToolBlockType, queryString)
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
HistoryBlockType,
|
||||
LastRunBlockType,
|
||||
QueryBlockType,
|
||||
RequestURLBlockType,
|
||||
VariableBlockType,
|
||||
WorkflowVariableBlockType,
|
||||
} from '../../types'
|
||||
|
|
@ -44,6 +45,7 @@ type ComponentPickerProps = {
|
|||
triggerString: string
|
||||
contextBlock?: ContextBlockType
|
||||
queryBlock?: QueryBlockType
|
||||
requestURLBlock?: RequestURLBlockType
|
||||
historyBlock?: HistoryBlockType
|
||||
variableBlock?: VariableBlockType
|
||||
externalToolBlock?: ExternalToolBlockType
|
||||
|
|
@ -57,6 +59,7 @@ const ComponentPicker = ({
|
|||
triggerString,
|
||||
contextBlock,
|
||||
queryBlock,
|
||||
requestURLBlock,
|
||||
historyBlock,
|
||||
variableBlock,
|
||||
externalToolBlock,
|
||||
|
|
@ -100,6 +103,7 @@ const ComponentPicker = ({
|
|||
variableBlock,
|
||||
externalToolBlock,
|
||||
workflowVariableBlock,
|
||||
requestURLBlock,
|
||||
currentBlock,
|
||||
errorMessageBlock,
|
||||
lastRunBlock,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,86 @@
|
|||
import type { JSX } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { DraggableBlockPlugin_EXPERIMENTAL } from '@lexical/react/LexicalDraggableBlockPlugin'
|
||||
import { RiDraggable } from '@remixicon/react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu'
|
||||
|
||||
function isOnMenu(element: HTMLElement): boolean {
|
||||
return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`)
|
||||
}
|
||||
|
||||
const SUPPORT_DRAG_CLASS = 'support-drag'
|
||||
function checkSupportDrag(element: Element | null): boolean {
|
||||
if (!element)
|
||||
return false
|
||||
|
||||
if (element.classList.contains(SUPPORT_DRAG_CLASS))
|
||||
return true
|
||||
|
||||
if (element.querySelector(`.${SUPPORT_DRAG_CLASS}`))
|
||||
return true
|
||||
|
||||
return !!(element.closest(`.${SUPPORT_DRAG_CLASS}`))
|
||||
}
|
||||
|
||||
export default function DraggableBlockPlugin({
|
||||
anchorElem = document.body,
|
||||
}: {
|
||||
anchorElem?: HTMLElement
|
||||
}): JSX.Element {
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
const targetLineRef = useRef<HTMLDivElement>(null)
|
||||
const [, setDraggableElement] = useState<HTMLElement | null>(
|
||||
null,
|
||||
)
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const [isSupportDrag, setIsSupportDrag] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return
|
||||
|
||||
const onMove = (e: MouseEvent) => {
|
||||
const isSupportDrag = checkSupportDrag(e.target as Element)
|
||||
setIsSupportDrag(isSupportDrag)
|
||||
}
|
||||
|
||||
root.addEventListener('mousemove', onMove)
|
||||
return () => root.removeEventListener('mousemove', onMove)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<DraggableBlockPlugin_EXPERIMENTAL
|
||||
anchorElem={anchorElem}
|
||||
menuRef={menuRef as any}
|
||||
targetLineRef={targetLineRef as any}
|
||||
menuComponent={
|
||||
isSupportDrag
|
||||
? (
|
||||
<div ref={menuRef} className={cn(DRAGGABLE_BLOCK_MENU_CLASSNAME, 'absolute right-2.5 top-4 cursor-grab opacity-0 will-change-transform active:cursor-move')}>
|
||||
<RiDraggable className="size-3.5 text-text-tertiary" />
|
||||
</div>
|
||||
)
|
||||
: null
|
||||
}
|
||||
targetLineComponent={(
|
||||
<div
|
||||
ref={targetLineRef}
|
||||
className="pointer-events-none absolute left-[-21px] top-0 opacity-0 will-change-transform"
|
||||
// style={{ width: 500 }} // width not worked here
|
||||
>
|
||||
<div
|
||||
className="absolute -right-10 left-0 top-0 h-[2px] bg-text-accent-secondary"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
isOnMenu={isOnMenu}
|
||||
onElementChanged={setDraggableElement}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import ActionButton from '../../../action-button'
|
||||
import { VariableX } from '../../../icons/src/vender/workflow'
|
||||
import Modal from '../../../modal'
|
||||
import InputField from './input-field'
|
||||
import VariableBlock from './variable-block'
|
||||
|
||||
type HITLInputComponentUIProps = {
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInput?: FormInputItem
|
||||
onChange: (input: FormInputItem) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponentUI: FC<HITLInputComponentUIProps> = ({
|
||||
nodeId,
|
||||
varName,
|
||||
formInput = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: varName,
|
||||
default: {
|
||||
type: 'constant',
|
||||
selector: [],
|
||||
value: '',
|
||||
},
|
||||
},
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [isShowEditModal, {
|
||||
setTrue: showEditModal,
|
||||
setFalse: hideEditModal,
|
||||
}] = useBoolean(false)
|
||||
|
||||
// Lexical delegate the click make it unable to add click by the method of react
|
||||
const editBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const editBtn = editBtnRef.current
|
||||
if (editBtn)
|
||||
editBtn.addEventListener('click', showEditModal)
|
||||
|
||||
return () => {
|
||||
if (editBtn)
|
||||
editBtn.removeEventListener('click', showEditModal)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const removeBtnRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const removeBtn = removeBtnRef.current
|
||||
const removeHandler = () => onRemove(varName)
|
||||
if (removeBtn)
|
||||
removeBtn.addEventListener('click', removeHandler)
|
||||
|
||||
return () => {
|
||||
if (removeBtn)
|
||||
removeBtn.removeEventListener('click', removeHandler)
|
||||
}
|
||||
}, [onRemove, varName])
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (varName === newPayload.output_variable_name)
|
||||
onChange(newPayload)
|
||||
else
|
||||
onRename(newPayload, varName)
|
||||
hideEditModal()
|
||||
}, [hideEditModal, onChange, onRename, varName])
|
||||
|
||||
const isDefaultValueVariable = useMemo(() => {
|
||||
return formInput.default?.type === 'variable'
|
||||
}, [formInput.default?.type])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="group relative flex h-8 w-full select-none items-center rounded-[8px] border-[1.5px] border-components-input-border-active bg-background-default-hover pl-1.5 pr-0.5"
|
||||
>
|
||||
<div className="absolute left-2.5 top-[-12px]">
|
||||
<div className="absolute bottom-1 h-[1.5px] w-full bg-background-default-subtle"></div>
|
||||
<div className="relative flex items-center space-x-0.5 px-1 text-text-accent-light-mode-only">
|
||||
<VariableX className="size-3" />
|
||||
<div className="system-xs-medium">{varName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full items-center gap-x-0.5 pr-5">
|
||||
<div className="min-w-0 grow">
|
||||
{/* Default Value Info */}
|
||||
{isDefaultValueVariable && (
|
||||
<VariableBlock
|
||||
variables={formInput.default?.selector}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
/>
|
||||
)}
|
||||
{!isDefaultValueVariable && (
|
||||
<div className="system-xs-medium max-w-full truncate text-components-input-text-filled">{formInput.default?.value}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{!readonly && (
|
||||
<div className="hidden h-full shrink-0 items-center space-x-1 group-hover:flex">
|
||||
<div className="flex h-full items-center" ref={editBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiEditLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
<div className="flex h-full items-center" ref={removeBtnRef}>
|
||||
<ActionButton size="s">
|
||||
<RiDeleteBinLine className="size-4 text-text-tertiary" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isShowEditModal && (
|
||||
<Modal
|
||||
isShow
|
||||
onClose={hideEditModal}
|
||||
wrapperClassName="z-[999]"
|
||||
className="max-w-[372px] !p-0"
|
||||
>
|
||||
<InputField
|
||||
nodeId={nodeId}
|
||||
isEdit
|
||||
payload={formInput}
|
||||
onChange={handleChange}
|
||||
onCancel={hideEditModal}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HITLInputComponentUI)
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import type { FC } from 'react'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_HITL_INPUT_BLOCK_COMMAND } from './'
|
||||
import ComponentUi from './component-ui'
|
||||
|
||||
type HITLInputComponentProps = {
|
||||
nodeKey: string
|
||||
nodeId: string
|
||||
varName: string
|
||||
formInputs?: FormInputItem[]
|
||||
onChange: (inputs: FormInputItem[]) => void
|
||||
onRename: (payload: FormInputItem, oldName: string) => void
|
||||
onRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
const HITLInputComponent: FC<HITLInputComponentProps> = ({
|
||||
nodeKey,
|
||||
nodeId,
|
||||
varName,
|
||||
formInputs = [],
|
||||
onChange,
|
||||
onRename,
|
||||
onRemove,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
}) => {
|
||||
const [ref] = useSelectOrDelete(nodeKey, DELETE_HITL_INPUT_BLOCK_COMMAND)
|
||||
const payload = formInputs.find(item => item.output_variable_name === varName)
|
||||
|
||||
const handleChange = useCallback((newPayload: FormInputItem) => {
|
||||
if (!payload) {
|
||||
onChange([...formInputs, newPayload])
|
||||
return
|
||||
}
|
||||
if (payload?.output_variable_name !== newPayload.output_variable_name) {
|
||||
onChange(produce(formInputs, (draft) => {
|
||||
draft.splice(draft.findIndex(item => item.output_variable_name === payload?.output_variable_name), 1, newPayload)
|
||||
}))
|
||||
return
|
||||
}
|
||||
onChange(formInputs.map(item => item.output_variable_name === varName ? newPayload : item))
|
||||
}, [formInputs, onChange, payload, varName])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="w-full pb-1 pt-3"
|
||||
>
|
||||
<ComponentUi
|
||||
nodeId={nodeId}
|
||||
varName={varName}
|
||||
formInput={payload}
|
||||
onChange={handleChange}
|
||||
onRename={onRename}
|
||||
onRemove={onRemove}
|
||||
workflowNodesMap={workflowNodesMap}
|
||||
getVarType={getVarType}
|
||||
environmentVariables={environmentVariables}
|
||||
conversationVariables={conversationVariables}
|
||||
ragVariables={ragVariables}
|
||||
readonly={readonly}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default HITLInputComponent
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
import type { TextNode } from 'lexical'
|
||||
import type { HITLInputBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { HITL_INPUT_REG } from '@/config'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import { $createHITLInputNode, HITLInputNode } from './node'
|
||||
|
||||
const REGEX = new RegExp(HITL_INPUT_REG)
|
||||
|
||||
const HITLInputReplacementBlock = ({
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
variables,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const environmentVariables = useMemo(() => variables?.find(o => o.nodeId === 'env')?.vars || [], [variables])
|
||||
const conversationVariables = useMemo(() => variables?.find(o => o.nodeId === 'conversation')?.vars || [], [variables])
|
||||
const ragVariables = useMemo(() => variables?.reduce<any[]>((acc, curr) => {
|
||||
if (curr.nodeId === 'rag')
|
||||
acc.push(...curr.vars)
|
||||
else
|
||||
acc.push(...curr.vars.filter(v => v.isRagVariable))
|
||||
return acc
|
||||
}, []), [variables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createHITLInputBlockNode = useCallback((textNode: TextNode): HITLInputNode => {
|
||||
const varName = textNode.getTextContent().split('.')[1].replace(/#\}\}$/, '')
|
||||
return $applyNodeReplacement($createHITLInputNode(
|
||||
varName,
|
||||
nodeId,
|
||||
formInputs || [],
|
||||
onFormInputsChange!,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove!,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
))
|
||||
}, [nodeId, formInputs, onFormInputsChange, onFormInputItemRename, onFormInputItemRemove, workflowNodesMap, getVarType, environmentVariables, conversationVariables, ragVariables, readonly])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + matchArr[0].length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createHITLInputBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(HITLInputReplacementBlock)
|
||||
|
|
@ -0,0 +1,106 @@
|
|||
import type { HITLInputBlockType } from '../../types'
|
||||
import type {
|
||||
HITLNodeProps,
|
||||
} from './node'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createHITLInputNode,
|
||||
HITLInputNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_HITL_INPUT_BLOCK_COMMAND = createCommand('INSERT_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const DELETE_HITL_INPUT_BLOCK_COMMAND = createCommand('DELETE_HITL_INPUT_BLOCK_COMMAND')
|
||||
export const UPDATE_WORKFLOW_NODES_MAP = createCommand('UPDATE_WORKFLOW_NODES_MAP')
|
||||
|
||||
export type HITLInputProps = {
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
const HITLInputBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
readonly,
|
||||
}: HITLInputBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
editor.update(() => {
|
||||
editor.dispatchCommand(UPDATE_WORKFLOW_NODES_MAP, workflowNodesMap)
|
||||
})
|
||||
}, [editor, workflowNodesMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputBlockPlugin: HITLInputBlock not registered on editor')
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_HITL_INPUT_BLOCK_COMMAND,
|
||||
(nodeProps: HITLNodeProps) => {
|
||||
const {
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
} = nodeProps
|
||||
const currentHITLNode = $createHITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
readonly,
|
||||
)
|
||||
const prev = new CustomTextNode('\n')
|
||||
$insertNodes([prev])
|
||||
$insertNodes([currentHITLNode])
|
||||
const next = new CustomTextNode('\n')
|
||||
$insertNodes([next])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_HITL_INPUT_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
|
||||
HITLInputBlock.displayName = 'HITLInputBlock'
|
||||
|
||||
export { HITLInputBlock }
|
||||
export { default as HITLInputBlockReplacementBlock } from './hitl-input-block-replacement-block'
|
||||
export { HITLInputNode } from './node'
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import type { FormInputItem, FormInputItemDefault } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { ValueSelector } from '@/app/components/workflow/types'
|
||||
import { produce } from 'immer'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
|
||||
import Button from '../../../button'
|
||||
import PrePopulate from './pre-populate'
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type InputFieldProps = {
|
||||
nodeId: string
|
||||
isEdit: boolean
|
||||
payload?: FormInputItem
|
||||
onChange: (newPayload: FormInputItem) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
const defaultPayload: FormInputItem = {
|
||||
type: InputVarType.paragraph,
|
||||
output_variable_name: '',
|
||||
default: { type: 'constant', selector: [], value: '' },
|
||||
}
|
||||
const InputField: React.FC<InputFieldProps> = ({
|
||||
nodeId,
|
||||
isEdit,
|
||||
payload,
|
||||
onChange,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState(payload || defaultPayload)
|
||||
const nameValid = useMemo(() => {
|
||||
const name = tempPayload.output_variable_name.trim()
|
||||
if (!name)
|
||||
return false
|
||||
if (name.includes(' '))
|
||||
return false
|
||||
return /^[a-z_]\w{0,29}$/.test(name)
|
||||
}, [tempPayload.output_variable_name])
|
||||
const handleSave = useCallback(() => {
|
||||
if (!nameValid)
|
||||
return
|
||||
onChange(tempPayload)
|
||||
}, [nameValid, onChange, tempPayload])
|
||||
const defaultValueConfig = tempPayload.default
|
||||
const handleDefaultValueChange = useCallback((key: keyof FormInputItemDefault) => {
|
||||
return (value: ValueSelector | string) => {
|
||||
const nextValue = produce(tempPayload, (draft) => {
|
||||
if (!draft.default)
|
||||
draft.default = { type: 'constant', selector: [], value: '' }
|
||||
if (key === 'selector') {
|
||||
draft.default.type = 'variable'
|
||||
draft.default.selector = value as ValueSelector
|
||||
}
|
||||
else if (key === 'value') {
|
||||
draft.default.type = 'constant'
|
||||
draft.default.value = value as string
|
||||
}
|
||||
else if (key === 'type') {
|
||||
draft.default.type = value as 'constant' | 'variable'
|
||||
}
|
||||
})
|
||||
setTempPayload(nextValue)
|
||||
}
|
||||
}, [tempPayload])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
handleSave()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [handleSave])
|
||||
|
||||
return (
|
||||
<div className="w-[372px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg backdrop-blur-[5px]">
|
||||
<div className="system-md-semibold text-text-primary">{t(`${i18nPrefix}.title`, { ns: 'workflow' })}</div>
|
||||
<div className="mt-3">
|
||||
<div className="system-xs-medium text-text-secondary">
|
||||
{t(`${i18nPrefix}.saveResponseAs`, { ns: 'workflow' })}
|
||||
<span className="system-xs-regular relative text-text-destructive-secondary">*</span>
|
||||
</div>
|
||||
<Input
|
||||
className="mt-1.5"
|
||||
placeholder={t(`${i18nPrefix}.saveResponseAsPlaceholder`, { ns: 'workflow' })}
|
||||
value={tempPayload.output_variable_name}
|
||||
onChange={(e) => {
|
||||
setTempPayload(prev => ({ ...prev, output_variable_name: e.target.value }))
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
{tempPayload.output_variable_name && !nameValid && (
|
||||
<div className="system-xs-regular mt-1 px-1 text-text-destructive-secondary">
|
||||
{t(`${i18nPrefix}.variableNameInvalid`, { ns: 'workflow' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="system-xs-medium mb-1.5 text-text-secondary">
|
||||
{t(`${i18nPrefix}.prePopulateField`, { ns: 'workflow' })}
|
||||
</div>
|
||||
<PrePopulate
|
||||
isVariable={defaultValueConfig?.type === 'variable'}
|
||||
onIsVariableChange={(isVariable) => {
|
||||
handleDefaultValueChange('type')(isVariable ? 'variable' : 'constant')
|
||||
}}
|
||||
nodeId={nodeId}
|
||||
valueSelector={defaultValueConfig?.selector}
|
||||
onValueSelectorChange={handleDefaultValueChange('selector')}
|
||||
value={defaultValueConfig?.value}
|
||||
onValueChange={handleDefaultValueChange('value')}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end space-x-2">
|
||||
<Button onClick={onCancel}>{t('operation.cancel', { ns: 'common' })}</Button>
|
||||
{isEdit
|
||||
? (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!nameValid}
|
||||
>
|
||||
{t('operation.save', { ns: 'common' })}
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
className="flex"
|
||||
variant="primary"
|
||||
disabled={!nameValid}
|
||||
onClick={handleSave}
|
||||
>
|
||||
<span className="mr-1">{t(`${i18nPrefix}.insert`, { ns: 'workflow' })}</span>
|
||||
<span className="system-kbd mr-0.5 flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">{getKeyboardKeyNameBySystem('ctrl')}</span>
|
||||
<span className=" system-kbd flex h-4 items-center rounded-[4px] bg-components-kbd-bg-white px-1">↩︎</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputField
|
||||
|
|
@ -0,0 +1,272 @@
|
|||
import type { LexicalNode, NodeKey, SerializedLexicalNode } from 'lexical'
|
||||
import type { GetVarType } from '../../types'
|
||||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { FormInputItem } from '@/app/components/workflow/nodes/human-input/types'
|
||||
import type { Var } from '@/app/components/workflow/types'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import HILTInputBlockComponent from './component'
|
||||
|
||||
export type HITLNodeProps = {
|
||||
variableName: string
|
||||
nodeId: string
|
||||
formInputs: FormInputItem[]
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
getVarType?: GetVarType
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode & HITLNodeProps
|
||||
|
||||
export class HITLInputNode extends DecoratorNode<React.JSX.Element> {
|
||||
__variableName: string
|
||||
__nodeId: string
|
||||
__formInputs?: FormInputItem[]
|
||||
__onFormInputsChange: (inputs: FormInputItem[]) => void
|
||||
__onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
__onFormInputItemRemove: (varName: string) => void
|
||||
__workflowNodesMap: WorkflowNodesMap
|
||||
__getVarType?: GetVarType
|
||||
__environmentVariables?: Var[]
|
||||
__conversationVariables?: Var[]
|
||||
__ragVariables?: Var[]
|
||||
__readonly?: boolean
|
||||
|
||||
isIsolated(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
isTopLevel(): boolean {
|
||||
return true // This is necessary for drag-and-drop to work correctly
|
||||
}
|
||||
|
||||
static getType(): string {
|
||||
return 'hitl-input-block'
|
||||
}
|
||||
|
||||
getVariableName(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__variableName
|
||||
}
|
||||
|
||||
getNodeId(): string {
|
||||
const self = this.getLatest()
|
||||
return self.__nodeId
|
||||
}
|
||||
|
||||
getFormInputs(): FormInputItem[] {
|
||||
const self = this.getLatest()
|
||||
return self.__formInputs || []
|
||||
}
|
||||
|
||||
getOnFormInputsChange(): (inputs: FormInputItem[]) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputsChange
|
||||
}
|
||||
|
||||
getOnFormInputItemRename(): (payload: FormInputItem, oldName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRename
|
||||
}
|
||||
|
||||
getOnFormInputItemRemove(): (varName: string) => void {
|
||||
const self = this.getLatest()
|
||||
return self.__onFormInputItemRemove
|
||||
}
|
||||
|
||||
getWorkflowNodesMap(): WorkflowNodesMap {
|
||||
const self = this.getLatest()
|
||||
return self.__workflowNodesMap
|
||||
}
|
||||
|
||||
getGetVarType(): GetVarType | undefined {
|
||||
const self = this.getLatest()
|
||||
return self.__getVarType
|
||||
}
|
||||
|
||||
getEnvironmentVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__environmentVariables || []
|
||||
}
|
||||
|
||||
getConversationVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__conversationVariables || []
|
||||
}
|
||||
|
||||
getRagVariables(): Var[] {
|
||||
const self = this.getLatest()
|
||||
return self.__ragVariables || []
|
||||
}
|
||||
|
||||
getReadonly(): boolean {
|
||||
const self = this.getLatest()
|
||||
return self.__readonly || false
|
||||
}
|
||||
|
||||
static clone(node: HITLInputNode): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
node.__variableName,
|
||||
node.__nodeId,
|
||||
node.__formInputs || [],
|
||||
node.__onFormInputsChange,
|
||||
node.__onFormInputItemRename,
|
||||
node.__onFormInputItemRemove,
|
||||
node.__workflowNodesMap,
|
||||
node.__getVarType,
|
||||
node.__environmentVariables,
|
||||
node.__conversationVariables,
|
||||
node.__ragVariables,
|
||||
node.__readonly,
|
||||
node.__key,
|
||||
)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
constructor(
|
||||
varName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
key?: NodeKey,
|
||||
) {
|
||||
super(key)
|
||||
|
||||
this.__variableName = varName
|
||||
this.__nodeId = nodeId
|
||||
this.__formInputs = formInputs
|
||||
this.__onFormInputsChange = onFormInputsChange
|
||||
this.__onFormInputItemRename = onFormInputItemRename
|
||||
this.__onFormInputItemRemove = onFormInputItemRemove
|
||||
this.__workflowNodesMap = workflowNodesMap
|
||||
this.__getVarType = getVarType
|
||||
this.__environmentVariables = environmentVariables
|
||||
this.__conversationVariables = conversationVariables
|
||||
this.__ragVariables = ragVariables
|
||||
this.__readonly = readonly
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'w-[calc(100%-1px)]', 'items-center', 'align-middle', 'support-drag')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return (
|
||||
<HILTInputBlockComponent
|
||||
nodeKey={this.getKey()}
|
||||
varName={this.getVariableName()}
|
||||
nodeId={this.getNodeId()}
|
||||
formInputs={this.getFormInputs()}
|
||||
onChange={this.getOnFormInputsChange()}
|
||||
onRename={this.getOnFormInputItemRename()}
|
||||
onRemove={this.getOnFormInputItemRemove()}
|
||||
workflowNodesMap={this.getWorkflowNodesMap()}
|
||||
getVarType={this.getGetVarType()}
|
||||
environmentVariables={this.getEnvironmentVariables()}
|
||||
conversationVariables={this.getConversationVariables()}
|
||||
ragVariables={this.getRagVariables()}
|
||||
readonly={this.getReadonly()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
static importJSON(serializedNode: SerializedNode): HITLInputNode {
|
||||
const node = $createHITLInputNode(
|
||||
serializedNode.variableName,
|
||||
serializedNode.nodeId,
|
||||
serializedNode.formInputs,
|
||||
serializedNode.onFormInputsChange,
|
||||
serializedNode.onFormInputItemRename,
|
||||
serializedNode.onFormInputItemRemove,
|
||||
serializedNode.workflowNodesMap,
|
||||
serializedNode.getVarType,
|
||||
serializedNode.environmentVariables,
|
||||
serializedNode.conversationVariables,
|
||||
serializedNode.ragVariables,
|
||||
serializedNode.readonly,
|
||||
)
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'hitl-input-block',
|
||||
version: 1,
|
||||
variableName: this.getVariableName(),
|
||||
nodeId: this.getNodeId(),
|
||||
formInputs: this.getFormInputs(),
|
||||
onFormInputsChange: this.getOnFormInputsChange(),
|
||||
onFormInputItemRename: this.getOnFormInputItemRename(),
|
||||
onFormInputItemRemove: this.getOnFormInputItemRemove(),
|
||||
workflowNodesMap: this.getWorkflowNodesMap(),
|
||||
getVarType: this.getGetVarType(),
|
||||
environmentVariables: this.getEnvironmentVariables(),
|
||||
conversationVariables: this.getConversationVariables(),
|
||||
ragVariables: this.getRagVariables(),
|
||||
readonly: this.getReadonly(),
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return `{{#$output.${this.getVariableName()}#}}`
|
||||
}
|
||||
}
|
||||
|
||||
export function $createHITLInputNode(
|
||||
variableName: string,
|
||||
nodeId: string,
|
||||
formInputs: FormInputItem[],
|
||||
onFormInputsChange: (inputs: FormInputItem[]) => void,
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void,
|
||||
onFormInputItemRemove: (varName: string) => void,
|
||||
workflowNodesMap: WorkflowNodesMap,
|
||||
getVarType?: GetVarType,
|
||||
environmentVariables?: Var[],
|
||||
conversationVariables?: Var[],
|
||||
ragVariables?: Var[],
|
||||
readonly?: boolean,
|
||||
): HITLInputNode {
|
||||
return new HITLInputNode(
|
||||
variableName,
|
||||
nodeId,
|
||||
formInputs,
|
||||
onFormInputsChange,
|
||||
onFormInputItemRename,
|
||||
onFormInputItemRemove,
|
||||
workflowNodesMap,
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
readonly,
|
||||
)
|
||||
}
|
||||
|
||||
export function $isHITLInputNode(
|
||||
node: HITLInputNode | LexicalNode | null | undefined,
|
||||
): node is HITLInputNode {
|
||||
return node instanceof HITLInputNode
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import Textarea from '../../../textarea'
|
||||
import TagLabel from './tag-label'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
type Props = {
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
nodeId: string
|
||||
valueSelector?: ValueSelector
|
||||
onValueSelectorChange?: (valueSelector: ValueSelector | string) => void
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const i18nPrefix = 'nodes.humanInput.insertInputField'
|
||||
|
||||
type PlaceholderProps = {
|
||||
varPickerProps: {
|
||||
nodeId: string
|
||||
value: ValueSelector
|
||||
onChange: (valueSelector: ValueSelector | string) => void
|
||||
readonly: boolean
|
||||
zIndex: number
|
||||
filterVar: (varPayload: Var) => boolean
|
||||
isJustShowValue?: boolean
|
||||
}
|
||||
onTypeClick: (isVariable: boolean) => void
|
||||
}
|
||||
const Placeholder = ({
|
||||
varPickerProps,
|
||||
onTypeClick,
|
||||
}: PlaceholderProps) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className="system-sm-regular mt-1 h-[80px] rounded-lg bg-components-input-bg-normal px-3 pt-2 text-text-tertiary">
|
||||
<div className="flex flex-wrap items-center leading-5">
|
||||
<Trans
|
||||
i18nKey={`${i18nPrefix}.prePopulateFieldPlaceholder`}
|
||||
ns="workflow"
|
||||
components={{
|
||||
staticContent: <TagLabel type="edit" className="mx-1" onClick={() => onTypeClick(false)}>{t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}</TagLabel>,
|
||||
variable: (
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
trigger={
|
||||
<TagLabel type="variable" className="mx-1">{t(`${i18nPrefix}.variable`, { ns: 'workflow' })}</TagLabel>
|
||||
}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const PrePopulate: FC<Props> = ({
|
||||
isVariable = false,
|
||||
onIsVariableChange,
|
||||
nodeId,
|
||||
valueSelector,
|
||||
onValueSelectorChange,
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
|
||||
const handleTypeChange = useCallback((isVar: boolean) => {
|
||||
setOnPlaceholderClicked(true)
|
||||
onIsVariableChange?.(isVar)
|
||||
}, [onIsVariableChange])
|
||||
|
||||
const [isFocus, setIsFocus] = useState(false)
|
||||
|
||||
const varPickerProps = {
|
||||
nodeId,
|
||||
value: valueSelector || [],
|
||||
onChange: onValueSelectorChange!,
|
||||
readonly: false,
|
||||
zIndex: 1000000, // bigger than shortcut plugin popup
|
||||
filterVar: (varPayload: Var) => {
|
||||
return [VarType.string, VarType.number, VarType.secret].includes(varPayload.type)
|
||||
},
|
||||
}
|
||||
|
||||
const isShowPlaceholder = !onPlaceholderClicked && (isVariable ? (!valueSelector || valueSelector.length === 0) : !value)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Tab' && !onPlaceholderClicked) {
|
||||
e.preventDefault()
|
||||
setOnPlaceholderClicked(true)
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [onPlaceholderClicked, setOnPlaceholderClicked])
|
||||
|
||||
if (isShowPlaceholder)
|
||||
return <Placeholder varPickerProps={varPickerProps} onTypeClick={handleTypeChange} />
|
||||
|
||||
if (isVariable) {
|
||||
return (
|
||||
<div className="relative h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal px-3 pt-2">
|
||||
<VarReferencePicker
|
||||
{...varPickerProps}
|
||||
isJustShowValue
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
|
||||
<Textarea
|
||||
value={value || ''}
|
||||
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
onChange={e => onValueChange?.(e.target.value)}
|
||||
onFocus={() => {
|
||||
setOnPlaceholderClicked(true)
|
||||
setIsFocus(true)
|
||||
}}
|
||||
onBlur={() => setIsFocus(false)}
|
||||
autoFocus
|
||||
/>
|
||||
<TypeSwitch
|
||||
className="absolute bottom-1 left-1.5"
|
||||
isVariable={isVariable}
|
||||
onIsVariableChange={handleTypeChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(PrePopulate)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
type: 'edit' | 'variable'
|
||||
children: string
|
||||
className?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const TagLabel: FC<Props> = ({
|
||||
type,
|
||||
children,
|
||||
className,
|
||||
onClick,
|
||||
}) => {
|
||||
const Icon = type === 'edit' ? RiEditLine : Variable02
|
||||
return (
|
||||
<div
|
||||
className={cn('inline-flex h-5 cursor-pointer items-center space-x-1 rounded-md bg-components-button-secondary-bg px-1 text-text-accent', className)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className="size-3.5" />
|
||||
<div className="system-xs-medium ">{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TagLabel)
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { Variable02 } from '../../../icons/src/vender/solid/development'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
isVariable?: boolean
|
||||
onIsVariableChange?: (isVariable: boolean) => void
|
||||
}
|
||||
|
||||
const TypeSwitch: FC<Props> = ({
|
||||
className,
|
||||
isVariable,
|
||||
onIsVariableChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('inline-flex h-6 cursor-pointer select-none items-center space-x-1 rounded-md pl-1.5 pr-2 text-text-tertiary hover:bg-components-button-ghost-bg-hover', className)} onClick={() => onIsVariableChange?.(!isVariable)}>
|
||||
<Variable02 className="size-3.5" />
|
||||
<div className="system-xs-medium">{t(`nodes.humanInput.insertInputField.${isVariable ? 'useConstantInstead' : 'useVarInstead'}`, { ns: 'workflow' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(TypeSwitch)
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
import type { WorkflowNodesMap } from '../workflow-variable-block/node'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
isConversationVar,
|
||||
isENV,
|
||||
isGlobalVar,
|
||||
isRagVariableVar,
|
||||
isSystemVar,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
import VarFullPathPanel from '@/app/components/workflow/nodes/_base/components/variable/var-full-path-panel'
|
||||
import {
|
||||
VariableLabelInEditor,
|
||||
} from '@/app/components/workflow/nodes/_base/components/variable/variable-label'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { isExceptionVariable } from '@/app/components/workflow/utils'
|
||||
import { UPDATE_WORKFLOW_NODES_MAP } from '../workflow-variable-block'
|
||||
import { HITLInputNode } from './node'
|
||||
|
||||
type HITLInputVariableBlockComponentProps = {
|
||||
variables: string[]
|
||||
workflowNodesMap: WorkflowNodesMap
|
||||
environmentVariables?: Var[]
|
||||
conversationVariables?: Var[]
|
||||
ragVariables?: Var[]
|
||||
getVarType?: (payload: {
|
||||
nodeId: string
|
||||
valueSelector: ValueSelector
|
||||
}) => Type
|
||||
}
|
||||
|
||||
const HITLInputVariableBlockComponent = ({
|
||||
variables,
|
||||
workflowNodesMap = {},
|
||||
getVarType,
|
||||
environmentVariables,
|
||||
conversationVariables,
|
||||
ragVariables,
|
||||
}: HITLInputVariableBlockComponentProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const variablesLength = variables.length
|
||||
const isRagVar = isRagVariableVar(variables)
|
||||
const isShowAPart = variablesLength > 2 && !isRagVar
|
||||
const varName = (
|
||||
() => {
|
||||
const isSystem = isSystemVar(variables)
|
||||
const varName = variables[variablesLength - 1]
|
||||
return `${isSystem ? 'sys.' : ''}${varName}`
|
||||
}
|
||||
)()
|
||||
const [localWorkflowNodesMap, setLocalWorkflowNodesMap] = useState<WorkflowNodesMap>(workflowNodesMap)
|
||||
const node = localWorkflowNodesMap![variables[isRagVar ? 1 : 0]]
|
||||
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
const variableValid = useMemo(() => {
|
||||
let variableValid = true
|
||||
const isEnv = isENV(variables)
|
||||
const isChatVar = isConversationVar(variables)
|
||||
const isGlobal = isGlobalVar(variables)
|
||||
if (isGlobal)
|
||||
return true
|
||||
|
||||
if (isEnv) {
|
||||
if (environmentVariables)
|
||||
variableValid = environmentVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isChatVar) {
|
||||
if (conversationVariables)
|
||||
variableValid = conversationVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}`)
|
||||
}
|
||||
else if (isRagVar) {
|
||||
if (ragVariables)
|
||||
variableValid = ragVariables.some(v => v.variable === `${variables?.[0] ?? ''}.${variables?.[1] ?? ''}.${variables?.[2] ?? ''}`)
|
||||
}
|
||||
else {
|
||||
variableValid = !!node
|
||||
}
|
||||
return variableValid
|
||||
}, [variables, node, environmentVariables, conversationVariables, isRagVar, ragVariables])
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([HITLInputNode]))
|
||||
throw new Error('HITLInputNodePlugin: HITLInputNode not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
UPDATE_WORKFLOW_NODES_MAP,
|
||||
(workflowNodesMap: WorkflowNodesMap) => {
|
||||
setLocalWorkflowNodesMap(workflowNodesMap)
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const Item = (
|
||||
<VariableLabelInEditor
|
||||
nodeType={node?.type}
|
||||
nodeTitle={node?.title}
|
||||
variables={variables}
|
||||
isExceptionVariable={isException}
|
||||
errorMsg={!variableValid ? t('errorMsg.invalidVariable', { ns: 'workflow' }) : undefined}
|
||||
notShowFullPath={isShowAPart}
|
||||
/>
|
||||
)
|
||||
|
||||
if (!node)
|
||||
return Item
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
noDecoration
|
||||
popupContent={(
|
||||
<VarFullPathPanel
|
||||
nodeName={node.title}
|
||||
path={variables.slice(1)}
|
||||
varType={getVarType
|
||||
? getVarType({
|
||||
nodeId: variables[0],
|
||||
valueSelector: variables,
|
||||
})
|
||||
: Type.string}
|
||||
nodeType={node?.type}
|
||||
/>
|
||||
)}
|
||||
disabled={!isShowAPart}
|
||||
>
|
||||
<div>{Item}</div>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(HITLInputVariableBlockComponent)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { FC } from 'react'
|
||||
import { RiGlobalLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import { DELETE_REQUEST_URL_BLOCK_COMMAND } from './index'
|
||||
|
||||
type RequestURLBlockComponentProps = {
|
||||
nodeKey: string
|
||||
}
|
||||
|
||||
const RequestURLBlockComponent: FC<RequestURLBlockComponentProps> = ({
|
||||
nodeKey,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [ref, isSelected] = useSelectOrDelete(nodeKey, DELETE_REQUEST_URL_BLOCK_COMMAND)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group/wrap relative mx-0.5 flex h-[18px] select-none items-center rounded-[5px] border border-components-panel-border-subtle bg-components-badge-white-to-dark px-1 hover:border-[#7839ee]',
|
||||
isSelected && '!border-[#7839ee] hover:!border-[#7839ee]',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<RiGlobalLine className="mr-0.5 h-3.5 w-3.5 text-util-colors-violet-violet-600" />
|
||||
<div className="system-xs-medium text-util-colors-violet-violet-600">{t('promptEditor.requestURL.item.title', { ns: 'common' })}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default RequestURLBlockComponent
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import {
|
||||
$insertNodes,
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
createCommand,
|
||||
} from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from './node'
|
||||
|
||||
export const INSERT_REQUEST_URL_BLOCK_COMMAND = createCommand('INSERT_REQUEST_URL_BLOCK_COMMAND')
|
||||
export const DELETE_REQUEST_URL_BLOCK_COMMAND = createCommand('DELETE_REQUEST_URL_BLOCK_COMMAND')
|
||||
|
||||
const RequestURLBlock = memo(({
|
||||
onInsert,
|
||||
onDelete,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockPlugin: RequestURLBlock not registered on editor')
|
||||
|
||||
return mergeRegister(
|
||||
editor.registerCommand(
|
||||
INSERT_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
const contextBlockNode = $createRequestURLBlockNode()
|
||||
|
||||
$insertNodes([contextBlockNode])
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
editor.registerCommand(
|
||||
DELETE_REQUEST_URL_BLOCK_COMMAND,
|
||||
() => {
|
||||
if (onDelete)
|
||||
onDelete()
|
||||
|
||||
return true
|
||||
},
|
||||
COMMAND_PRIORITY_EDITOR,
|
||||
),
|
||||
)
|
||||
}, [editor, onInsert, onDelete])
|
||||
|
||||
return null
|
||||
})
|
||||
RequestURLBlock.displayName = 'RequestURLBlock'
|
||||
|
||||
export { RequestURLBlock }
|
||||
export { RequestURLBlockNode } from './node'
|
||||
export { default as RequestURLBlockReplacementBlock } from './request-url-block-replacement-block'
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { LexicalNode, SerializedLexicalNode } from 'lexical'
|
||||
import { DecoratorNode } from 'lexical'
|
||||
import RequestURLBlockComponent from './component'
|
||||
|
||||
export type SerializedNode = SerializedLexicalNode
|
||||
|
||||
export class RequestURLBlockNode extends DecoratorNode<React.JSX.Element> {
|
||||
static getType(): string {
|
||||
return 'request-url-block'
|
||||
}
|
||||
|
||||
static clone(node: RequestURLBlockNode): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode(node.__key)
|
||||
}
|
||||
|
||||
isInline(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
createDOM(): HTMLElement {
|
||||
const div = document.createElement('div')
|
||||
div.classList.add('inline-flex', 'items-center', 'align-middle')
|
||||
return div
|
||||
}
|
||||
|
||||
updateDOM(): false {
|
||||
return false
|
||||
}
|
||||
|
||||
decorate(): React.JSX.Element {
|
||||
return <RequestURLBlockComponent nodeKey={this.getKey()} />
|
||||
}
|
||||
|
||||
static importJSON(): RequestURLBlockNode {
|
||||
const node = $createRequestURLBlockNode()
|
||||
|
||||
return node
|
||||
}
|
||||
|
||||
exportJSON(): SerializedNode {
|
||||
return {
|
||||
type: 'request-url-block',
|
||||
version: 1,
|
||||
}
|
||||
}
|
||||
|
||||
getTextContent(): string {
|
||||
return '{{#url#}}'
|
||||
}
|
||||
}
|
||||
export function $createRequestURLBlockNode(): RequestURLBlockNode {
|
||||
return new RequestURLBlockNode()
|
||||
}
|
||||
|
||||
export function $isRequestURLBlockNode(
|
||||
node: RequestURLBlockNode | LexicalNode | null | undefined,
|
||||
): node is RequestURLBlockNode {
|
||||
return node instanceof RequestURLBlockNode
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import type { RequestURLBlockType } from '../../types'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { mergeRegister } from '@lexical/utils'
|
||||
import { $applyNodeReplacement } from 'lexical'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from 'react'
|
||||
import { REQUEST_URL_PLACEHOLDER_TEXT } from '../../constants'
|
||||
import { decoratorTransform } from '../../utils'
|
||||
import { CustomTextNode } from '../custom-text/node'
|
||||
import {
|
||||
$createRequestURLBlockNode,
|
||||
RequestURLBlockNode,
|
||||
} from '../request-url-block/node'
|
||||
|
||||
const REGEX = new RegExp(REQUEST_URL_PLACEHOLDER_TEXT)
|
||||
|
||||
const RequestURLBlockReplacementBlock = ({
|
||||
onInsert,
|
||||
}: RequestURLBlockType) => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([RequestURLBlockNode]))
|
||||
throw new Error('RequestURLBlockNodePlugin: RequestURLBlockNode not registered on editor')
|
||||
}, [editor])
|
||||
|
||||
const createRequestURLBlockNode = useCallback((): RequestURLBlockNode => {
|
||||
if (onInsert)
|
||||
onInsert()
|
||||
return $applyNodeReplacement($createRequestURLBlockNode())
|
||||
}, [onInsert])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
||||
if (matchArr === null)
|
||||
return null
|
||||
|
||||
const startOffset = matchArr.index
|
||||
const endOffset = startOffset + REQUEST_URL_PLACEHOLDER_TEXT.length
|
||||
return {
|
||||
end: endOffset,
|
||||
start: startOffset,
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
REGEX.lastIndex = 0
|
||||
return mergeRegister(
|
||||
editor.registerNodeTransform(CustomTextNode, textNode => decoratorTransform(textNode, getMatch, createRequestURLBlockNode)),
|
||||
)
|
||||
}, [])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export default memo(RequestURLBlockReplacementBlock)
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
import { LexicalComposer } from '@lexical/react/LexicalComposer'
|
||||
import { ContentEditable } from '@lexical/react/LexicalContentEditable'
|
||||
import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'
|
||||
import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import ShortcutsPopupPlugin, { SHORTCUTS_EMPTY_CONTENT } from './index'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Range.getClientRects and getBoundingClientRect for JSDOM
|
||||
const mockDOMRect = {
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 100,
|
||||
height: 20,
|
||||
top: 100,
|
||||
right: 200,
|
||||
bottom: 120,
|
||||
left: 100,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
// Mock getClientRects on Range prototype
|
||||
Range.prototype.getClientRects = vi.fn(() => {
|
||||
const rectList = [mockDOMRect] as unknown as DOMRectList
|
||||
Object.defineProperty(rectList, 'length', { value: 1 })
|
||||
Object.defineProperty(rectList, 'item', { value: (index: number) => index === 0 ? mockDOMRect : null })
|
||||
return rectList
|
||||
})
|
||||
|
||||
// Mock getBoundingClientRect on Range prototype
|
||||
Range.prototype.getBoundingClientRect = vi.fn(() => mockDOMRect as DOMRect)
|
||||
})
|
||||
|
||||
const CONTAINER_ID = 'host'
|
||||
const CONTENT_EDITABLE_ID = 'ce'
|
||||
|
||||
const MinimalEditor: React.FC<{
|
||||
withContainer?: boolean
|
||||
}> = ({ withContainer = true }) => {
|
||||
const initialConfig = {
|
||||
namespace: 'shortcuts-popup-plugin-test',
|
||||
onError: (e: Error) => {
|
||||
throw e
|
||||
},
|
||||
}
|
||||
const [containerEl, setContainerEl] = useState<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<LexicalComposer initialConfig={initialConfig}>
|
||||
<div data-testid={CONTAINER_ID} className="relative" ref={withContainer ? setContainerEl : undefined}>
|
||||
<RichTextPlugin
|
||||
contentEditable={<ContentEditable data-testid={CONTENT_EDITABLE_ID} />}
|
||||
placeholder={null}
|
||||
ErrorBoundary={LexicalErrorBoundary}
|
||||
/>
|
||||
<ShortcutsPopupPlugin
|
||||
container={withContainer ? containerEl : undefined}
|
||||
/>
|
||||
</div>
|
||||
</LexicalComposer>
|
||||
)
|
||||
}
|
||||
|
||||
describe('ShortcutsPopupPlugin', () => {
|
||||
it('opens on hotkey when editor is focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true }) // 模拟 Ctrl+/
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not open when editor is not focused', async () => {
|
||||
render(<MinimalEditor />)
|
||||
// 未聚焦
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on Escape', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('closes on click outside', async () => {
|
||||
render(<MinimalEditor />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
expect(await screen.findByText(SHORTCUTS_EMPTY_CONTENT)).toBeInTheDocument()
|
||||
|
||||
fireEvent.mouseDown(ce)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText(SHORTCUTS_EMPTY_CONTENT)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('portals into provided container when container is set', async () => {
|
||||
render(<MinimalEditor withContainer />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
const host = screen.getByTestId(CONTAINER_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(host).toContainElement(portalContent)
|
||||
})
|
||||
|
||||
it('falls back to document.body when container is not provided', async () => {
|
||||
render(<MinimalEditor withContainer={false} />)
|
||||
const ce = screen.getByTestId(CONTENT_EDITABLE_ID)
|
||||
ce.focus()
|
||||
|
||||
fireEvent.keyDown(document, { key: '/', ctrlKey: true })
|
||||
const portalContent = await screen.findByText(SHORTCUTS_EMPTY_CONTENT)
|
||||
expect(document.body).toContainElement(portalContent)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
import type { LexicalCommand } from 'lexical'
|
||||
import {
|
||||
autoUpdate,
|
||||
flip,
|
||||
offset,
|
||||
shift,
|
||||
size,
|
||||
useFloating,
|
||||
} from '@floating-ui/react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import {
|
||||
$getSelection,
|
||||
$isRangeSelection,
|
||||
} from 'lexical'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { cn } from '@/utils/classnames'
|
||||
|
||||
export const SHORTCUTS_EMPTY_CONTENT = 'shortcuts_empty_content'
|
||||
|
||||
// Hotkey can be:
|
||||
// - string: 'mod+/'
|
||||
// - string[]: ['mod', '/']
|
||||
// - string[][]: [['mod', '/'], ['mod', 'shift', '/']] (any combo matches)
|
||||
// - function: custom matcher
|
||||
export type Hotkey = string | string[] | string[][] | ((e: KeyboardEvent) => boolean)
|
||||
|
||||
type ShortcutPopupPluginProps = {
|
||||
hotkey?: Hotkey
|
||||
children?: React.ReactNode | ((close: () => void, onInsert: (command: LexicalCommand<unknown>, params: any[]) => void) => React.ReactNode)
|
||||
className?: string
|
||||
container?: Element | null
|
||||
onOpen?: () => void
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const META_ALIASES = new Set(['meta', 'cmd', 'command'])
|
||||
const CTRL_ALIASES = new Set(['ctrl'])
|
||||
const ALT_ALIASES = new Set(['alt', 'option'])
|
||||
const SHIFT_ALIASES = new Set(['shift'])
|
||||
|
||||
function matchHotkey(event: KeyboardEvent, hotkey?: Hotkey) {
|
||||
if (!hotkey)
|
||||
return false
|
||||
|
||||
if (typeof hotkey === 'function')
|
||||
return hotkey(event)
|
||||
|
||||
const matchCombo = (tokens: string[]) => {
|
||||
const parts = tokens.map(t => t.toLowerCase().trim()).filter(Boolean)
|
||||
let expectedKey: string | null = null
|
||||
|
||||
let needMod = false
|
||||
let needCtrl = false
|
||||
let needMeta = false
|
||||
let needAlt = false
|
||||
let needShift = false
|
||||
|
||||
for (const p of parts) {
|
||||
if (p === 'mod') {
|
||||
needMod = true
|
||||
continue
|
||||
}
|
||||
if (CTRL_ALIASES.has(p)) {
|
||||
needCtrl = true
|
||||
continue
|
||||
}
|
||||
if (META_ALIASES.has(p)) {
|
||||
needMeta = true
|
||||
continue
|
||||
}
|
||||
if (ALT_ALIASES.has(p)) {
|
||||
needAlt = true
|
||||
continue
|
||||
}
|
||||
if (SHIFT_ALIASES.has(p)) {
|
||||
needShift = true
|
||||
continue
|
||||
}
|
||||
expectedKey = p
|
||||
}
|
||||
|
||||
if (needMod && !(event.metaKey || event.ctrlKey))
|
||||
return false
|
||||
if (needCtrl && !event.ctrlKey)
|
||||
return false
|
||||
if (needMeta && !event.metaKey)
|
||||
return false
|
||||
if (needAlt && !event.altKey)
|
||||
return false
|
||||
if (needShift && !event.shiftKey)
|
||||
return false
|
||||
|
||||
if (expectedKey) {
|
||||
const k = event.key.toLowerCase()
|
||||
const normalized = k === ' ' ? 'space' : k
|
||||
if (normalized !== expectedKey)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (Array.isArray(hotkey)) {
|
||||
const isNested = hotkey.length > 0 && Array.isArray((hotkey as unknown[])[0])
|
||||
if (isNested) {
|
||||
const combos = hotkey as string[][]
|
||||
return combos.some(tokens => matchCombo(tokens))
|
||||
}
|
||||
else {
|
||||
const tokens = hotkey as string[]
|
||||
return matchCombo(tokens)
|
||||
}
|
||||
}
|
||||
|
||||
const tokensFromString = hotkey
|
||||
.toLowerCase()
|
||||
.split('+')
|
||||
.map(t => t.trim())
|
||||
.filter(Boolean)
|
||||
return matchCombo(tokensFromString)
|
||||
}
|
||||
|
||||
export default function ShortcutsPopupPlugin({
|
||||
hotkey = 'mod+/',
|
||||
children,
|
||||
className,
|
||||
container,
|
||||
onOpen,
|
||||
onClose,
|
||||
}: ShortcutPopupPluginProps): React.ReactPortal | null {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
const portalRef = useRef<HTMLDivElement | null>(null)
|
||||
const lastSelectionRef = useRef<Range | null>(null)
|
||||
|
||||
const containerEl = useMemo(() => container ?? (typeof document !== 'undefined' ? document.body : null), [container])
|
||||
const useContainer = !!containerEl && containerEl !== document.body
|
||||
|
||||
const { refs, floatingStyles, isPositioned } = useFloating({
|
||||
placement: 'bottom-start',
|
||||
middleware: [
|
||||
offset(0), // fix hide cursor
|
||||
shift({
|
||||
padding: 8,
|
||||
altBoundary: true,
|
||||
}),
|
||||
flip(),
|
||||
size({
|
||||
apply({ availableWidth, availableHeight, elements }) {
|
||||
Object.assign(elements.floating.style, {
|
||||
maxWidth: `${Math.min(400, availableWidth)}px`,
|
||||
maxHeight: `${Math.min(300, availableHeight)}px`,
|
||||
overflow: 'auto',
|
||||
})
|
||||
},
|
||||
padding: 8,
|
||||
}),
|
||||
],
|
||||
whileElementsMounted: autoUpdate,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return editor.registerUpdateListener(({ editorState }) => {
|
||||
editorState.read(() => {
|
||||
const selection = $getSelection()
|
||||
if ($isRangeSelection(selection)) {
|
||||
const domSelection = window.getSelection()
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
lastSelectionRef.current = domSelection.getRangeAt(0).cloneRange()
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [editor])
|
||||
|
||||
const isEditorFocused = useCallback(() => {
|
||||
const root = editor.getRootElement()
|
||||
if (!root)
|
||||
return false
|
||||
return root.contains(document.activeElement)
|
||||
}, [editor])
|
||||
|
||||
const openPortal = useCallback(() => {
|
||||
const domSelection = window.getSelection()
|
||||
let range: Range | null = null
|
||||
if (domSelection && domSelection.rangeCount > 0)
|
||||
range = domSelection.getRangeAt(0).cloneRange()
|
||||
else
|
||||
range = lastSelectionRef.current
|
||||
|
||||
if (range) {
|
||||
const rects = range.getClientRects()
|
||||
let rect: DOMRect | null = null
|
||||
|
||||
if (rects && rects.length)
|
||||
rect = rects[rects.length - 1]
|
||||
|
||||
else
|
||||
rect = range.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0) {
|
||||
const root = editor.getRootElement()
|
||||
if (root) {
|
||||
const sc = range.startContainer
|
||||
const node = sc.nodeType === Node.ELEMENT_NODE
|
||||
? sc as Element
|
||||
: (sc.parentElement || root)
|
||||
|
||||
rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.width === 0 && rect.height === 0)
|
||||
rect = root.getBoundingClientRect()
|
||||
}
|
||||
}
|
||||
|
||||
if (rect && !(rect.top === 0 && rect.left === 0 && rect.width === 0 && rect.height === 0)) {
|
||||
const virtualEl = {
|
||||
getBoundingClientRect() {
|
||||
return rect!
|
||||
},
|
||||
}
|
||||
refs.setReference(virtualEl as Element)
|
||||
}
|
||||
}
|
||||
|
||||
setOpen(true)
|
||||
onOpen?.()
|
||||
}, [onOpen])
|
||||
|
||||
const closePortal = useCallback(() => {
|
||||
setOpen(false)
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (open && event.key === 'Escape') {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
closePortal()
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEditorFocused())
|
||||
return
|
||||
|
||||
if (matchHotkey(event, hotkey)) {
|
||||
event.preventDefault()
|
||||
openPortal()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown, true)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown, true)
|
||||
}, [hotkey, open, isEditorFocused, openPortal, closePortal])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open)
|
||||
return
|
||||
|
||||
const onMouseDown = (e: MouseEvent) => {
|
||||
if (!portalRef.current)
|
||||
return
|
||||
if (!portalRef.current.contains(e.target as Node))
|
||||
closePortal()
|
||||
}
|
||||
document.addEventListener('mousedown', onMouseDown, false)
|
||||
return () => document.removeEventListener('mousedown', onMouseDown, false)
|
||||
}, [open, closePortal])
|
||||
|
||||
const handleInsert = useCallback((command: LexicalCommand<unknown>, params: any) => {
|
||||
editor.dispatchCommand(command, params)
|
||||
closePortal()
|
||||
}, [editor, closePortal])
|
||||
|
||||
if (!open || !containerEl)
|
||||
return null
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={(node) => {
|
||||
portalRef.current = node
|
||||
refs.setFloating(node)
|
||||
}}
|
||||
className={cn(
|
||||
useContainer ? '' : 'z-[999999]',
|
||||
'absolute rounded-xl bg-components-panel-bg-blur shadow-lg',
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
...floatingStyles,
|
||||
visibility: isPositioned ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{typeof children === 'function' ? children(closePortal, handleInsert) : (children ?? SHORTCUTS_EMPTY_CONTENT)}
|
||||
</div>,
|
||||
containerEl,
|
||||
)
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@ const WorkflowVariableBlockReplacementBlock = ({
|
|||
|
||||
const nodePathString = textNode.getTextContent().slice(3, -3)
|
||||
return $applyNodeReplacement($createWorkflowVariableBlockNode(nodePathString.split('.'), workflowNodesMap, getVarType, variables?.find(o => o.nodeId === 'env')?.vars || [], variables?.find(o => o.nodeId === 'conversation')?.vars || [], ragVariables))
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables])
|
||||
}, [onInsert, workflowNodesMap, getVarType, variables, ragVariables])
|
||||
|
||||
const getMatch = useCallback((text: string) => {
|
||||
const matchArr = REGEX.exec(text)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { GeneratorType } from '../../app/configuration/config/automatic/types'
|
||||
import type { FormInputItem } from '../../workflow/nodes/human-input/types'
|
||||
import type { Type } from '../../workflow/nodes/llm/types'
|
||||
import type { Dataset } from './plugins/context-block'
|
||||
import type { RoleName } from './plugins/history-block'
|
||||
|
|
@ -46,6 +47,13 @@ export type HistoryBlockType = {
|
|||
onEditRole?: () => void
|
||||
}
|
||||
|
||||
export type RequestURLBlockType = {
|
||||
show?: boolean
|
||||
selectable?: boolean
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
}
|
||||
|
||||
export type VariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: Option[]
|
||||
|
|
@ -73,6 +81,21 @@ export type WorkflowVariableBlockType = {
|
|||
onManageInputField?: () => void
|
||||
}
|
||||
|
||||
export type HITLInputBlockType = {
|
||||
show?: boolean
|
||||
nodeId: string
|
||||
formInputs?: FormInputItem[]
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
getVarType?: GetVarType
|
||||
onFormInputsChange?: (inputs: FormInputItem[]) => void
|
||||
onFormInputItemRemove: (varName: string) => void
|
||||
onFormInputItemRename: (payload: FormInputItem, oldName: string) => void
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
readonly?: boolean
|
||||
}
|
||||
|
||||
export type MenuTextMatch = {
|
||||
leadOffset: number
|
||||
matchingString: string
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { EDUCATION_VERIFYING_LOCALSTORAGE_ITEM } from '@/app/education-apply/constants'
|
||||
import { Plan } from '../type'
|
||||
import PlanComp from './index'
|
||||
|
|
@ -188,7 +188,9 @@ describe('PlanComp', () => {
|
|||
expect(lastCall.onCancel).toBeDefined()
|
||||
|
||||
// Call onConfirm to close modal
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
act(() => {
|
||||
lastCall.onConfirm()
|
||||
lastCall.onCancel()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -118,6 +118,7 @@ export type CurrentPlanInfoBackend = {
|
|||
knowledge_pipeline: {
|
||||
publish_enabled: boolean
|
||||
}
|
||||
human_input_email_delivery_enabled: boolean
|
||||
}
|
||||
|
||||
export type SubscriptionItem = {
|
||||
|
|
|
|||
|
|
@ -94,6 +94,7 @@ describe('billing utils', () => {
|
|||
knowledge_pipeline: {
|
||||
publish_enabled: false,
|
||||
},
|
||||
human_input_email_delivery_enabled: false,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1247,6 +1247,10 @@ describe('CommonCreateModal', () => {
|
|||
const input = screen.getByTestId('form-field-webhook_url')
|
||||
fireEvent.change(input, { target: { value: 'https://example.com/webhook' } })
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateBuilder).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockToastNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
|
|
|
|||
|
|
@ -295,6 +295,8 @@ vi.mock('@/utils/var', () => ({
|
|||
// Mock provider context
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => createMockProviderContextValue(),
|
||||
useProviderContextSelector: <T,>(selector: (state: ReturnType<typeof createMockProviderContextValue>) => T): T =>
|
||||
selector(createMockProviderContextValue()),
|
||||
}))
|
||||
|
||||
// Mock WorkflowWithInnerContext
|
||||
|
|
|
|||
|
|
@ -141,6 +141,8 @@ vi.mock('@/context/modal-context', () => ({
|
|||
let mockProviderContextValue = createMockProviderContextValue()
|
||||
vi.mock('@/context/provider-context', () => ({
|
||||
useProviderContext: () => mockProviderContextValue,
|
||||
useProviderContextSelector: <T,>(selector: (s: ReturnType<typeof createMockProviderContextValue>) => T): T =>
|
||||
selector(mockProviderContextValue),
|
||||
}))
|
||||
|
||||
// Mock event emitter context
|
||||
|
|
|
|||
|
|
@ -131,6 +131,8 @@ vi.mock('@/context/provider-context', () => ({
|
|||
useProviderContext: () => ({
|
||||
isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate(),
|
||||
}),
|
||||
useProviderContextSelector: <T,>(selector: (s: { isAllowPublishAsCustomKnowledgePipelineTemplate: boolean }) => T): T =>
|
||||
selector({ isAllowPublishAsCustomKnowledgePipelineTemplate: mockIsAllowPublishAsCustomKnowledgePipelineTemplate() }),
|
||||
}))
|
||||
|
||||
// Mock toast context
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { getKeyboardKeyCodeBySystem } from '@/app/components/workflow/utils'
|
|||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import { useModalContextSelector } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useProviderContextSelector } from '@/context/provider-context'
|
||||
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
|
|
@ -68,7 +68,7 @@ const Popup = () => {
|
|||
const { mutateAsync: publishWorkflow } = usePublishWorkflow()
|
||||
const { notify } = useToastContext()
|
||||
const workflowStore = useWorkflowStore()
|
||||
const { isAllowPublishAsCustomKnowledgePipelineTemplate } = useProviderContext()
|
||||
const isAllowPublishAsCustomKnowledgePipelineTemplate = useProviderContextSelector(s => s.isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
|
||||
const apiReferenceUrl = useDatasetApiAccessUrl()
|
||||
|
||||
|
|
@ -152,7 +152,7 @@ const Popup = () => {
|
|||
if (confirmVisible)
|
||||
hideConfirm()
|
||||
}
|
||||
}, [handleCheckBeforePublish, publishWorkflow, pipelineId, notify, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, showConfirm, publishedAt, confirmVisible, hidePublishing, showPublishing, hideConfirm, publishing])
|
||||
}, [publishing, handleCheckBeforePublish, publishedAt, confirmVisible, showPublishing, publishWorkflow, pipelineId, datasetId, showConfirm, notify, t, workflowStore, mutateDatasetRes, invalidPublishedPipelineInfo, invalidDatasetList, hidePublishing, hideConfirm])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
|
|
@ -207,15 +207,7 @@ const Popup = () => {
|
|||
hidePublishingAsCustomizedPipeline()
|
||||
hidePublishAsKnowledgePipelineModal()
|
||||
}
|
||||
}, [
|
||||
pipelineId,
|
||||
publishAsCustomizedPipeline,
|
||||
showPublishingAsCustomizedPipeline,
|
||||
hidePublishingAsCustomizedPipeline,
|
||||
hidePublishAsKnowledgePipelineModal,
|
||||
notify,
|
||||
t,
|
||||
])
|
||||
}, [showPublishingAsCustomizedPipeline, publishAsCustomizedPipeline, pipelineId, notify, t, invalidCustomizedTemplateList, hidePublishingAsCustomizedPipeline, hidePublishAsKnowledgePipelineModal])
|
||||
|
||||
const handleClickPublishAsKnowledgePipeline = useCallback(() => {
|
||||
if (!isAllowPublishAsCustomKnowledgePipelineTemplate)
|
||||
|
|
|
|||
|
|
@ -67,6 +67,12 @@ vi.mock('@/service/workflow', () => ({
|
|||
fetchWorkflowDraft: (url: string) => mockFetchWorkflowDraft(url),
|
||||
}))
|
||||
|
||||
// Mock download utility
|
||||
const mockDownloadBlob = vi.fn()
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (options: { data: Blob, fileName: string }) => mockDownloadBlob(options),
|
||||
}))
|
||||
|
||||
// Mock workflow constants
|
||||
vi.mock('@/app/components/workflow/constants', () => ({
|
||||
DSL_EXPORT_CHECK: 'DSL_EXPORT_CHECK',
|
||||
|
|
@ -77,33 +83,9 @@ vi.mock('@/app/components/workflow/constants', () => ({
|
|||
// ============================================================================
|
||||
|
||||
describe('useDSL', () => {
|
||||
let mockLink: { href: string, download: string, click: ReturnType<typeof vi.fn> }
|
||||
let originalCreateElement: typeof document.createElement
|
||||
let mockCreateObjectURL: ReturnType<typeof vi.spyOn>
|
||||
let mockRevokeObjectURL: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
// Create a proper mock link element
|
||||
mockLink = {
|
||||
href: '',
|
||||
download: '',
|
||||
click: vi.fn(),
|
||||
}
|
||||
|
||||
// Save original and mock selectively - only intercept 'a' elements
|
||||
originalCreateElement = document.createElement.bind(document)
|
||||
document.createElement = vi.fn((tagName: string) => {
|
||||
if (tagName === 'a') {
|
||||
return mockLink as unknown as HTMLElement
|
||||
}
|
||||
return originalCreateElement(tagName)
|
||||
}) as typeof document.createElement
|
||||
|
||||
mockCreateObjectURL = vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
|
||||
mockRevokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
|
||||
|
||||
// Default store state
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
pipelineId: 'test-pipeline-id',
|
||||
|
|
@ -118,9 +100,6 @@ describe('useDSL', () => {
|
|||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.createElement = originalCreateElement
|
||||
mockCreateObjectURL.mockRestore()
|
||||
mockRevokeObjectURL.mockRestore()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
|
|
@ -187,9 +166,10 @@ describe('useDSL', () => {
|
|||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(document.createElement).toHaveBeenCalledWith('a')
|
||||
expect(mockCreateObjectURL).toHaveBeenCalled()
|
||||
expect(mockRevokeObjectURL).toHaveBeenCalledWith('blob:test-url')
|
||||
expect(mockDownloadBlob).toHaveBeenCalled()
|
||||
const callArg = mockDownloadBlob.mock.calls[0][0]
|
||||
expect(callArg.data).toBeInstanceOf(Blob)
|
||||
expect(callArg.fileName).toBe('Test Knowledge Base.pipeline')
|
||||
})
|
||||
|
||||
it('should use correct file extension for download', async () => {
|
||||
|
|
@ -199,17 +179,23 @@ describe('useDSL', () => {
|
|||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockLink.download).toBe('Test Knowledge Base.pipeline')
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
fileName: 'Test Knowledge Base.pipeline',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should trigger download click', async () => {
|
||||
it('should trigger download with yaml blob', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockLink.click).toHaveBeenCalled()
|
||||
expect(mockDownloadBlob).toHaveBeenCalled()
|
||||
const callArg = mockDownloadBlob.mock.calls[0][0]
|
||||
expect(callArg.data.type).toBe('application/yaml')
|
||||
})
|
||||
|
||||
it('should show error notification on export failure', async () => {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ export const useAvailableNodesMetaData = () => {
|
|||
const docLink = useDocLink()
|
||||
|
||||
const mergedNodesMetaData = useMemo(() => [
|
||||
...WORKFLOW_COMMON_NODES,
|
||||
// RAG pipeline doesn't support human-input node temporarily
|
||||
...WORKFLOW_COMMON_NODES.filter(node => node.metaData.type !== BlockEnum.HumanInput),
|
||||
{
|
||||
...dataSourceDefault,
|
||||
defaultValue: {
|
||||
|
|
@ -47,7 +48,7 @@ export const useAvailableNodesMetaData = () => {
|
|||
title,
|
||||
},
|
||||
}
|
||||
}), [mergedNodesMetaData, t])
|
||||
}), [helpLinkUri, mergedNodesMetaData, t])
|
||||
|
||||
const availableNodesMetaDataMap = useMemo(() => availableNodesMetaData.reduce((acc, node) => {
|
||||
acc![node.metaData.type] = node
|
||||
|
|
|
|||
|
|
@ -20,5 +20,5 @@ export const useConfigsMap = () => {
|
|||
fileUploadConfig,
|
||||
},
|
||||
}
|
||||
}, [pipelineId])
|
||||
}, [fileUploadConfig, pipelineId])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useRef } from 'react'
|
||||
import {
|
||||
useReactFlow,
|
||||
useStoreApi,
|
||||
|
|
@ -42,6 +42,8 @@ export const usePipelineRun = () => {
|
|||
handleWorkflowTextReplace,
|
||||
} = useWorkflowRunEvent()
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const handleBackupDraft = useCallback(() => {
|
||||
const {
|
||||
getNodes,
|
||||
|
|
@ -154,12 +156,18 @@ export const usePipelineRun = () => {
|
|||
resultText: '',
|
||||
})
|
||||
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = null
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: params,
|
||||
},
|
||||
{
|
||||
getAbortController: (controller: AbortController) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
onWorkflowStarted: (params) => {
|
||||
handleWorkflowStarted(params)
|
||||
|
||||
|
|
@ -267,31 +275,17 @@ export const usePipelineRun = () => {
|
|||
...restCallback,
|
||||
},
|
||||
)
|
||||
}, [
|
||||
store,
|
||||
workflowStore,
|
||||
doSyncWorkflowDraft,
|
||||
handleWorkflowStarted,
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowAgentLog,
|
||||
])
|
||||
}, [store, doSyncWorkflowDraft, workflowStore, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
|
||||
|
||||
const handleStopRun = useCallback((taskId: string) => {
|
||||
const { pipelineId } = workflowStore.getState()
|
||||
|
||||
stopWorkflowRun(`/rag/pipelines/${pipelineId}/workflow-runs/tasks/${taskId}/stop`)
|
||||
|
||||
if (abortControllerRef.current)
|
||||
abortControllerRef.current.abort()
|
||||
|
||||
abortControllerRef.current = null
|
||||
}, [workflowStore])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
|
|
|
|||
|
|
@ -1,133 +0,0 @@
|
|||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import Result from './content'
|
||||
|
||||
// Only mock react-i18next for translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard for the Header component
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: vi.fn(() => true),
|
||||
}))
|
||||
|
||||
// Mock the format function from service/base
|
||||
vi.mock('@/service/base', () => ({
|
||||
format: (content: string) => content.replace(/\n/g, '<br>'),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Result (content)', () => {
|
||||
const mockOnFeedback = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
content: 'Test content here',
|
||||
showFeedback: true,
|
||||
feedback: { rating: null } as FeedbackType,
|
||||
onFeedback: mockOnFeedback,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the Header component', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
// Header renders the result title
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render content', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('Test content here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render formatted content with line breaks', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content={'Line 1\nLine 2'}
|
||||
/>,
|
||||
)
|
||||
|
||||
// The format function converts \n to <br>
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv?.innerHTML).toContain('Line 1<br>Line 2')
|
||||
})
|
||||
|
||||
it('should have max height style', () => {
|
||||
render(<Result {...defaultProps} />)
|
||||
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv).toHaveStyle({ maxHeight: '70vh' })
|
||||
})
|
||||
|
||||
it('should render with empty content', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content=""
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render with HTML content safely', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
content="<script>alert('xss')</script>"
|
||||
/>,
|
||||
)
|
||||
|
||||
// Content is rendered via dangerouslySetInnerHTML
|
||||
const contentDiv = document.querySelector('[class*="overflow-scroll"]')
|
||||
expect(contentDiv).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback props', () => {
|
||||
it('should pass showFeedback to Header', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
showFeedback={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Feedback buttons should not be visible
|
||||
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
|
||||
expect(feedbackArea).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should pass feedback to Header', () => {
|
||||
render(
|
||||
<Result
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Like button should be highlighted
|
||||
const likeButton = document.querySelector('[class*="primary"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Result as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { FC } from 'react'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import * as React from 'react'
|
||||
import { format } from '@/service/base'
|
||||
import Header from './header'
|
||||
|
||||
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)
|
||||
|
|
@ -1,176 +0,0 @@
|
|||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Header from './header'
|
||||
|
||||
// Only mock react-i18next for translations
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Mock copy-to-clipboard
|
||||
const mockCopy = vi.fn((_text: string) => true)
|
||||
vi.mock('copy-to-clipboard', () => ({
|
||||
default: (text: string) => mockCopy(text),
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
})
|
||||
|
||||
describe('Header', () => {
|
||||
const mockOnFeedback = vi.fn()
|
||||
|
||||
const defaultProps = {
|
||||
result: 'Test result content',
|
||||
showFeedback: true,
|
||||
feedback: { rating: null } as FeedbackType,
|
||||
onFeedback: mockOnFeedback,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('rendering', () => {
|
||||
it('should render the result title', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('generation.resultTitle')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the copy button', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('generation.copy')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('copy functionality', () => {
|
||||
it('should copy result when copy button is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
const copyButton = screen.getByText('generation.copy').closest('button')
|
||||
fireEvent.click(copyButton!)
|
||||
|
||||
expect(mockCopy).toHaveBeenCalledWith('Test result content')
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback buttons when showFeedback is true', () => {
|
||||
it('should show feedback buttons when no rating is given', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Should show both thumbs up and down buttons
|
||||
const buttons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
expect(buttons.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should show like button highlighted when rating is like', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show the undo button for like
|
||||
const likeButton = document.querySelector('[class*="primary"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should show dislike button highlighted when rating is dislike', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'dislike' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should show the undo button for dislike
|
||||
const dislikeButton = document.querySelector('[class*="red"]')
|
||||
expect(dislikeButton).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should call onFeedback with like when thumbs up is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Find the thumbs up button (first one in the feedback area)
|
||||
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
const thumbsUp = Array.from(thumbButtons).find(btn =>
|
||||
btn.className.includes('rounded-md') && !btn.className.includes('primary'),
|
||||
)
|
||||
|
||||
if (thumbsUp) {
|
||||
fireEvent.click(thumbsUp)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'like' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onFeedback with dislike when thumbs down is clicked', () => {
|
||||
render(<Header {...defaultProps} />)
|
||||
|
||||
// Find the thumbs down button
|
||||
const thumbButtons = document.querySelectorAll('[class*="cursor-pointer"]')
|
||||
const thumbsDown = Array.from(thumbButtons).pop()
|
||||
|
||||
if (thumbsDown) {
|
||||
fireEvent.click(thumbsDown)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: 'dislike' })
|
||||
}
|
||||
})
|
||||
|
||||
it('should call onFeedback with null when undo like is clicked', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'like' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// When liked, clicking the like button again should undo it (has bg-primary-100 class)
|
||||
const likeButton = document.querySelector('[class*="bg-primary-100"]')
|
||||
expect(likeButton).toBeInTheDocument()
|
||||
fireEvent.click(likeButton!)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
|
||||
})
|
||||
|
||||
it('should call onFeedback with null when undo dislike is clicked', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
feedback={{ rating: 'dislike' }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// When disliked, clicking the dislike button again should undo it (has bg-red-100 class)
|
||||
const dislikeButton = document.querySelector('[class*="bg-red-100"]')
|
||||
expect(dislikeButton).toBeInTheDocument()
|
||||
fireEvent.click(dislikeButton!)
|
||||
expect(mockOnFeedback).toHaveBeenCalledWith({ rating: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('feedback buttons when showFeedback is false', () => {
|
||||
it('should not show feedback buttons', () => {
|
||||
render(
|
||||
<Header
|
||||
{...defaultProps}
|
||||
showFeedback={false}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Should not show feedback area buttons (only copy button)
|
||||
const feedbackArea = document.querySelector('[class*="space-x-1 rounded-lg border"]')
|
||||
expect(feedbackArea).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('memoization', () => {
|
||||
it('should be wrapped with React.memo', () => {
|
||||
expect((Header as unknown as { $$typeof: symbol }).$$typeof).toBe(Symbol.for('react.memo'))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
|
||||
import { ClipboardDocumentIcon, HandThumbDownIcon, HandThumbUpIcon } from '@heroicons/react/24/outline'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
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('generation.resultTitle', { ns: 'share' })}</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('generation.copy', { ns: 'share' })}</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)
|
||||
|
|
@ -5,7 +5,9 @@ import type { WorkflowProcess } from '@/app/components/base/chat/types'
|
|||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { PromptConfig } from '@/models/debug'
|
||||
import type { SiteInfo } from '@/models/share'
|
||||
import type { AppSourceType } from '@/service/share'
|
||||
import type {
|
||||
IOtherOptions,
|
||||
} from '@/service/base'
|
||||
import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { RiLoader2Line } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
|
|
@ -25,7 +27,17 @@ import Toast from '@/app/components/base/toast'
|
|||
import NoData from '@/app/components/share/text-generation/no-data'
|
||||
import { NodeRunningStatus, WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
|
||||
import { sendCompletionMessage, sendWorkflowMessage, stopChatMessageResponding, stopWorkflowMessage, updateFeedback } from '@/service/share'
|
||||
import {
|
||||
sseGet,
|
||||
} from '@/service/base'
|
||||
import {
|
||||
AppSourceType,
|
||||
sendCompletionMessage,
|
||||
sendWorkflowMessage,
|
||||
stopChatMessageResponding,
|
||||
stopWorkflowMessage,
|
||||
updateFeedback,
|
||||
} from '@/service/share'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { sleep } from '@/utils'
|
||||
import { formatBooleanInputs } from '@/utils/model-config'
|
||||
|
|
@ -93,10 +105,10 @@ const Result: FC<IResultProps> = ({
|
|||
const getCompletionRes = () => completionResRef.current
|
||||
const [workflowProcessData, doSetWorkflowProcessData] = useState<WorkflowProcess>()
|
||||
const workflowProcessDataRef = useRef<WorkflowProcess | undefined>(undefined)
|
||||
const setWorkflowProcessData = (data: WorkflowProcess) => {
|
||||
const setWorkflowProcessData = useCallback((data: WorkflowProcess | undefined) => {
|
||||
workflowProcessDataRef.current = data
|
||||
doSetWorkflowProcessData(data)
|
||||
}
|
||||
}, [])
|
||||
const getWorkflowProcessData = () => workflowProcessDataRef.current
|
||||
const [currentTaskId, setCurrentTaskId] = useState<string | null>(null)
|
||||
const [isStopping, setIsStopping] = useState(false)
|
||||
|
|
@ -157,7 +169,7 @@ const Result: FC<IResultProps> = ({
|
|||
finally {
|
||||
setIsStopping(false)
|
||||
}
|
||||
}, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify])
|
||||
}, [appId, currentTaskId, appSourceType, isStopping, isWorkflow, notify])
|
||||
|
||||
useEffect(() => {
|
||||
if (!onRunControlChange)
|
||||
|
|
@ -257,6 +269,7 @@ const Result: FC<IResultProps> = ({
|
|||
rating: null,
|
||||
})
|
||||
setCompletionRes('')
|
||||
setWorkflowProcessData(undefined)
|
||||
resetRunState()
|
||||
|
||||
let res: string[] = []
|
||||
|
|
@ -281,10 +294,17 @@ const Result: FC<IResultProps> = ({
|
|||
})()
|
||||
|
||||
if (isWorkflow) {
|
||||
sendWorkflowMessage(
|
||||
data,
|
||||
{
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
const otherOptions: IOtherOptions = {
|
||||
isPublicAPI: appSourceType === AppSourceType.webApp,
|
||||
onWorkflowStarted: ({ workflow_run_id, task_id }) => {
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
if (workflowProcessData && workflowProcessData.tracing.length > 0) {
|
||||
setWorkflowProcessData(produce(workflowProcessData, (draft) => {
|
||||
draft.expand = true
|
||||
draft.status = WorkflowRunningStatus.Running
|
||||
}))
|
||||
}
|
||||
else {
|
||||
tempMessageId = workflow_run_id
|
||||
setCurrentTaskId(task_id || null)
|
||||
setIsStopping(false)
|
||||
|
|
@ -294,178 +314,258 @@ const Result: FC<IResultProps> = ({
|
|||
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
|
||||
}
|
||||
},
|
||||
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 }
|
||||
: {}),
|
||||
if (data.loop_id)
|
||||
return
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
|
||||
if (draft.tracing.length > 0) {
|
||||
const currentIndex = draft.tracing.findIndex(item => item.node_id === data.node_id)
|
||||
if (currentIndex > -1) {
|
||||
draft.expand = true
|
||||
draft.tracing![currentIndex] = {
|
||||
...data,
|
||||
expand: !!data.error,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
}
|
||||
}
|
||||
}))
|
||||
},
|
||||
onWorkflowFinished: ({ data }) => {
|
||||
if (isTimeout) {
|
||||
notify({ type: 'warning', message: t('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
|
||||
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)
|
||||
else {
|
||||
draft.expand = true
|
||||
draft.tracing.push({
|
||||
...data,
|
||||
status: NodeRunningStatus.Running,
|
||||
expand: true,
|
||||
})
|
||||
}
|
||||
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]]
|
||||
}))
|
||||
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('warningMessage.timeoutExceeded', { ns: 'appDebug' }) })
|
||||
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()
|
||||
setMessageId(tempMessageId)
|
||||
onCompleted(getCompletionRes(), taskId, true)
|
||||
onCompleted(getCompletionRes(), taskId, false)
|
||||
isEnd = true
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
const { data: { text } } = params
|
||||
return
|
||||
}
|
||||
if (data.error) {
|
||||
notify({ type: 'error', message: data.error })
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
draft.resultText += text
|
||||
draft.status = WorkflowRunningStatus.Failed
|
||||
markNodesStopped(draft.tracing)
|
||||
}))
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
const { data: { text } } = params
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
draft.resultText = text
|
||||
}))
|
||||
},
|
||||
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
|
||||
}))
|
||||
},
|
||||
onHumanInputRequired: ({ data: humanInputRequiredData }) => {
|
||||
const workflowProcessData = getWorkflowProcessData()
|
||||
setWorkflowProcessData(produce(workflowProcessData!, (draft) => {
|
||||
if (!draft.humanInputFormDataList) {
|
||||
draft.humanInputFormDataList = [humanInputRequiredData]
|
||||
}
|
||||
else {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentFormIndex > -1) {
|
||||
draft.humanInputFormDataList[currentFormIndex] = humanInputRequiredData
|
||||
}
|
||||
else {
|
||||
draft.humanInputFormDataList.push(humanInputRequiredData)
|
||||
}
|
||||
}
|
||||
const currentIndex = draft.tracing!.findIndex(item => item.node_id === humanInputRequiredData.node_id)
|
||||
if (currentIndex > -1) {
|
||||
draft.tracing![currentIndex].status = NodeRunningStatus.Paused
|
||||
}
|
||||
}))
|
||||
},
|
||||
onHumanInputFormFilled: ({ data: humanInputFilledFormData }) => {
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFilledFormData.node_id)
|
||||
draft.humanInputFormDataList.splice(currentFormIndex, 1)
|
||||
}
|
||||
if (!draft.humanInputFilledFormDataList) {
|
||||
draft.humanInputFilledFormDataList = [humanInputFilledFormData]
|
||||
}
|
||||
else {
|
||||
draft.humanInputFilledFormDataList.push(humanInputFilledFormData)
|
||||
}
|
||||
}))
|
||||
},
|
||||
onHumanInputFormTimeout: ({ data: humanInputFormTimeoutData }) => {
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
if (draft.humanInputFormDataList?.length) {
|
||||
const currentFormIndex = draft.humanInputFormDataList.findIndex(item => item.node_id === humanInputFormTimeoutData.node_id)
|
||||
draft.humanInputFormDataList[currentFormIndex].expiration_time = humanInputFormTimeoutData.expiration_time
|
||||
}
|
||||
}))
|
||||
},
|
||||
onWorkflowPaused: ({ data: workflowPausedData }) => {
|
||||
const url = `/workflow/${workflowPausedData.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
otherOptions,
|
||||
)
|
||||
setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
|
||||
draft.expand = false
|
||||
draft.status = WorkflowRunningStatus.Paused
|
||||
}))
|
||||
},
|
||||
}
|
||||
sendWorkflowMessage(
|
||||
data,
|
||||
otherOptions,
|
||||
appSourceType,
|
||||
appId,
|
||||
).catch((error) => {
|
||||
|
|
@ -562,7 +662,8 @@ const Result: FC<IResultProps> = ({
|
|||
isMobile={isMobile}
|
||||
appSourceType={appSourceType}
|
||||
installedAppId={appId}
|
||||
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
|
||||
// isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
|
||||
isLoading={false}
|
||||
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
|
||||
controlClearMoreLikeThis={controlClearMoreLikeThis}
|
||||
isShowTextToSpeech={isShowTextToSpeech}
|
||||
|
|
|
|||
|
|
@ -111,6 +111,10 @@ const FeaturesTrigger = () => {
|
|||
return isFetchedPlan && plan.type === Plan.sandbox && entryCount > 2
|
||||
}, [nodes, plan.type, isFetchedPlan])
|
||||
|
||||
const hasHumanInputNode = useMemo(() => {
|
||||
return nodes.some(node => node.data.type === BlockEnum.HumanInput)
|
||||
}, [nodes])
|
||||
|
||||
const resetWorkflowVersionHistory = useResetWorkflowVersionHistory()
|
||||
const invalidateAppTriggers = useInvalidateAppTriggers()
|
||||
|
||||
|
|
@ -171,7 +175,7 @@ const FeaturesTrigger = () => {
|
|||
else {
|
||||
throw new Error('Checklist failed')
|
||||
}
|
||||
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers])
|
||||
}, [needWarningNodes, handleCheckBeforePublish, publishWorkflow, notify, appID, t, updatePublishedWorkflow, updateAppDetail, workflowStore, resetWorkflowVersionHistory, invalidateAppTriggers, hasUserInputNode])
|
||||
|
||||
const onPublisherToggle = useCallback((state: boolean) => {
|
||||
if (state)
|
||||
|
|
@ -214,6 +218,7 @@ const FeaturesTrigger = () => {
|
|||
hasTriggerNode,
|
||||
startNodeLimitExceeded,
|
||||
publishDisabled: !hasWorkflowNodes || startNodeLimitExceeded,
|
||||
hasHumanInputNode,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-
|
|||
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { handleStream, post, ssePost } from '@/service/base'
|
||||
import { handleStream, post, sseGet, ssePost } from '@/service/base'
|
||||
import { ContentType } from '@/service/fetch'
|
||||
import { useInvalidAllLastRun } from '@/service/use-workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
|
|
@ -79,6 +79,9 @@ export const useWorkflowRun = () => {
|
|||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeHumanInputRequired,
|
||||
handleWorkflowNodeHumanInputFormFilled,
|
||||
handleWorkflowNodeHumanInputFormTimeout,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
|
|
@ -89,6 +92,7 @@ export const useWorkflowRun = () => {
|
|||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowPaused,
|
||||
} = useWorkflowRunEvent()
|
||||
|
||||
const handleBackupDraft = useCallback(() => {
|
||||
|
|
@ -176,6 +180,10 @@ export const useWorkflowRun = () => {
|
|||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
onWorkflowPaused,
|
||||
onHumanInputRequired,
|
||||
onHumanInputFormFilled,
|
||||
onHumanInputFormTimeout,
|
||||
onCompleted,
|
||||
...restCallback
|
||||
} = callback || {}
|
||||
|
|
@ -372,12 +380,6 @@ export const useWorkflowRun = () => {
|
|||
const baseSseOptions: IOtherOptions = {
|
||||
...restCallback,
|
||||
onWorkflowStarted: (params) => {
|
||||
const state = workflowStore.getState()
|
||||
if (state.workflowRunningData) {
|
||||
state.setWorkflowRunningData(produce(state.workflowRunningData, (draft) => {
|
||||
draft.resultText = ''
|
||||
}))
|
||||
}
|
||||
handleWorkflowStarted(params)
|
||||
|
||||
if (onWorkflowStarted)
|
||||
|
|
@ -492,6 +494,32 @@ export const useWorkflowRun = () => {
|
|||
if (audioPlayer)
|
||||
audioPlayer.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
baseSseOptions,
|
||||
)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
onError: wrappedOnError,
|
||||
onCompleted: wrappedOnCompleted,
|
||||
}
|
||||
|
|
@ -604,6 +632,10 @@ export const useWorkflowRun = () => {
|
|||
baseSseOptions.onTTSEnd,
|
||||
baseSseOptions.onTextReplace,
|
||||
baseSseOptions.onAgentLog,
|
||||
baseSseOptions.onHumanInputRequired,
|
||||
baseSseOptions.onHumanInputFormFilled,
|
||||
baseSseOptions.onHumanInputFormTimeout,
|
||||
baseSseOptions.onWorkflowPaused,
|
||||
baseSseOptions.onDataSourceNodeProcessing,
|
||||
baseSseOptions.onDataSourceNodeCompleted,
|
||||
baseSseOptions.onDataSourceNodeError,
|
||||
|
|
@ -655,19 +687,157 @@ export const useWorkflowRun = () => {
|
|||
return
|
||||
}
|
||||
|
||||
const finalCallbacks: IOtherOptions = {
|
||||
...baseSseOptions,
|
||||
getAbortController: (controller: AbortController) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
handleWorkflowFinished(params)
|
||||
|
||||
if (onWorkflowFinished)
|
||||
onWorkflowFinished(params)
|
||||
if (isInWorkflowDebug) {
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
}
|
||||
},
|
||||
onError: (params) => {
|
||||
handleWorkflowFailed()
|
||||
|
||||
if (onError)
|
||||
onError(params)
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onNodeStarted)
|
||||
onNodeStarted(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
if (onNodeFinished)
|
||||
onNodeFinished(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onIterationStart)
|
||||
onIterationStart(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
if (onIterationNext)
|
||||
onIterationNext(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
if (onIterationFinish)
|
||||
onIterationFinish(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onLoopStart)
|
||||
onLoopStart(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
if (onLoopNext)
|
||||
onLoopNext(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
if (onLoopFinish)
|
||||
onLoopFinish(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
if (onNodeRetry)
|
||||
onNodeRetry(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
if (onAgentLog)
|
||||
onAgentLog(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
player?.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
},
|
||||
onTTSEnd: (messageId: string, audio: string) => {
|
||||
player?.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
finalCallbacks,
|
||||
)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
...restCallback,
|
||||
}
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
{
|
||||
body: requestBody,
|
||||
},
|
||||
{
|
||||
...baseSseOptions,
|
||||
getAbortController: (controller: AbortController) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
},
|
||||
finalCallbacks,
|
||||
)
|
||||
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowFailed, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace])
|
||||
}, [store, doSyncWorkflowDraft, workflowStore, pathname, handleWorkflowFailed, flowId, handleWorkflowStarted, handleWorkflowFinished, fetchInspectVars, invalidAllLastRun, handleWorkflowNodeStarted, handleWorkflowNodeFinished, handleWorkflowNodeIterationStarted, handleWorkflowNodeIterationNext, handleWorkflowNodeIterationFinished, handleWorkflowNodeLoopStarted, handleWorkflowNodeLoopNext, handleWorkflowNodeLoopFinished, handleWorkflowNodeRetry, handleWorkflowAgentLog, handleWorkflowTextChunk, handleWorkflowTextReplace, handleWorkflowPaused, handleWorkflowNodeHumanInputRequired, handleWorkflowNodeHumanInputFormFilled, handleWorkflowNodeHumanInputFormTimeout])
|
||||
|
||||
const handleStopRun = useCallback((taskId: string) => {
|
||||
const setStoppedState = () => {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import {
|
|||
End,
|
||||
Home,
|
||||
Http,
|
||||
HumanInLoop,
|
||||
IfElse,
|
||||
Iteration,
|
||||
KnowledgeBase,
|
||||
|
|
@ -71,6 +72,7 @@ const DEFAULT_ICON_MAP: Record<BlockEnum, React.ComponentType<{ className: strin
|
|||
[BlockEnum.TriggerSchedule]: Schedule,
|
||||
[BlockEnum.TriggerWebhook]: WebhookLine,
|
||||
[BlockEnum.TriggerPlugin]: VariableX,
|
||||
[BlockEnum.HumanInput]: HumanInLoop,
|
||||
}
|
||||
|
||||
const getIcon = (type: BlockEnum, className: string) => {
|
||||
|
|
@ -102,6 +104,7 @@ const ICON_CONTAINER_BG_COLOR_MAP: Record<string, string> = {
|
|||
[BlockEnum.DocExtractor]: 'bg-util-colors-green-green-500',
|
||||
[BlockEnum.ListFilter]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.Agent]: 'bg-util-colors-indigo-indigo-500',
|
||||
[BlockEnum.HumanInput]: 'bg-util-colors-cyan-cyan-500',
|
||||
[BlockEnum.KnowledgeBase]: 'bg-util-colors-warning-warning-500',
|
||||
[BlockEnum.DataSource]: 'bg-components-icon-bg-midnight-solid',
|
||||
[BlockEnum.TriggerSchedule]: 'bg-util-colors-violet-violet-500',
|
||||
|
|
|
|||
|
|
@ -128,6 +128,7 @@ export const SUPPORT_OUTPUT_VARS_NODE = [
|
|||
BlockEnum.ListFilter,
|
||||
BlockEnum.Agent,
|
||||
BlockEnum.DataSource,
|
||||
BlockEnum.HumanInput,
|
||||
]
|
||||
|
||||
export const AGENT_OUTPUT_STRUCT: Var[] = [
|
||||
|
|
@ -211,6 +212,17 @@ export const TOOL_OUTPUT_STRUCT: Var[] = [
|
|||
},
|
||||
]
|
||||
|
||||
export const HUMAN_INPUT_OUTPUT_STRUCT: Var[] = [
|
||||
{
|
||||
variable: '__action_id',
|
||||
type: VarType.string,
|
||||
},
|
||||
{
|
||||
variable: '__rendered_content',
|
||||
type: VarType.string,
|
||||
},
|
||||
]
|
||||
|
||||
export const PARAMETER_EXTRACTOR_COMMON_STRUCT: Var[] = [
|
||||
{
|
||||
variable: '__is_success',
|
||||
|
|
|
|||
|
|
@ -5,12 +5,13 @@ import codeDefault from '@/app/components/workflow/nodes/code/default'
|
|||
import documentExtractorDefault from '@/app/components/workflow/nodes/document-extractor/default'
|
||||
|
||||
import httpRequestDefault from '@/app/components/workflow/nodes/http/default'
|
||||
import humanInputDefault from '@/app/components/workflow/nodes/human-input/default'
|
||||
import ifElseDefault from '@/app/components/workflow/nodes/if-else/default'
|
||||
import iterationStartDefault from '@/app/components/workflow/nodes/iteration-start/default'
|
||||
import iterationDefault from '@/app/components/workflow/nodes/iteration/default'
|
||||
import knowledgeRetrievalDefault from '@/app/components/workflow/nodes/knowledge-retrieval/default'
|
||||
import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
|
||||
|
||||
import listOperatorDefault from '@/app/components/workflow/nodes/list-operator/default'
|
||||
import llmDefault from '@/app/components/workflow/nodes/llm/default'
|
||||
import loopEndDefault from '@/app/components/workflow/nodes/loop-end/default'
|
||||
import loopStartDefault from '@/app/components/workflow/nodes/loop-start/default'
|
||||
|
|
@ -41,4 +42,5 @@ export const WORKFLOW_COMMON_NODES = [
|
|||
httpRequestDefault,
|
||||
listOperatorDefault,
|
||||
toolDefault,
|
||||
humanInputDefault,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const RunMode = ({
|
|||
handleWorkflowRunAllTriggersInWorkflow,
|
||||
} = useWorkflowStartRun()
|
||||
const { handleStopRun } = useWorkflowRun()
|
||||
const { validateBeforeRun, warningNodes } = useWorkflowRunValidation()
|
||||
const { warningNodes } = useWorkflowRunValidation()
|
||||
const workflowRunningData = useStore(s => s.workflowRunningData)
|
||||
const isListening = useStore(s => s.isListening)
|
||||
|
||||
|
|
@ -98,14 +98,7 @@ const RunMode = ({
|
|||
// Placeholder for trigger-specific execution logic for schedule, webhook, plugin types
|
||||
console.log('TODO: Handle trigger execution for type:', option.type, 'nodeId:', option.nodeId)
|
||||
}
|
||||
}, [
|
||||
validateBeforeRun,
|
||||
handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow,
|
||||
])
|
||||
}, [warningNodes, notify, t, handleWorkflowStartRunInWorkflow, handleWorkflowTriggerScheduleRunInWorkflow, handleWorkflowTriggerWebhookRunInWorkflow, handleWorkflowTriggerPluginRunInWorkflow, handleWorkflowRunAllTriggersInWorkflow])
|
||||
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { BlockEnum } from '../types'
|
|||
import { useNodesMetaData } from './use-nodes-meta-data'
|
||||
|
||||
const availableBlocksFilter = (nodeType: BlockEnum, inContainer?: boolean) => {
|
||||
if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase))
|
||||
if (inContainer && (nodeType === BlockEnum.Iteration || nodeType === BlockEnum.Loop || nodeType === BlockEnum.End || nodeType === BlockEnum.DataSource || nodeType === BlockEnum.KnowledgeBase || nodeType === BlockEnum.HumanInput))
|
||||
return false
|
||||
|
||||
if (!inContainer && nodeType === BlockEnum.LoopEnd)
|
||||
|
|
|
|||
|
|
@ -249,7 +249,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[]) => {
|
|||
})
|
||||
|
||||
return list
|
||||
}, [nodes, nodesExtraData, edges, buildInTools, customTools, workflowTools, language, dataSourceList, getToolIcon, strategyProviders, getCheckData, t, map, shouldCheckStartNode])
|
||||
}, [nodes, edges, shouldCheckStartNode, nodesExtraData, buildInTools, customTools, workflowTools, language, dataSourceList, triggerPlugins, getToolIcon, strategyProviders, getCheckData, t, map])
|
||||
|
||||
return needWarningNodes
|
||||
}
|
||||
|
|
@ -419,7 +419,7 @@ export const useChecklistBeforePublish = () => {
|
|||
}
|
||||
|
||||
return true
|
||||
}, [store, notify, t, language, nodesExtraData, strategyProviders, updateDatasetsDetail, getCheckData, workflowStore, buildInTools, customTools, workflowTools, shouldCheckStartNode])
|
||||
}, [store, workflowStore, getNodesAvailableVarList, shouldCheckStartNode, nodesExtraData, notify, t, updateDatasetsDetail, buildInTools, customTools, workflowTools, language, getCheckData, strategyProviders])
|
||||
|
||||
return {
|
||||
handleCheckBeforePublish,
|
||||
|
|
|
|||
|
|
@ -151,11 +151,65 @@ export const useEdgesInteractions = () => {
|
|||
setEdges(newEdges)
|
||||
}, [store, getNodesReadOnly])
|
||||
|
||||
const handleEdgeSourceHandleChange = useCallback((nodeId: string, oldHandleId: string, newHandleId: string) => {
|
||||
if (getNodesReadOnly())
|
||||
return
|
||||
|
||||
const { getNodes, setNodes, edges, setEdges } = store.getState()
|
||||
const nodes = getNodes()
|
||||
|
||||
// Find edges connected to the old handle
|
||||
const affectedEdges = edges.filter(
|
||||
edge => edge.source === nodeId && edge.sourceHandle === oldHandleId,
|
||||
)
|
||||
|
||||
if (affectedEdges.length === 0)
|
||||
return
|
||||
|
||||
// Update node metadata: remove old handle, add new handle
|
||||
const nodesConnectedSourceOrTargetHandleIdsMap = getNodesConnectedSourceOrTargetHandleIdsMap(
|
||||
[
|
||||
...affectedEdges.map(edge => ({ type: 'remove', edge })),
|
||||
...affectedEdges.map(edge => ({
|
||||
type: 'add',
|
||||
edge: { ...edge, sourceHandle: newHandleId },
|
||||
})),
|
||||
],
|
||||
nodes,
|
||||
)
|
||||
|
||||
const newNodes = produce(nodes, (draft: Node[]) => {
|
||||
draft.forEach((node) => {
|
||||
if (nodesConnectedSourceOrTargetHandleIdsMap[node.id]) {
|
||||
node.data = {
|
||||
...node.data,
|
||||
...nodesConnectedSourceOrTargetHandleIdsMap[node.id],
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
setNodes(newNodes)
|
||||
|
||||
// Update edges to use new sourceHandle and regenerate edge IDs
|
||||
const newEdges = produce(edges, (draft) => {
|
||||
draft.forEach((edge) => {
|
||||
if (edge.source === nodeId && edge.sourceHandle === oldHandleId) {
|
||||
edge.sourceHandle = newHandleId
|
||||
edge.id = `${edge.source}-${newHandleId}-${edge.target}-${edge.targetHandle}`
|
||||
}
|
||||
})
|
||||
})
|
||||
setEdges(newEdges)
|
||||
handleSyncWorkflowDraft()
|
||||
saveStateToHistory(WorkflowHistoryEvent.EdgeSourceHandleChange)
|
||||
}, [getNodesReadOnly, store, handleSyncWorkflowDraft, saveStateToHistory])
|
||||
|
||||
return {
|
||||
handleEdgeEnter,
|
||||
handleEdgeLeave,
|
||||
handleEdgeDeleteByDeleteBranch,
|
||||
handleEdgeDelete,
|
||||
handleEdgesChange,
|
||||
handleEdgeSourceHandleChange,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue