From 1907ea6ef934fefe6cc9d9ff34b460b5196911fa Mon Sep 17 00:00:00 2001 From: CodingOnStar Date: Fri, 27 Mar 2026 15:04:23 +0800 Subject: [PATCH] refactor(web): split app publisher menu content into sections and add tests --- .../__tests__/menu-content.spec.tsx | 244 +++++++++++++ .../components/app/app-publisher/index.tsx | 321 +++--------------- .../menu-content-access-section.tsx | 47 +++ .../menu-content-actions-section.tsx | 155 +++++++++ .../menu-content-marketplace-section.tsx | 41 +++ .../menu-content-publish-section.tsx | 130 +++++++ .../app/app-publisher/menu-content-shared.tsx | 72 ++++ .../app/app-publisher/menu-content.tsx | 110 ++++++ .../app/app-publisher/menu-content.types.ts | 47 +++ .../app/app-publisher/menu-content.utils.ts | 3 + .../tools/external-data-tool-modal.tsx | 104 ++---- .../app/configuration/tools/helpers.ts | 184 ++++++++++ .../app/configuration/tools/index.tsx | 51 +-- .../app/text-generate/item/action-bar.tsx | 141 ++++++++ .../app/text-generate/item/index.tsx | 208 +++--------- .../text-generate/item/workflow-content.tsx | 116 +++++++ 16 files changed, 1437 insertions(+), 537 deletions(-) create mode 100644 web/app/components/app/app-publisher/__tests__/menu-content.spec.tsx create mode 100644 web/app/components/app/app-publisher/menu-content-access-section.tsx create mode 100644 web/app/components/app/app-publisher/menu-content-actions-section.tsx create mode 100644 web/app/components/app/app-publisher/menu-content-marketplace-section.tsx create mode 100644 web/app/components/app/app-publisher/menu-content-publish-section.tsx create mode 100644 web/app/components/app/app-publisher/menu-content-shared.tsx create mode 100644 web/app/components/app/app-publisher/menu-content.tsx create mode 100644 web/app/components/app/app-publisher/menu-content.types.ts create mode 100644 web/app/components/app/app-publisher/menu-content.utils.ts create mode 100644 web/app/components/app/configuration/tools/helpers.ts create mode 100644 web/app/components/app/text-generate/item/action-bar.tsx create mode 100644 web/app/components/app/text-generate/item/workflow-content.tsx diff --git a/web/app/components/app/app-publisher/__tests__/menu-content.spec.tsx b/web/app/components/app/app-publisher/__tests__/menu-content.spec.tsx new file mode 100644 index 0000000000..939725d97a --- /dev/null +++ b/web/app/components/app/app-publisher/__tests__/menu-content.spec.tsx @@ -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: () =>
upgrade-btn
, +})) + +vi.mock('@/app/components/tools/workflow-tool/configure-button', () => ({ + default: () =>
workflow-tool-configure-button
, +})) + +const createSystemFeatures = (overrides: Partial = {}): 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 => ({ + 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 => ({ + 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 = {}) => { + return render() +} + +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() + }) +}) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index 0674a2a977..fc23fbe080 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -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.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 ( - <> - -
- {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })} -
- - ) -} - 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 = ({ -
-
-
- {publishedAt ? t('common.latestPublished', { ns: 'workflow' }) : t('common.currentDraftUnpublished', { ns: 'workflow' })} -
- {publishedAt - ? ( -
-
- {t('common.publishedAt', { ns: 'workflow' })} - {' '} - {formatTimeFromNow(publishedAt)} -
- {isChatApp && ( - - )} -
- ) - : ( -
- {t('common.autoSaved', { ns: 'workflow' })} - {' '} - · - {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} -
- )} - {debugWithMultipleModel - ? ( - handlePublish(item)} - // textGenerationModelList={textGenerationModelList} - /> - ) - : ( - <> - - {showStartNodeLimitHint && ( -
-

- {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} - {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} -

-

- {t('publishLimit.startNodeDesc', { ns: 'workflow' })} -

- -
- )} - - )} -
- {(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects)) - ?
- : ( - <> - - {systemFeatures.webapp_auth.enabled && ( -
-
-

{t('publishApp.title', { ns: 'app' })}

-
-
{ - setShowAppAccessControl(true) - }} - > -
- -
- {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

} -
- -
-
- {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

} -
- )} - { - // Hide run/batch run app buttons when there is a trigger node. - !hasTriggerNode && ( -
- - } - > - {t('common.runApp', { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION - ? ( - - } - > - {t('common.batchRunApp', { ns: 'workflow' })} - - - ) - : ( - { - setEmbeddingModalOpen(true) - handleTrigger() - }} - disabled={!publishedAt} - icon={} - > - {t('common.embedIntoSite', { ns: 'workflow' })} - - )} - - { - if (publishedAt) - handleOpenInExplore() - }} - disabled={disabledFunctionButton} - icon={} - > - {t('common.openInExplore', { ns: 'workflow' })} - - - - } - > - {t('common.accessAPIReference', { ns: 'workflow' })} - - - {appDetail?.mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && ( - - )} -
- ) - } - {systemFeatures.enable_creators_platform && ( -
- - : } - > - {publishingToMarketplace - ? t('common.publishingToMarketplace', { ns: 'workflow' }) - : t('common.publishToMarketplace', { ns: 'workflow' })} - -
- )} - - )} -
+ { + 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} + />
+ +const MenuContentAccessSection: FC = ({ + appDetail, + isAppAccessSet, + onShowAppAccessControl, + systemFeatures, +}) => { + const { t } = useTranslation() + + if (!systemFeatures.webapp_auth.enabled) + return null + + return ( +
+
+

{t('publishApp.title', { ns: 'app' })}

+
+
+
+ +
+ {!isAppAccessSet &&

{t('publishApp.notSet', { ns: 'app' })}

} +
+ +
+
+ {!isAppAccessSet &&

{t('publishApp.notSetDesc', { ns: 'app' })}

} +
+ ) +} + +export default MenuContentAccessSection diff --git a/web/app/components/app/app-publisher/menu-content-actions-section.tsx b/web/app/components/app/app-publisher/menu-content-actions-section.tsx new file mode 100644 index 0000000000..7ad015ba86 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content-actions-section.tsx @@ -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 = ({ + 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 ( +
+ } + link={appURL} + tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined} + > + {t('common.runApp', { ns: 'workflow' })} + + {isBatchActionVisible + ? ( + } + link={getBatchRunLink(appURL)} + tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined} + > + {t('common.batchRunApp', { ns: 'workflow' })} + + ) + : ( + } + onClick={onOpenEmbedding} + > + {t('common.embedIntoSite', { ns: 'workflow' })} + + )} + } + onClick={handleOpenInExplore} + tooltip={disabledFunctionButton ? disabledFunctionTooltip : undefined} + > + {t('common.openInExplore', { ns: 'workflow' })} + + } + link="./develop" + tooltip={apiReferenceTooltip} + > + {t('common.accessAPIReference', { ns: 'workflow' })} + + {mode === AppModeEnum.WORKFLOW && !hasHumanInputNode && appDetail && ( + + )} +
+ ) +} + +export default MenuContentActionsSection diff --git a/web/app/components/app/app-publisher/menu-content-marketplace-section.tsx b/web/app/components/app/app-publisher/menu-content-marketplace-section.tsx new file mode 100644 index 0000000000..c9088ae0f5 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content-marketplace-section.tsx @@ -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 = ({ + onPublishToMarketplace, + publishingToMarketplace, + systemFeatures, +}) => { + const { t } = useTranslation() + + if (!systemFeatures.enable_creators_platform) + return null + + return ( +
+ + : } + onClick={onPublishToMarketplace} + > + {publishingToMarketplace + ? t('common.publishingToMarketplace', { ns: 'workflow' }) + : t('common.publishToMarketplace', { ns: 'workflow' })} + +
+ ) +} + +export default MenuContentMarketplaceSection diff --git a/web/app/components/app/app-publisher/menu-content-publish-section.tsx b/web/app/components/app/app-publisher/menu-content-publish-section.tsx new file mode 100644 index 0000000000..6fb7513313 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content-publish-section.tsx @@ -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 = ({ + debugWithMultipleModel = false, + draftUpdatedAt, + formatTimeFromNow, + isChatApp, + multipleModelConfigs = [], + onPublish, + onRestore, + publishDisabled = false, + published, + publishedAt, + publishLoading = false, + startNodeLimitExceeded = false, + upgradeHighlightStyle, +}) => { + const { t } = useTranslation() + + return ( +
+
+ {publishedAt + ? t('common.latestPublished', { ns: 'workflow' }) + : t('common.currentDraftUnpublished', { ns: 'workflow' })} +
+ {publishedAt + ? ( +
+
+ {t('common.publishedAt', { ns: 'workflow' })} + {' '} + {formatTimeFromNow(publishedAt)} +
+ {isChatApp && ( + + )} +
+ ) + : ( +
+ {t('common.autoSaved', { ns: 'workflow' })} + {' '} + · + {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)} +
+ )} + {debugWithMultipleModel + ? ( + onPublish(item)} + /> + ) + : ( + <> + + {startNodeLimitExceeded && ( +
+

+ {t('publishLimit.startNodeTitlePrefix', { ns: 'workflow' })} + {t('publishLimit.startNodeTitleSuffix', { ns: 'workflow' })} +

+

+ {t('publishLimit.startNodeDesc', { ns: 'workflow' })} +

+ +
+ )} + + )} +
+ ) +} + +export default MenuContentPublishSection diff --git a/web/app/components/app/app-publisher/menu-content-shared.tsx b/web/app/components/app/app-publisher/menu-content-shared.tsx new file mode 100644 index 0000000000..e110b599db --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content-shared.tsx @@ -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.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 ( + <> + +
+ {t(`accessControlDialog.accessItems.${label}`, { ns: 'app' })} +
+ + ) +} + +type SuggestedActionWithTooltipProps = SuggestedActionProps & { + tooltip?: ReactNode +} + +export const SuggestedActionWithTooltip = ({ + children, + tooltip, + ...props +}: SuggestedActionWithTooltipProps) => { + const action = ( + + {children} + + ) + + if (!tooltip) + return action + + return ( + + + {tooltip} + + ) +} diff --git a/web/app/components/app/app-publisher/menu-content.tsx b/web/app/components/app/app-publisher/menu-content.tsx new file mode 100644 index 0000000000..412dfaecb2 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content.tsx @@ -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 = ({ + 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 ( +
+ + {showAccessLoading + ?
+ : ( + <> + + + + + + )} +
+ ) +} + +export default AppPublisherMenuContent diff --git a/web/app/components/app/app-publisher/menu-content.types.ts b/web/app/components/app/app-publisher/menu-content.types.ts new file mode 100644 index 0000000000..1f16e60695 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content.types.ts @@ -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 + onPublishToMarketplace: () => Promise | void + onRestore: () => Promise | void + onShowAppAccessControl: () => void + published: boolean + publishingToMarketplace: boolean + systemFeatures: SystemFeatures + upgradeHighlightStyle: CSSProperties + workflowToolDisabled: boolean + workflowToolMessage?: string +} diff --git a/web/app/components/app/app-publisher/menu-content.utils.ts b/web/app/components/app/app-publisher/menu-content.utils.ts new file mode 100644 index 0000000000..07c7d3f595 --- /dev/null +++ b/web/app/components/app/app-publisher/menu-content.utils.ts @@ -0,0 +1,3 @@ +export const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P'] + +export const getBatchRunLink = (appURL: string) => `${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch` diff --git a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx index 1c9adca1d1..a9edc972ae 100644 --- a/web/app/components/app/configuration/tools/external-data-tool-modal.tsx +++ b/web/app/components/app/configuration/tools/external-data-tool-modal.tsx @@ -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 = ({ data, onCancel, @@ -40,7 +41,7 @@ const ExternalDataToolModal: FC = ({ 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 = ({ const currentProvider = providers.find(provider => provider.key === localeData.type) const handleDataTypeChange = (type: string) => { - let config: undefined | Record - 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) - } setLocaleData({ ...localeData, type, - config, + config: getExternalDataToolDefaultConfig(type, providers), }) } @@ -107,70 +99,30 @@ const ExternalDataToolModal: FC = ({ }) } - const formatData = (originData: ExternalDataTool) => { - const { type, config } = originData - const params: Record = {} - - 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 = ({ ) } { - 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 && ( ( + !!type && SYSTEM_EXTERNAL_DATA_TOOL_TYPES.includes(type as typeof SYSTEM_EXTERNAL_DATA_TOOL_TYPES[number]) +) + +const getLocalizedLabel = ( + label: Record | 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>((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 = {} + + 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 +} diff --git a/web/app/components/app/configuration/tools/index.tsx b/web/app/components/app/configuration/tools/index.tsx index 8ab71c73cf..f55c7dc88c 100644 --- a/web/app/components/app/configuration/tools/index.tsx +++ b/web/app/components/app/configuration/tools/index.tsx @@ -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 = () => {
setExternalDataToolsConfig([...externalDataToolsConfig.slice(0, index), ...externalDataToolsConfig.slice(index + 1)])} + onClick={() => setExternalDataToolsConfig(removeExternalDataTool(externalDataToolsConfig, index))} >
diff --git a/web/app/components/app/text-generate/item/action-bar.tsx b/web/app/components/app/text-generate/item/action-bar.tsx new file mode 100644 index 0000000000..76b61ce61d --- /dev/null +++ b/web/app/components/app/text-generate/item/action-bar.tsx @@ -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 && ( +
+ + + +
+ )} +
+ {moreLikeThis && !isTryApp && ( + + + + )} + {isShowTextToSpeech && !isTryApp && messageId && ( + + )} + {((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && ( + + + + )} + {isInWebApp && isError && ( + + + + )} + {isInWebApp && !isWorkflow && !isTryApp && ( + messageId && onSave?.(messageId)}> + + + )} +
+ {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && ( +
+ {!feedback?.rating && ( + <> + onFeedback?.({ rating: 'like' })}> + + + onFeedback?.({ rating: 'dislike' })}> + + + + )} + {feedback?.rating === 'like' && ( + onFeedback?.({ rating: null })}> + + + )} + {feedback?.rating === 'dislike' && ( + onFeedback?.({ rating: null })}> + + + )} +
+ )} + {depth > MAX_DEPTH && ( + {t('errorMessage.waitForResponse', { ns: 'appDebug' })} + )} + + ) +} + +export default GenerationItemActionBar diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index d032054796..f3396e60ed 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -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 = ({ 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 = ({ isWorkflow, siteInfo, taskId, + isError: false, + onRetry, } const handleMoreLikeThis = async () => { @@ -207,7 +198,6 @@ const GenerationItem: FC = ({ } const [currentTab, setCurrentTab] = useState('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 = ({ 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 ( <>
@@ -240,71 +239,17 @@ const GenerationItem: FC = ({ > {workflowProcessData && ( <> -
- {taskId && ( -
- - {t('generation.execution', { ns: 'share' })} - · - {taskId} -
- )} - {siteInfo && workflowProcessData && ( - - )} - {showResultTabs && ( -
-
switchTab('RESULT')} - > - {t('result', { ns: 'runLog' })} -
-
switchTab('DETAIL')} - > - {t('detail', { ns: 'runLog' })} -
-
- )} -
- {!isError && ( - <> - {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && ( -
- -
- )} - {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && ( -
- -
- )} - - - )} + )} {!workflowProcessData && taskId && ( @@ -327,88 +272,41 @@ const GenerationItem: FC = ({ {/* meta data */}
{!isWorkflow && ( - {content?.length} + {typeof content === 'string' ? content.length : 0} {' '} {t('unit.char', { ns: 'common' })} )} {/* action buttons */}
- {!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && ( -
- - - {/*
{t('common.operation.log')}
*/} -
-
- )} -
- {moreLikeThis && !isTryApp && ( - - - - )} - {isShowTextToSpeech && !isTryApp && ( - - )} - {((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && ( - { - const copyContent = isWorkflow ? workflowProcessData?.resultText : content - if (typeof copyContent === 'string') - copy(copyContent) - else - copy(JSON.stringify(copyContent)) - toast.success(t('actionMsg.copySuccessfully', { ns: 'common' })) - }} - > - - - )} - {isInWebApp && isError && ( - - - - )} - {isInWebApp && !isWorkflow && !isTryApp && ( - { onSave?.(messageId as string) }}> - - - )} -
- {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && ( -
- {!feedback?.rating && ( - <> - onFeedback?.({ rating: 'like' })}> - - - onFeedback?.({ rating: 'dislike' })}> - - - - )} - {feedback?.rating === 'like' && ( - onFeedback?.({ rating: null })}> - - - )} - {feedback?.rating === 'dislike' && ( - onFeedback?.({ rating: null })}> - - - )} -
- )} +
{/* more like this elements */} @@ -431,8 +329,8 @@ const GenerationItem: FC = ({ )}
- {((childMessageId || isQuerying) && depth < 3) && ( - + {((childMessageId || isQuerying) && depth < MAX_DEPTH) && ( + )} ) diff --git a/web/app/components/app/text-generate/item/workflow-content.tsx b/web/app/components/app/text-generate/item/workflow-content.tsx new file mode 100644 index 0000000000..222a95e672 --- /dev/null +++ b/web/app/components/app/text-generate/item/workflow-content.tsx @@ -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, action: string }) => Promise + onSwitchTab: (tab: string) => void + siteInfo: SiteInfo | null + taskId?: string + workflowProcessData: WorkflowProcess +} + +const WorkflowContent: FC = ({ + 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 ( + <> +
+ {taskId && ( +
+ + {t('generation.execution', { ns: 'share' })} + · + {taskId} +
+ )} + {siteInfo && ( + + )} + {showResultTabs && ( +
+
onSwitchTab('RESULT')} + > + {t('result', { ns: 'runLog' })} +
+
onSwitchTab('DETAIL')} + > + {t('detail', { ns: 'runLog' })} +
+
+ )} +
+ {!isError && ( + <> + {currentTab === 'RESULT' && workflowProcessData.humanInputFormDataList && workflowProcessData.humanInputFormDataList.length > 0 && ( +
+ +
+ )} + {currentTab === 'RESULT' && workflowProcessData.humanInputFilledFormDataList && workflowProcessData.humanInputFilledFormDataList.length > 0 && ( +
+ +
+ )} + + + )} + + ) +} + +export default WorkflowContent