refactor(web): split app publisher menu content into sections and add tests

This commit is contained in:
CodingOnStar
2026-03-27 15:04:23 +08:00
parent e13100098a
commit 1907ea6ef9
16 changed files with 1437 additions and 537 deletions

View File

@@ -0,0 +1,244 @@
import type { AppPublisherMenuContentProps } from '../menu-content.types'
import type { AppDetailResponse } from '@/models/app'
import type { SystemFeatures } from '@/types/feature'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import { toast } from '@/app/components/base/ui/toast'
import { AccessMode } from '@/models/access-control'
import { AppModeEnum } from '@/types/app'
import { defaultSystemFeatures } from '@/types/feature'
import MenuContent from '../menu-content'
vi.mock('@/app/components/base/ui/toast', () => ({
toast: {
error: vi.fn(),
},
}))
vi.mock('@/app/components/billing/upgrade-btn', () => ({
default: () => <div data-testid="upgrade-btn">upgrade-btn</div>,
}))
vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({
default: () => <div data-testid="workflow-tool-configure-button">workflow-tool-configure-button</div>,
}))
const createSystemFeatures = (overrides: Partial<SystemFeatures> = {}): SystemFeatures => ({
...defaultSystemFeatures,
...overrides,
branding: {
...defaultSystemFeatures.branding,
...overrides.branding,
},
license: {
...defaultSystemFeatures.license,
...overrides.license,
},
plugin_installation_permission: {
...defaultSystemFeatures.plugin_installation_permission,
...overrides.plugin_installation_permission,
},
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
...overrides.webapp_auth,
sso_config: {
...defaultSystemFeatures.webapp_auth.sso_config,
...overrides.webapp_auth?.sso_config,
},
},
})
const createAppDetail = (overrides: Partial<AppDetailResponse> = {}): AppDetailResponse => ({
access_mode: AccessMode.PUBLIC,
description: 'Workflow description',
icon: '🤖',
icon_background: '#ffffff',
icon_type: 'emoji',
id: 'app-1',
mode: AppModeEnum.WORKFLOW,
name: 'Workflow app',
...overrides,
} as AppDetailResponse)
const createProps = (overrides: Partial<AppPublisherMenuContentProps> = {}): AppPublisherMenuContentProps => ({
appDetail: createAppDetail(),
appURL: '/apps/app-1',
debugWithMultipleModel: false,
disabledFunctionButton: false,
disabledFunctionTooltip: undefined,
draftUpdatedAt: 5678,
formatTimeFromNow: time => `from-now:${time}`,
hasHumanInputNode: false,
hasTriggerNode: false,
inputs: [],
isAppAccessSet: true,
isChatApp: false,
isGettingAppWhiteListSubjects: false,
isGettingUserCanAccessApp: false,
missingStartNode: false,
multipleModelConfigs: [],
onOpenEmbedding: vi.fn(),
onOpenInExplore: vi.fn(),
onPublish: vi.fn(),
onPublishToMarketplace: vi.fn(),
onRefreshData: vi.fn(),
onRestore: vi.fn(),
onShowAppAccessControl: vi.fn(),
outputs: [],
publishDisabled: false,
published: false,
publishedAt: 1234,
publishingToMarketplace: false,
publishLoading: false,
startNodeLimitExceeded: false,
systemFeatures: createSystemFeatures(),
toolPublished: false,
upgradeHighlightStyle: { backgroundImage: 'linear-gradient(90deg, red, blue)' },
workflowToolDisabled: false,
workflowToolMessage: undefined,
...overrides,
})
const renderMenuContent = (overrides: Partial<AppPublisherMenuContentProps> = {}) => {
return render(<MenuContent {...createProps(overrides)} />)
}
describe('AppPublisherMenuContent', () => {
it('should render published metadata and trigger restore for chat apps', async () => {
const user = userEvent.setup()
const onRestore = vi.fn()
renderMenuContent({
isChatApp: true,
onRestore,
publishedAt: 1234,
})
expect(screen.getByText('workflow.common.latestPublished')).toBeInTheDocument()
expect(screen.getByText(/workflow\.common\.publishedAt/)).toBeInTheDocument()
expect(screen.getByText(/from-now:1234/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'workflow.common.restore' }))
expect(onRestore).toHaveBeenCalledTimes(1)
})
it('should render draft state, start node limit hint, and publish action for unpublished apps', async () => {
const user = userEvent.setup()
const onPublish = vi.fn()
renderMenuContent({
draftUpdatedAt: 5678,
onPublish,
publishedAt: undefined,
startNodeLimitExceeded: true,
})
expect(screen.getByText('workflow.common.currentDraftUnpublished')).toBeInTheDocument()
expect(screen.getByText(/workflow\.common\.autoSaved/)).toBeInTheDocument()
expect(screen.getByText(/from-now:5678/)).toBeInTheDocument()
expect(screen.getByText('workflow.publishLimit.startNodeTitlePrefix')).toBeInTheDocument()
expect(screen.getByText('workflow.publishLimit.startNodeTitleSuffix')).toBeInTheDocument()
expect(screen.getByText('workflow.publishLimit.startNodeDesc')).toBeInTheDocument()
expect(screen.getByTestId('upgrade-btn')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /workflow\.common\.publishUpdate/i }))
expect(onPublish).toHaveBeenCalledTimes(1)
})
it('should render access control status and open the access dialog when clicked', async () => {
const user = userEvent.setup()
const onShowAppAccessControl = vi.fn()
renderMenuContent({
appDetail: createAppDetail({ access_mode: AccessMode.SPECIFIC_GROUPS_MEMBERS }),
isAppAccessSet: false,
onShowAppAccessControl,
systemFeatures: createSystemFeatures({
webapp_auth: {
...defaultSystemFeatures.webapp_auth,
enabled: true,
},
}),
})
expect(screen.getByText('app.publishApp.title')).toBeInTheDocument()
expect(screen.getByText('app.accessControlDialog.accessItems.specific')).toBeInTheDocument()
expect(screen.getByText('app.publishApp.notSet')).toBeInTheDocument()
expect(screen.getByText('app.publishApp.notSetDesc')).toBeInTheDocument()
await user.click(screen.getByText('app.publishApp.notSet'))
expect(onShowAppAccessControl).toHaveBeenCalledTimes(1)
})
it('should render workflow actions and call the explore handler when requested', async () => {
const user = userEvent.setup()
const onOpenInExplore = vi.fn()
renderMenuContent({
onOpenInExplore,
publishedAt: 1234,
})
expect(screen.getByText('workflow.common.runApp')).toBeInTheDocument()
expect(screen.getByText('workflow.common.batchRunApp')).toBeInTheDocument()
expect(screen.getByText('workflow.common.openInExplore')).toBeInTheDocument()
expect(screen.getByText('workflow.common.accessAPIReference')).toBeInTheDocument()
expect(screen.getByTestId('workflow-tool-configure-button')).toBeInTheDocument()
expect(screen.getByText('workflow.common.runApp').closest('a')).toHaveAttribute('href', '/apps/app-1')
expect(screen.getByText('workflow.common.batchRunApp').closest('a')).toHaveAttribute('href', '/apps/app-1?mode=batch')
await user.click(screen.getByText('workflow.common.openInExplore'))
expect(onOpenInExplore).toHaveBeenCalledTimes(1)
})
it('should disable unavailable actions and surface marketplace publishing state', () => {
renderMenuContent({
disabledFunctionButton: true,
disabledFunctionTooltip: 'permission denied',
missingStartNode: true,
publishingToMarketplace: true,
systemFeatures: createSystemFeatures({
enable_creators_platform: true,
}),
})
expect(screen.getByText('workflow.common.runApp').closest('a')).not.toHaveAttribute('href')
expect(screen.getByText('workflow.common.batchRunApp').closest('a')).not.toHaveAttribute('href')
expect(screen.getByText('workflow.common.openInExplore').closest('a')).not.toHaveAttribute('href')
expect(screen.getByText('workflow.common.accessAPIReference').closest('a')).not.toHaveAttribute('href')
expect(screen.getByText('workflow.common.publishingToMarketplace')).toBeInTheDocument()
})
it('should hide suggested actions when the workflow already has a trigger node', () => {
renderMenuContent({
hasTriggerNode: true,
})
expect(screen.queryByText('workflow.common.runApp')).not.toBeInTheDocument()
expect(screen.queryByTestId('workflow-tool-configure-button')).not.toBeInTheDocument()
})
it('should not attempt to open explore when the action is disabled', async () => {
const user = userEvent.setup()
const onOpenInExplore = vi.fn()
renderMenuContent({
disabledFunctionButton: true,
disabledFunctionTooltip: 'permission denied',
onOpenInExplore,
publishedAt: undefined,
})
await user.click(screen.getByText('workflow.common.openInExplore'))
expect(onOpenInExplore).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()
})
})

View File

@@ -2,12 +2,8 @@ import type { ModelAndParameter } from '../configuration/debug/types'
import type { CollaborationUpdate } from '@/app/components/workflow/collaboration/types/collaboration'
import type { InputVar, Variable } from '@/app/components/workflow/types'
import type { InstalledApp } from '@/models/explore'
import type { I18nKeysByPrefix } from '@/types/i18n'
import type { PublishWorkflowParams } from '@/types/workflow'
import {
RiLoader2Line,
RiStore2Line,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import {
memo,
@@ -22,19 +18,15 @@ import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { trackEvent } from '@/app/components/base/amplitude'
import Button from '@/app/components/base/button'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { toast } from '@/app/components/base/ui/toast'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { collaborationManager } from '@/app/components/workflow/collaboration/core/collaboration-manager'
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
import { WorkflowContext } from '@/app/components/workflow/context'
import { appDefaultIconBackground } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useAsyncWindowOpen } from '@/hooks/use-async-window-open'
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
@@ -47,58 +39,14 @@ import { useInvalidateAppWorkflow } from '@/service/use-workflow'
import { fetchPublishedWorkflow } from '@/service/workflow'
import { AppModeEnum } from '@/types/app'
import { basePath } from '@/utils/var'
import Divider from '../../base/divider'
import Loading from '../../base/loading'
import Tooltip from '../../base/tooltip'
import ShortcutsName from '../../workflow/shortcuts-name'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import AccessControl from '../app-access-control'
import PublishWithMultipleModel from './publish-with-multiple-model'
import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
import AppPublisherMenuContent from './menu-content'
type InstalledAppsResponse = {
installed_apps?: InstalledApp[]
}
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
export type AppPublisherProps = {
disabled?: boolean
publishDisabled?: boolean
@@ -124,8 +72,6 @@ export type AppPublisherProps = {
hasHumanInputNode?: boolean
}
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
const AppPublisher = ({
disabled = false,
publishDisabled = false,
@@ -180,7 +126,12 @@ const AppPublisher = ({
return true
}, [appAccessSubjects, appDetail])
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
const noAccessPermission = useMemo(() => Boolean(
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])
const disabledFunctionTooltip = useMemo(() => {
@@ -333,7 +284,6 @@ const AppPublisher = ({
const hasPublishedVersion = !!publishedAt
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
const workflowToolMessage = workflowToolDisabled ? t('common.workflowAsToolDisabledHint', { ns: 'workflow' }) : undefined
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
const upgradeHighlightStyle = useMemo(() => ({
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
WebkitBackgroundClip: 'text',
@@ -364,220 +314,47 @@ const AppPublisher = ({
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[11]">
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<div className="p-4 pt-3">
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={handleRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => handlePublish(item)}
// textGenerationModelList={textGenerationModelList}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => handlePublish()}
disabled={publishDisabled || published || publishLoading}
loading={publishLoading}
>
{
publishLoading
? t('common.publishing', { ns: 'workflow' })
: published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)
}
</Button>
{showStartNodeLimitHint && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm font-semibold leading-5 text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
? <div className="py-2"><Loading /></div>
: (
<>
<Divider className="my-0" />
{systemFeatures.webapp_auth.enabled && (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
onClick={() => {
setShowAppAccessControl(true)
}}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={appDetail?.access_mode} />
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)}
{
// Hide run/batch run app buttons when there is a trigger node.
!hasTriggerNode && (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={appURL}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
? (
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={disabledFunctionButton}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
)
: (
<SuggestedAction
onClick={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
disabled={!publishedAt}
icon={<CodeBrowser className="h-4 w-4" />}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<Tooltip triggerClassName="flex" disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
<SuggestedAction
className="flex-1"
onClick={() => {
if (publishedAt)
handleOpenInExplore()
}}
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
<Tooltip triggerClassName="flex" disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('notPublishedYet', { ns: 'app' }) : t('noUserInputNode', { ns: 'app' })} asChild={false}>
<SuggestedAction
className="flex-1"
disabled={!publishedAt || missingStartNode}
link="./develop"
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedAction>
</Tooltip>
{appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && (
<WorkflowToolConfigureButton
disabled={workflowToolDisabled}
published={!!toolPublished}
detailNeedUpdate={!!toolPublished && published}
workflowAppId={appDetail?.id}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
}}
name={appDetail?.name}
description={appDetail?.description}
inputs={inputs}
outputs={outputs}
handlePublish={handlePublish}
onRefreshData={onRefreshData}
disabledReason={workflowToolMessage}
/>
)}
</div>
)
}
{systemFeatures.enable_creators_platform && (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<SuggestedAction
className="flex-1"
onClick={handlePublishToMarketplace}
disabled={publishingToMarketplace}
icon={publishingToMarketplace
? <RiLoader2Line className="h-4 w-4 animate-spin" />
: <RiStore2Line className="h-4 w-4" />}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
)}
</>
)}
</div>
<AppPublisherMenuContent
publishedAt={publishedAt}
draftUpdatedAt={draftUpdatedAt}
debugWithMultipleModel={debugWithMultipleModel}
multipleModelConfigs={multipleModelConfigs}
publishDisabled={publishDisabled}
publishLoading={publishLoading}
toolPublished={toolPublished}
inputs={inputs}
outputs={outputs}
onRefreshData={onRefreshData}
workflowToolAvailable={workflowToolAvailable}
hasTriggerNode={hasTriggerNode}
missingStartNode={missingStartNode}
startNodeLimitExceeded={startNodeLimitExceeded}
hasHumanInputNode={hasHumanInputNode}
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
formatTimeFromNow={formatTimeFromNow}
isAppAccessSet={isAppAccessSet}
isChatApp={isChatApp}
isGettingAppWhiteListSubjects={isGettingAppWhiteListSubjects}
isGettingUserCanAccessApp={isGettingUserCanAccessApp}
onOpenEmbedding={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
onOpenInExplore={handleOpenInExplore}
onPublish={handlePublish}
onPublishToMarketplace={handlePublishToMarketplace}
onRestore={handleRestore}
onShowAppAccessControl={() => setShowAppAccessControl(true)}
published={published}
publishingToMarketplace={publishingToMarketplace}
systemFeatures={systemFeatures}
upgradeHighlightStyle={upgradeHighlightStyle}
workflowToolDisabled={workflowToolDisabled}
workflowToolMessage={workflowToolMessage}
/>
</PortalToFollowElemContent>
<EmbeddedModal
siteInfo={appDetail?.site}

View File

@@ -0,0 +1,47 @@
import type { FC } from 'react'
import type { AppPublisherMenuContentProps } from './menu-content.types'
import { useTranslation } from 'react-i18next'
import { AccessModeDisplay } from './menu-content-shared'
type MenuContentAccessSectionProps = Pick<
AppPublisherMenuContentProps,
| 'appDetail'
| 'isAppAccessSet'
| 'onShowAppAccessControl'
| 'systemFeatures'
>
const MenuContentAccessSection: FC<MenuContentAccessSectionProps> = ({
appDetail,
isAppAccessSet,
onShowAppAccessControl,
systemFeatures,
}) => {
const { t } = useTranslation()
if (!systemFeatures.webapp_auth.enabled)
return null
return (
<div className="p-4 pt-3">
<div className="flex h-6 items-center">
<p className="text-text-tertiary system-xs-medium">{t('publishApp.title', { ns: 'app' })}</p>
</div>
<div
className="flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent"
onClick={onShowAppAccessControl}
>
<div className="flex grow items-center gap-x-1.5 overflow-hidden pr-1">
<AccessModeDisplay mode={appDetail?.access_mode} />
</div>
{!isAppAccessSet && <p className="shrink-0 text-text-tertiary system-xs-regular">{t('publishApp.notSet', { ns: 'app' })}</p>}
<div className="flex h-4 w-4 shrink-0 items-center justify-center">
<span className="i-ri-arrow-right-s-line h-4 w-4 text-text-quaternary" />
</div>
</div>
{!isAppAccessSet && <p className="mt-1 text-text-warning system-xs-regular">{t('publishApp.notSetDesc', { ns: 'app' })}</p>}
</div>
)
}
export default MenuContentAccessSection

View File

@@ -0,0 +1,155 @@
import type { FC } from 'react'
import type { AppPublisherMenuContentProps } from './menu-content.types'
import type { PublishWorkflowParams } from '@/types/workflow'
import { useTranslation } from 'react-i18next'
import { toast } from '@/app/components/base/ui/toast'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import { appDefaultIconBackground } from '@/config'
import { AppModeEnum } from '@/types/app'
import { SuggestedActionWithTooltip } from './menu-content-shared'
import { getBatchRunLink } from './menu-content.utils'
import SuggestedAction from './suggested-action'
type MenuContentActionsSectionProps = Pick<
AppPublisherMenuContentProps,
| 'appDetail'
| 'appURL'
| 'disabledFunctionButton'
| 'disabledFunctionTooltip'
| 'hasHumanInputNode'
| 'hasTriggerNode'
| 'inputs'
| 'missingStartNode'
| 'onOpenEmbedding'
| 'onOpenInExplore'
| 'onPublish'
| 'onRefreshData'
| 'outputs'
| 'published'
| 'publishedAt'
| 'toolPublished'
| 'workflowToolDisabled'
| 'workflowToolMessage'
>
const MenuContentActionsSection: FC<MenuContentActionsSectionProps> = ({
appDetail,
appURL,
disabledFunctionButton,
disabledFunctionTooltip,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
missingStartNode = false,
onOpenEmbedding,
onOpenInExplore,
onPublish,
onRefreshData,
outputs,
published,
publishedAt,
toolPublished,
workflowToolDisabled,
workflowToolMessage,
}) => {
const { t } = useTranslation()
if (hasTriggerNode)
return null
const mode = appDetail?.mode
const isBatchActionVisible = mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.COMPLETION
const apiReferenceDisabled = !publishedAt || missingStartNode
const apiReferenceTooltip = !publishedAt
? t('notPublishedYet', { ns: 'app' })
: missingStartNode
? t('noUserInputNode', { ns: 'app' })
: undefined
const handleOpenInExplore = () => {
if (publishedAt) {
onOpenInExplore()
return
}
toast.error(t('notPublishedYet', { ns: 'app' }))
}
const handleWorkflowToolPublish = async (params?: PublishWorkflowParams) => {
await onPublish(params)
}
return (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<SuggestedActionWithTooltip
className="flex-1"
disabled={disabledFunctionButton}
icon={<span className="i-ri-play-circle-line h-4 w-4" />}
link={appURL}
tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined}
>
{t('common.runApp', { ns: 'workflow' })}
</SuggestedActionWithTooltip>
{isBatchActionVisible
? (
<SuggestedActionWithTooltip
className="flex-1"
disabled={disabledFunctionButton}
icon={<span className="i-ri-play-list-2-line h-4 w-4" />}
link={getBatchRunLink(appURL)}
tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined}
>
{t('common.batchRunApp', { ns: 'workflow' })}
</SuggestedActionWithTooltip>
)
: (
<SuggestedAction
disabled={!publishedAt}
icon={<span className="i-custom-vender-line-development-code-browser h-4 w-4" />}
onClick={onOpenEmbedding}
>
{t('common.embedIntoSite', { ns: 'workflow' })}
</SuggestedAction>
)}
<SuggestedActionWithTooltip
className="flex-1"
disabled={disabledFunctionButton}
icon={<span className="i-ri-planet-line h-4 w-4" />}
onClick={handleOpenInExplore}
tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined}
>
{t('common.openInExplore', { ns: 'workflow' })}
</SuggestedActionWithTooltip>
<SuggestedActionWithTooltip
className="flex-1"
disabled={apiReferenceDisabled}
icon={<span className="i-ri-terminal-box-line h-4 w-4" />}
link="./develop"
tooltip={apiReferenceTooltip}
>
{t('common.accessAPIReference', { ns: 'workflow' })}
</SuggestedActionWithTooltip>
{mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && appDetail && (
<WorkflowToolConfigureButton
description={appDetail.description}
detailNeedUpdate={!!toolPublished && published}
disabled={workflowToolDisabled}
disabledReason={workflowToolMessage}
handlePublish={handleWorkflowToolPublish}
icon={{
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail.icon) || '🤖',
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail.icon_background) || appDefaultIconBackground,
}}
inputs={inputs}
name={appDetail.name}
onRefreshData={onRefreshData}
outputs={outputs}
published={!!toolPublished}
workflowAppId={appDetail.id}
/>
)}
</div>
)
}
export default MenuContentActionsSection

View File

@@ -0,0 +1,41 @@
import type { FC } from 'react'
import type { AppPublisherMenuContentProps } from './menu-content.types'
import { useTranslation } from 'react-i18next'
import SuggestedAction from './suggested-action'
type MenuContentMarketplaceSectionProps = Pick<
AppPublisherMenuContentProps,
| 'onPublishToMarketplace'
| 'publishingToMarketplace'
| 'systemFeatures'
>
const MenuContentMarketplaceSection: FC<MenuContentMarketplaceSectionProps> = ({
onPublishToMarketplace,
publishingToMarketplace,
systemFeatures,
}) => {
const { t } = useTranslation()
if (!systemFeatures.enable_creators_platform)
return null
return (
<div className="flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3">
<SuggestedAction
className="flex-1"
disabled={publishingToMarketplace}
icon={publishingToMarketplace
? <span className="i-ri-loader-2-line h-4 w-4 animate-spin" />
: <span className="i-ri-store-2-line h-4 w-4" />}
onClick={onPublishToMarketplace}
>
{publishingToMarketplace
? t('common.publishingToMarketplace', { ns: 'workflow' })
: t('common.publishToMarketplace', { ns: 'workflow' })}
</SuggestedAction>
</div>
)
}
export default MenuContentMarketplaceSection

View File

@@ -0,0 +1,130 @@
import type { FC } from 'react'
import type { AppPublisherMenuContentProps } from './menu-content.types'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
import ShortcutsName from '../../workflow/shortcuts-name'
import { PUBLISH_SHORTCUT } from './menu-content.utils'
import PublishWithMultipleModel from './publish-with-multiple-model'
type MenuContentPublishSectionProps = Pick<
AppPublisherMenuContentProps,
| 'debugWithMultipleModel'
| 'draftUpdatedAt'
| 'formatTimeFromNow'
| 'isChatApp'
| 'multipleModelConfigs'
| 'onPublish'
| 'onRestore'
| 'publishDisabled'
| 'published'
| 'publishedAt'
| 'publishLoading'
| 'startNodeLimitExceeded'
| 'upgradeHighlightStyle'
>
const MenuContentPublishSection: FC<MenuContentPublishSectionProps> = ({
debugWithMultipleModel = false,
draftUpdatedAt,
formatTimeFromNow,
isChatApp,
multipleModelConfigs = [],
onPublish,
onRestore,
publishDisabled = false,
published,
publishedAt,
publishLoading = false,
startNodeLimitExceeded = false,
upgradeHighlightStyle,
}) => {
const { t } = useTranslation()
return (
<div className="p-4 pt-3">
<div className="flex h-6 items-center text-text-tertiary system-xs-medium-uppercase">
{publishedAt
? t('common.latestPublished', { ns: 'workflow' })
: t('common.currentDraftUnpublished', { ns: 'workflow' })}
</div>
{publishedAt
? (
<div className="flex items-center justify-between">
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.publishedAt', { ns: 'workflow' })}
{' '}
{formatTimeFromNow(publishedAt)}
</div>
{isChatApp && (
<Button
variant="secondary-accent"
size="small"
onClick={onRestore}
disabled={published}
>
{t('common.restore', { ns: 'workflow' })}
</Button>
)}
</div>
)
: (
<div className="flex items-center text-text-secondary system-sm-medium">
{t('common.autoSaved', { ns: 'workflow' })}
{' '}
·
{Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
</div>
)}
{debugWithMultipleModel
? (
<PublishWithMultipleModel
multipleModelConfigs={multipleModelConfigs}
onSelect={item => onPublish(item)}
/>
)
: (
<>
<Button
variant="primary"
className="mt-3 w-full"
onClick={() => onPublish()}
disabled={publishDisabled || published || publishLoading}
loading={publishLoading}
>
{publishLoading
? t('common.publishing', { ns: 'workflow' })
: published
? t('common.published', { ns: 'workflow' })
: (
<div className="flex gap-1">
<span>{t('common.publishUpdate', { ns: 'workflow' })}</span>
<ShortcutsName keys={PUBLISH_SHORTCUT} bgColor="white" />
</div>
)}
</Button>
{startNodeLimitExceeded && (
<div className="mt-3 flex flex-col items-stretch">
<p
className="text-sm font-semibold leading-5 text-transparent"
style={upgradeHighlightStyle}
>
<span className="block">{t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })}</span>
<span className="block">{t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })}</span>
</p>
<p className="mt-1 text-xs leading-4 text-text-secondary">
{t('publishLimit.startNodeDesc', { ns: 'workflow' })}
</p>
<UpgradeBtn
isShort
className="mb-[12px] mt-[9px] h-[32px] w-[93px] self-start"
/>
</div>
)}
</>
)}
</div>
)
}
export default MenuContentPublishSection

View File

@@ -0,0 +1,72 @@
import type { FC, ReactNode } from 'react'
import type { SuggestedActionProps } from './suggested-action'
import type { I18nKeysByPrefix } from '@/types/i18n'
import { useTranslation } from 'react-i18next'
import { AccessMode } from '@/models/access-control'
import { Tooltip, TooltipContent, TooltipTrigger } from '../../base/ui/tooltip'
import SuggestedAction from './suggested-action'
type AccessModeLabel = I18nKeysByPrefix<'app', 'accessControlDialog.accessItems.'>
const ACCESS_MODE_MAP: Record<AccessMode, { label: AccessModeLabel, icon: string }> = {
[AccessMode.ORGANIZATION]: {
label: 'organization',
icon: 'i-ri-building-line',
},
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
label: 'specific',
icon: 'i-ri-lock-line',
},
[AccessMode.PUBLIC]: {
label: 'anyone',
icon: 'i-ri-global-line',
},
[AccessMode.EXTERNAL_MEMBERS]: {
label: 'external',
icon: 'i-ri-verified-badge-line',
},
}
export const AccessModeDisplay: FC<{ mode?: AccessMode }> = ({ mode }) => {
const { t } = useTranslation()
if (!mode || !ACCESS_MODE_MAP[mode])
return null
const { icon, label } = ACCESS_MODE_MAP[mode]
return (
<>
<span className={`${icon} h-4 w-4 shrink-0 text-text-secondary`} />
<div className="grow truncate">
<span className="text-text-secondary system-sm-medium">{t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })}</span>
</div>
</>
)
}
type SuggestedActionWithTooltipProps = SuggestedActionProps & {
tooltip?: ReactNode
}
export const SuggestedActionWithTooltip = ({
children,
tooltip,
...props
}: SuggestedActionWithTooltipProps) => {
const action = (
<SuggestedAction {...props}>
{children}
</SuggestedAction>
)
if (!tooltip)
return action
return (
<Tooltip>
<TooltipTrigger render={action} />
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
)
}

View File

@@ -0,0 +1,110 @@
import type { FC } from 'react'
import type { AppPublisherMenuContentProps } from './menu-content.types'
import Divider from '../../base/divider'
import Loading from '../../base/loading'
import MenuContentAccessSection from './menu-content-access-section'
import MenuContentActionsSection from './menu-content-actions-section'
import MenuContentMarketplaceSection from './menu-content-marketplace-section'
import MenuContentPublishSection from './menu-content-publish-section'
const AppPublisherMenuContent: FC<AppPublisherMenuContentProps> = ({
appDetail,
appURL,
debugWithMultipleModel = false,
disabledFunctionButton,
disabledFunctionTooltip,
draftUpdatedAt,
formatTimeFromNow,
hasHumanInputNode = false,
hasTriggerNode = false,
inputs,
isAppAccessSet,
isChatApp,
isGettingAppWhiteListSubjects,
isGettingUserCanAccessApp,
missingStartNode = false,
multipleModelConfigs = [],
onOpenEmbedding,
onOpenInExplore,
onPublish,
onPublishToMarketplace,
onRefreshData,
onRestore,
onShowAppAccessControl,
outputs,
publishDisabled = false,
published,
publishedAt,
publishingToMarketplace,
publishLoading = false,
startNodeLimitExceeded = false,
systemFeatures,
toolPublished,
upgradeHighlightStyle,
workflowToolAvailable: _workflowToolAvailable = true,
workflowToolDisabled,
workflowToolMessage,
}) => {
const showAccessLoading = systemFeatures.webapp_auth.enabled
&& (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects)
return (
<div className="w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5">
<MenuContentPublishSection
debugWithMultipleModel={debugWithMultipleModel}
draftUpdatedAt={draftUpdatedAt}
formatTimeFromNow={formatTimeFromNow}
isChatApp={isChatApp}
multipleModelConfigs={multipleModelConfigs}
onPublish={onPublish}
onRestore={onRestore}
publishDisabled={publishDisabled}
published={published}
publishedAt={publishedAt}
publishLoading={publishLoading}
startNodeLimitExceeded={startNodeLimitExceeded}
upgradeHighlightStyle={upgradeHighlightStyle}
/>
{showAccessLoading
? <div className="py-2"><Loading /></div>
: (
<>
<Divider className="my-0" />
<MenuContentAccessSection
appDetail={appDetail}
isAppAccessSet={isAppAccessSet}
onShowAppAccessControl={onShowAppAccessControl}
systemFeatures={systemFeatures}
/>
<MenuContentActionsSection
appDetail={appDetail}
appURL={appURL}
disabledFunctionButton={disabledFunctionButton}
disabledFunctionTooltip={disabledFunctionTooltip}
hasHumanInputNode={hasHumanInputNode}
hasTriggerNode={hasTriggerNode}
inputs={inputs}
missingStartNode={missingStartNode}
onOpenEmbedding={onOpenEmbedding}
onOpenInExplore={onOpenInExplore}
onPublish={onPublish}
onRefreshData={onRefreshData}
outputs={outputs}
published={published}
publishedAt={publishedAt}
toolPublished={toolPublished}
workflowToolDisabled={workflowToolDisabled}
workflowToolMessage={workflowToolMessage}
/>
<MenuContentMarketplaceSection
onPublishToMarketplace={onPublishToMarketplace}
publishingToMarketplace={publishingToMarketplace}
systemFeatures={systemFeatures}
/>
</>
)}
</div>
)
}
export default AppPublisherMenuContent

View File

@@ -0,0 +1,47 @@
import type { CSSProperties } from 'react'
import type { ModelAndParameter } from '../configuration/debug/types'
import type { AppPublisherProps } from './index'
import type { AppDetailResponse } from '@/models/app'
import type { SystemFeatures } from '@/types/feature'
import type { PublishWorkflowParams } from '@/types/workflow'
export type AppPublisherMenuContentProps = Pick<
AppPublisherProps,
| 'publishedAt'
| 'draftUpdatedAt'
| 'debugWithMultipleModel'
| 'multipleModelConfigs'
| 'publishDisabled'
| 'publishLoading'
| 'toolPublished'
| 'inputs'
| 'outputs'
| 'onRefreshData'
| 'workflowToolAvailable'
| 'hasTriggerNode'
| 'missingStartNode'
| 'startNodeLimitExceeded'
| 'hasHumanInputNode'
> & {
appDetail?: AppDetailResponse
appURL: string
disabledFunctionButton: boolean
disabledFunctionTooltip?: string
formatTimeFromNow: (time: number) => string
isAppAccessSet: boolean
isChatApp: boolean
isGettingAppWhiteListSubjects: boolean
isGettingUserCanAccessApp: boolean
onOpenEmbedding: () => void
onOpenInExplore: () => void
onPublish: (params?: ModelAndParameter | PublishWorkflowParams) => Promise<void> | void
onPublishToMarketplace: () => Promise<void> | void
onRestore: () => Promise<void> | void
onShowAppAccessControl: () => void
published: boolean
publishingToMarketplace: boolean
systemFeatures: SystemFeatures
upgradeHighlightStyle: CSSProperties
workflowToolDisabled: boolean
workflowToolMessage?: string
}

View File

@@ -0,0 +1,3 @@
export const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
export const getBatchRunLink = (appURL: string) => `${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react'
import type { ExternalDataToolProvider as Provider } from './helpers'
import type {
CodeBasedExtensionItem,
ExternalDataTool,
} from '@/models/common'
import { noop } from 'es-toolkit/function'
@@ -16,21 +16,22 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { toast } from '@/app/components/base/ui/toast'
import ApiBasedExtensionSelector from '@/app/components/header/account-setting/api-based-extension-page/selector'
import { useDocLink, useLocale } from '@/context/i18n'
import { LanguagesSupported } from '@/i18n-config/language'
import { useCodeBasedExtensions } from '@/service/use-common'
import {
formatExternalDataToolForSave,
getExternalDataToolDefaultConfig,
getExternalDataToolValidationError,
getInitialExternalDataTool,
SYSTEM_EXTERNAL_DATA_TOOL_TYPES,
} from './helpers'
const systemTypes = ['api']
type ExternalDataToolModalProps = {
data: ExternalDataTool
onCancel: () => void
onSave: (externalDataTool: ExternalDataTool) => void
onValidateBeforeSave?: (externalDataTool: ExternalDataTool) => boolean
}
type Provider = {
key: string
name: string
form_schema?: CodeBasedExtensionItem['form_schema']
}
const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
data,
onCancel,
@@ -40,7 +41,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const { t } = useTranslation()
const docLink = useDocLink()
const locale = useLocale()
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
const [localeData, setLocaleData] = useState(getInitialExternalDataTool(data))
const [showEmojiPicker, setShowEmojiPicker] = useState(false)
const { data: codeBasedExtensionList } = useCodeBasedExtensions('external_data_tool')
@@ -64,19 +65,10 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
const currentProvider = providers.find(provider => provider.key === localeData.type)
const handleDataTypeChange = (type: string) => {
let config: undefined | Record<string, any>
const currProvider = providers.find(provider => provider.key === type)
if (systemTypes.findIndex(t => t === type) < 0 && currProvider?.form_schema) {
config = currProvider?.form_schema.reduce((prev, next) => {
prev[next.variable] = next.default
return prev
}, {} as Record<string, any>)
}
setLocaleData({
...localeData,
type,
config,
config: getExternalDataToolDefaultConfig(type, providers),
})
}
@@ -107,70 +99,30 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
})
}
const formatData = (originData: ExternalDataTool) => {
const { type, config } = originData
const params: Record<string, string | undefined> = {}
if (type === 'api')
params.api_based_extension_id = config?.api_based_extension_id
if (systemTypes.findIndex(t => t === type) < 0 && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((form) => {
params[form.variable] = config?.[form.variable]
})
}
return {
...originData,
type,
enabled: data.type ? data.enabled : true,
config: {
...params,
},
}
}
const handleSave = () => {
if (!localeData.type) {
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.toolType.title', { ns: 'appDebug' }) }))
const validationError = getExternalDataToolValidationError({
localeData,
currentProvider,
locale,
})
if (validationError) {
const translatedLabel = validationError.label.includes('.')
? t(validationError.label, validationError.label, { ns: 'appDebug' })
: validationError.label
if (validationError.kind === 'invalid')
toast.error(t('varKeyError.notValid', { ns: 'appDebug', key: translatedLabel }))
else
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: translatedLabel }))
return
}
if (!localeData.label) {
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.name.title', { ns: 'appDebug' }) }))
return
}
if (!localeData.variable) {
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }))
return
}
if (localeData.variable && !/^[a-z_]\w{0,29}$/i.test(localeData.variable)) {
toast.error(t('varKeyError.notValid', { ns: 'appDebug', key: t('feature.tools.modal.variableName.title', { ns: 'appDebug' }) }))
return
}
if (localeData.type === 'api' && !localeData.config?.api_based_extension_id) {
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? 'API Extension' : 'API 扩展' }))
return
}
if (systemTypes.findIndex(t => t === localeData.type) < 0 && currentProvider?.form_schema) {
for (let i = 0; i < currentProvider.form_schema.length; i++) {
if (!localeData.config?.[currentProvider.form_schema[i].variable] && currentProvider.form_schema[i].required) {
toast.error(t('errorMessage.valueOfVarRequired', { ns: 'appDebug', key: locale !== LanguagesSupported[1] ? currentProvider.form_schema[i].label['en-US'] : currentProvider.form_schema[i].label['zh-Hans'] }))
return
}
}
}
const formattedData = formatData(localeData)
const formattedData = formatExternalDataToolForSave(localeData, currentProvider, data.type ? !!data.enabled : true)
if (onValidateBeforeSave && !onValidateBeforeSave(formattedData))
return
onSave(formatData(formattedData))
onSave(formatExternalDataToolForSave(formattedData, currentProvider, !!formattedData.enabled))
}
const action = data.type ? t('operation.edit', { ns: 'common' }) : t('operation.add', { ns: 'common' })
@@ -258,7 +210,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
)
}
{
systemTypes.findIndex(t => t === localeData.type) < 0
!SYSTEM_EXTERNAL_DATA_TOOL_TYPES.includes(localeData.type as typeof SYSTEM_EXTERNAL_DATA_TOOL_TYPES[number])
&& currentProvider?.form_schema
&& (
<FormGeneration

View File

@@ -0,0 +1,184 @@
import type {
CodeBasedExtensionItem,
ExternalDataTool,
} from '@/models/common'
import { LanguagesSupported } from '@/i18n-config/language'
export const SYSTEM_EXTERNAL_DATA_TOOL_TYPES = ['api'] as const
export type ExternalDataToolProvider = {
key: string
name: string
form_schema?: CodeBasedExtensionItem['form_schema']
}
type PromptVariableLike = {
key: string
}
type ExternalDataToolValidationError = {
kind: 'required' | 'invalid'
label: string
}
const isSystemExternalDataToolType = (type?: string) => (
!!type && SYSTEM_EXTERNAL_DATA_TOOL_TYPES.includes(type as typeof SYSTEM_EXTERNAL_DATA_TOOL_TYPES[number])
)
const getLocalizedLabel = (
label: Record<string, string> | undefined,
locale: string,
) => {
if (!label)
return ''
if (locale === LanguagesSupported[1])
return label['zh-Hans'] || label['en-US'] || ''
return label['en-US'] || label['zh-Hans'] || ''
}
export const getInitialExternalDataTool = (data: ExternalDataTool): ExternalDataTool => (
data.type ? data : { ...data, type: 'api' }
)
export const getExternalDataToolDefaultConfig = (
type: string,
providers: ExternalDataToolProvider[],
) => {
const provider = providers.find(item => item.key === type)
if (isSystemExternalDataToolType(type) || !provider?.form_schema)
return undefined
return provider.form_schema.reduce<Record<string, unknown>>((acc, field) => {
acc[field.variable] = field.default
return acc
}, {})
}
export const upsertExternalDataTool = (
externalDataTools: ExternalDataTool[],
externalDataTool: ExternalDataTool,
index: number,
) => {
if (index > -1) {
return [
...externalDataTools.slice(0, index),
externalDataTool,
...externalDataTools.slice(index + 1),
]
}
return [...externalDataTools, externalDataTool]
}
export const removeExternalDataTool = (
externalDataTools: ExternalDataTool[],
index: number,
) => {
return [
...externalDataTools.slice(0, index),
...externalDataTools.slice(index + 1),
]
}
export const findExternalDataToolVariableConflict = (
variable: string | undefined,
externalDataTools: ExternalDataTool[],
promptVariables: PromptVariableLike[],
index: number,
) => {
if (!variable)
return undefined
const promptVariable = promptVariables.find(item => item.key === variable)
if (promptVariable)
return promptVariable.key
return externalDataTools
.filter((_item, toolIndex) => toolIndex !== index)
.find(item => item.variable === variable)
?.variable
}
export const formatExternalDataToolForSave = (
originData: ExternalDataTool,
currentProvider: ExternalDataToolProvider | undefined,
fallbackEnabled: boolean,
) => {
const { type, config } = originData
const params: Record<string, unknown> = {}
if (type === 'api')
params.api_based_extension_id = config?.api_based_extension_id
if (!isSystemExternalDataToolType(type) && currentProvider?.form_schema) {
currentProvider.form_schema.forEach((field) => {
params[field.variable] = config?.[field.variable]
})
}
return {
...originData,
type,
enabled: fallbackEnabled,
config: params,
}
}
export const getExternalDataToolValidationError = ({
localeData,
currentProvider,
locale,
}: {
localeData: ExternalDataTool
currentProvider?: ExternalDataToolProvider
locale: string
}): ExternalDataToolValidationError | null => {
if (!localeData.type) {
return {
kind: 'required',
label: 'feature.tools.modal.toolType.title',
}
}
if (!localeData.label) {
return {
kind: 'required',
label: 'feature.tools.modal.name.title',
}
}
if (!localeData.variable) {
return {
kind: 'required',
label: 'feature.tools.modal.variableName.title',
}
}
if (!/^[a-z_]\w{0,29}$/i.test(localeData.variable)) {
return {
kind: 'invalid',
label: 'feature.tools.modal.variableName.title',
}
}
if (localeData.type === 'api' && !localeData.config?.api_based_extension_id) {
return {
kind: 'required',
label: locale === LanguagesSupported[1] ? 'API 扩展' : 'API Extension',
}
}
if (!isSystemExternalDataToolType(localeData.type) && currentProvider?.form_schema) {
const requiredField = currentProvider.form_schema.find(field => !localeData.config?.[field.variable] && field.required)
if (requiredField) {
return {
kind: 'required',
label: getLocalizedLabel(requiredField.label, locale),
}
}
}
return null
}

View File

@@ -22,6 +22,11 @@ import {
} from '@/app/components/base/ui/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { useModalContext } from '@/context/modal-context'
import {
findExternalDataToolVariableConflict,
removeExternalDataTool,
upsertExternalDataTool,
} from './helpers'
const Tools = () => {
const { t } = useTranslation()
@@ -35,42 +40,20 @@ const Tools = () => {
const [copied, setCopied] = useState(false)
const handleSaveExternalDataToolModal = (externalDataTool: ExternalDataTool, index: number) => {
if (index > -1) {
setExternalDataToolsConfig([
...externalDataToolsConfig.slice(0, index),
externalDataTool,
...externalDataToolsConfig.slice(index + 1),
])
}
else {
setExternalDataToolsConfig([...externalDataToolsConfig, externalDataTool])
}
setExternalDataToolsConfig(upsertExternalDataTool(externalDataToolsConfig, externalDataTool, index))
}
const handleValidateBeforeSaveExternalDataToolModal = (newExternalDataTool: ExternalDataTool, index: number) => {
const promptVariables = modelConfig?.configs?.prompt_variables || []
for (let i = 0; i < promptVariables.length; i++) {
if (promptVariables[i].key === newExternalDataTool.variable) {
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: promptVariables[i].key }))
return false
}
}
let existedExternalDataTools = []
if (index > -1) {
existedExternalDataTools = [
...externalDataToolsConfig.slice(0, index),
...externalDataToolsConfig.slice(index + 1),
]
}
else {
existedExternalDataTools = [...externalDataToolsConfig]
}
for (let i = 0; i < existedExternalDataTools.length; i++) {
if (existedExternalDataTools[i].variable === newExternalDataTool.variable) {
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: existedExternalDataTools[i].variable }))
return false
}
const conflictKey = findExternalDataToolVariableConflict(
newExternalDataTool.variable,
externalDataToolsConfig,
promptVariables,
index,
)
if (conflictKey) {
toast.error(t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: conflictKey }))
return false
}
return true
@@ -181,7 +164,7 @@ const Tools = () => {
</div>
<div
className="group/action hidden h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-[#FEE4E2] group-hover:flex"
onClick={() => setExternalDataToolsConfig([...externalDataToolsConfig.slice(0, index), ...externalDataToolsConfig.slice(index + 1)])}
onClick={() => setExternalDataToolsConfig(removeExternalDataTool(externalDataToolsConfig, index))}
>
<RiDeleteBinLine className="h-4 w-4 text-gray-500 group-hover/action:text-[#D92D20]" />
</div>

View File

@@ -0,0 +1,141 @@
'use client'
import type { FeedbackType } from '@/app/components/base/chat/chat/type'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import {
RiBookmark3Line,
RiClipboardLine,
RiFileList3Line,
RiResetLeftLine,
RiSparklingLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
import NewAudioButton from '@/app/components/base/new-audio-button'
import { AppSourceType } from '@/service/share'
type GenerationItemActionBarProps = {
appSourceType: AppSourceType
currentTab: string
depth: number
feedback?: FeedbackType
isError: boolean
isInWebApp: boolean
isResponding?: boolean
isShowTextToSpeech?: boolean
isTryApp: boolean
isWorkflow?: boolean
messageId?: string | null
moreLikeThis?: boolean
onCopy: () => void
onFeedback?: (feedback: FeedbackType) => void
onMoreLikeThis: () => void
onOpenLogModal: () => void
onRetry: () => void
onSave?: (messageId: string) => void
supportFeedback?: boolean
voice?: string
workflowProcessData?: WorkflowProcess
}
const MAX_DEPTH = 3
const GenerationItemActionBar = ({
appSourceType,
currentTab,
depth,
feedback,
isError,
isInWebApp,
isResponding,
isShowTextToSpeech,
isTryApp,
isWorkflow,
messageId,
moreLikeThis,
onCopy,
onFeedback,
onMoreLikeThis,
onOpenLogModal,
onRetry,
onSave,
supportFeedback,
voice,
workflowProcessData,
}: GenerationItemActionBarProps) => {
const { t } = useTranslation()
return (
<>
{!isInWebApp && appSourceType !== AppSourceType.installedApp && !isResponding && (
<div className="ml-1 flex 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">
<ActionButton disabled={isError || !messageId} onClick={onOpenLogModal}>
<RiFileList3Line className="h-4 w-4" />
</ActionButton>
</div>
)}
<div className="ml-1 flex 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">
{moreLikeThis && !isTryApp && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={onMoreLikeThis}>
<RiSparklingLine className="h-4 w-4" />
</ActionButton>
)}
{isShowTextToSpeech && !isTryApp && messageId && (
<NewAudioButton
id={messageId}
voice={voice}
/>
)}
{((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
<ActionButton
disabled={isError || !messageId}
onClick={onCopy}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && isError && (
<ActionButton onClick={onRetry}>
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && !isWorkflow && !isTryApp && (
<ActionButton disabled={isError || !messageId} onClick={() => messageId && onSave?.(messageId)}>
<RiBookmark3Line className="h-4 w-4" />
</ActionButton>
)}
</div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
<div className="ml-1 flex 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">
{!feedback?.rating && (
<>
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
<RiThumbUpLine className="h-4 w-4" />
</ActionButton>
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
<RiThumbDownLine className="h-4 w-4" />
</ActionButton>
</>
)}
{feedback?.rating === 'like' && (
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbUpLine className="h-4 w-4" />
</ActionButton>
)}
{feedback?.rating === 'dislike' && (
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbDownLine className="h-4 w-4" />
</ActionButton>
)}
</div>
)}
{depth > MAX_DEPTH && (
<span className="sr-only">{t('errorMessage.waitForResponse', { ns: 'appDebug' })}</span>
)}
</>
)
}
export default GenerationItemActionBar

View File

@@ -4,15 +4,8 @@ import type { FeedbackType, IChatItem } from '@/app/components/base/chat/chat/ty
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import {
RiBookmark3Line,
RiClipboardLine,
RiFileList3Line,
RiPlayList2Line,
RiResetLeftLine,
RiSparklingFill,
RiSparklingLine,
RiThumbDownLine,
RiThumbUpLine,
} from '@remixicon/react'
import { useBoolean } from 'ahooks'
import copy from 'copy-to-clipboard'
@@ -20,21 +13,17 @@ import * as React 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'
import { Markdown } from '@/app/components/base/markdown'
import NewAudioButton from '@/app/components/base/new-audio-button'
import { toast } from '@/app/components/base/ui/toast'
import { useParams } from '@/next/navigation'
import { fetchTextGenerationMessage } from '@/service/debug'
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'
import GenerationItemActionBar from './action-bar'
import WorkflowContent from './workflow-content'
const MAX_DEPTH = 3
@@ -44,7 +33,7 @@ export type IGenerationItemProps = {
className?: string
isError: boolean
onRetry: () => void
content: any
content: unknown
messageId?: string | null
conversationId?: string
isLoading?: boolean
@@ -123,7 +112,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const [isQuerying, { setTrue: startQuerying, setFalse: stopQuerying }] = useBoolean(false)
const childProps = {
const childProps: IGenerationItemProps = {
isInWebApp,
content: completionRes,
messageId: childMessageId,
@@ -141,6 +130,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
isWorkflow,
siteInfo,
taskId,
isError: false,
onRetry,
}
const handleMoreLikeThis = async () => {
@@ -207,7 +198,6 @@ const GenerationItem: FC<IGenerationItemProps> = ({
}
const [currentTab, setCurrentTab] = useState<string>('DETAIL')
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)
}
@@ -224,6 +214,15 @@ const GenerationItem: FC<IGenerationItemProps> = ({
await submitHumanInputForm(formToken, formData)
}, [appSourceType])
const handleCopy = useCallback(() => {
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}, [content, isWorkflow, t, workflowProcessData?.resultText])
return (
<>
<div className={cn('relative', !isTop && 'mt-3', className)}>
@@ -240,71 +239,17 @@ const GenerationItem: FC<IGenerationItemProps> = ({
>
{workflowProcessData && (
<>
<div className={cn(
'p-3',
showResultTabs && 'border-b border-divider-subtle',
)}
>
{taskId && (
<div className={cn('mb-2 flex items-center text-text-accent-secondary system-2xs-medium-uppercase', isError && 'text-text-destructive')}>
<RiPlayList2Line className="mr-1 h-3 w-3" />
<span>{t('generation.execution', { ns: 'share' })}</span>
<span className="px-1">·</span>
<span>{taskId}</span>
</div>
)}
{siteInfo && workflowProcessData && (
<WorkflowProcessItem
data={workflowProcessData}
expand={workflowProcessData.expand}
hideProcessDetail={hideProcessDetail}
hideInfo={hideProcessDetail}
readonly={!siteInfo.show_workflow_steps}
/>
)}
{showResultTabs && (
<div className="flex items-center space-x-6 px-1">
<div
className={cn(
'cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('RESULT')}
>
{t('result', { ns: 'runLog' })}
</div>
<div
className={cn(
'cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => switchTab('DETAIL')}
>
{t('detail', { ns: 'runLog' })}
</div>
</div>
)}
</div>
{!isError && (
<>
{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} />
</>
)}
<WorkflowContent
content={content}
currentTab={currentTab}
hideProcessDetail={hideProcessDetail}
isError={isError}
onSubmitHumanInputForm={handleSubmitHumanInputForm}
onSwitchTab={switchTab}
siteInfo={siteInfo}
taskId={taskId}
workflowProcessData={workflowProcessData}
/>
</>
)}
{!workflowProcessData && taskId && (
@@ -327,88 +272,41 @@ const GenerationItem: FC<IGenerationItemProps> = ({
{/* meta data */}
<div className={cn(
'relative mt-1 h-4 px-4 text-text-quaternary system-xs-regular',
isMobile && ((childMessageId || isQuerying) && depth < 3) && 'pl-10',
isMobile && ((childMessageId || isQuerying) && depth < MAX_DEPTH) && 'pl-10',
)}
>
{!isWorkflow && (
<span>
{content?.length}
{typeof content === 'string' ? content.length : 0}
{' '}
{t('unit.char', { ns: 'common' })}
</span>
)}
{/* action buttons */}
<div className="absolute bottom-1 right-2 flex items-center">
{!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
<div className="ml-1 flex 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">
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
<RiFileList3Line className="h-4 w-4" />
{/* <div>{t('common.operation.log')}</div> */}
</ActionButton>
</div>
)}
<div className="ml-1 flex 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">
{moreLikeThis && !isTryApp && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
<RiSparklingLine className="h-4 w-4" />
</ActionButton>
)}
{isShowTextToSpeech && !isTryApp && (
<NewAudioButton
id={messageId!}
voice={config?.text_to_speech?.voice}
/>
)}
{((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
<ActionButton
disabled={isError || !messageId}
onClick={() => {
const copyContent = isWorkflow ? workflowProcessData?.resultText : content
if (typeof copyContent === 'string')
copy(copyContent)
else
copy(JSON.stringify(copyContent))
toast.success(t('actionMsg.copySuccessfully', { ns: 'common' }))
}}
>
<RiClipboardLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && isError && (
<ActionButton onClick={onRetry}>
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && !isWorkflow && !isTryApp && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className="h-4 w-4" />
</ActionButton>
)}
</div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
<div className="ml-1 flex 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">
{!feedback?.rating && (
<>
<ActionButton onClick={() => onFeedback?.({ rating: 'like' })}>
<RiThumbUpLine className="h-4 w-4" />
</ActionButton>
<ActionButton onClick={() => onFeedback?.({ rating: 'dislike' })}>
<RiThumbDownLine className="h-4 w-4" />
</ActionButton>
</>
)}
{feedback?.rating === 'like' && (
<ActionButton state={ActionButtonState.Active} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbUpLine className="h-4 w-4" />
</ActionButton>
)}
{feedback?.rating === 'dislike' && (
<ActionButton state={ActionButtonState.Destructive} onClick={() => onFeedback?.({ rating: null })}>
<RiThumbDownLine className="h-4 w-4" />
</ActionButton>
)}
</div>
)}
<GenerationItemActionBar
appSourceType={appSourceType}
currentTab={currentTab}
depth={depth}
feedback={feedback}
isError={isError}
isInWebApp={isInWebApp}
isResponding={isResponding}
isShowTextToSpeech={isShowTextToSpeech}
isTryApp={isTryApp}
isWorkflow={isWorkflow}
messageId={messageId}
moreLikeThis={moreLikeThis}
onCopy={handleCopy}
onFeedback={onFeedback}
onMoreLikeThis={handleMoreLikeThis}
onOpenLogModal={handleOpenLogModal}
onRetry={onRetry}
onSave={onSave}
supportFeedback={supportFeedback}
voice={config?.text_to_speech?.voice}
workflowProcessData={workflowProcessData}
/>
</div>
</div>
{/* more like this elements */}
@@ -431,8 +329,8 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</>
)}
</div>
{((childMessageId || isQuerying) && depth < 3) && (
<GenerationItem {...childProps as any} />
{((childMessageId || isQuerying) && depth < MAX_DEPTH) && (
<GenerationItem {...childProps} />
)}
</>
)

View File

@@ -0,0 +1,116 @@
'use client'
import type { FC } from 'react'
import type { WorkflowProcess } from '@/app/components/base/chat/types'
import type { SiteInfo } from '@/models/share'
import {
RiPlayList2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
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 { cn } from '@/utils/classnames'
import ResultTab from './result-tab'
type WorkflowContentProps = {
content: unknown
currentTab: string
hideProcessDetail?: boolean
isError: boolean
onSubmitHumanInputForm: (formToken: string, formData: { inputs: Record<string, string>, action: string }) => Promise<void>
onSwitchTab: (tab: string) => void
siteInfo: SiteInfo | null
taskId?: string
workflowProcessData: WorkflowProcess
}
const WorkflowContent: FC<WorkflowContentProps> = ({
content,
currentTab,
hideProcessDetail,
isError,
onSubmitHumanInputForm,
onSwitchTab,
siteInfo,
taskId,
workflowProcessData,
}) => {
const { t } = useTranslation()
const showResultTabs = !!workflowProcessData.resultText
|| !!workflowProcessData.files?.length
|| !!workflowProcessData.humanInputFormDataList?.length
|| !!workflowProcessData.humanInputFilledFormDataList?.length
return (
<>
<div className={cn(
'p-3',
showResultTabs && 'border-b border-divider-subtle',
)}
>
{taskId && (
<div className={cn('mb-2 flex items-center text-text-accent-secondary system-2xs-medium-uppercase', isError && 'text-text-destructive')}>
<RiPlayList2Line className="mr-1 h-3 w-3" />
<span>{t('generation.execution', { ns: 'share' })}</span>
<span className="px-1">·</span>
<span>{taskId}</span>
</div>
)}
{siteInfo && (
<WorkflowProcessItem
data={workflowProcessData}
expand={workflowProcessData.expand}
hideProcessDetail={hideProcessDetail}
hideInfo={hideProcessDetail}
readonly={!siteInfo.show_workflow_steps}
/>
)}
{showResultTabs && (
<div className="flex items-center space-x-6 px-1">
<div
className={cn(
'cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'RESULT' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => onSwitchTab('RESULT')}
>
{t('result', { ns: 'runLog' })}
</div>
<div
className={cn(
'cursor-pointer border-b-2 border-transparent py-3 text-text-tertiary system-sm-semibold-uppercase',
currentTab === 'DETAIL' && 'border-util-colors-blue-brand-blue-brand-600 text-text-primary',
)}
onClick={() => onSwitchTab('DETAIL')}
>
{t('detail', { ns: 'runLog' })}
</div>
</div>
)}
</div>
{!isError && (
<>
{currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && (
<div className="px-4 pt-4">
<HumanInputFormList
humanInputFormDataList={workflowProcessData.humanInputFormDataList}
onHumanInputFormSubmit={onSubmitHumanInputForm}
/>
</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} />
</>
)}
</>
)
}
export default WorkflowContent