From b9f718005c03a89a5be11054ff1e7e5c8b42d78c Mon Sep 17 00:00:00 2001 From: Joel Date: Thu, 22 Jan 2026 18:16:37 +0800 Subject: [PATCH] feat: frontend part of support try apps (#31287) Co-authored-by: CodingOnStar Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com> --- .../app/configuration/config-var/index.tsx | 4 +- .../app/configuration/config-var/var-item.tsx | 2 +- .../app/configuration/config-vision/index.tsx | 88 +++-- .../config/agent/agent-tools/index.tsx | 15 +- .../app/configuration/config/config-audio.tsx | 22 +- .../configuration/config/config-document.tsx | 22 +- .../app/configuration/config/index.tsx | 26 +- .../dataset-config/card-item/index.spec.tsx | 2 +- .../dataset-config/card-item/index.tsx | 36 +- .../configuration/dataset-config/index.tsx | 62 +-- .../configuration/debug/chat-user-input.tsx | 7 +- .../text-generation-item.tsx | 3 +- .../debug/debug-with-single-model/index.tsx | 2 + .../app/configuration/debug/index.tsx | 86 ++-- .../prompt-value-panel/index.tsx | 20 +- .../create-app-dialog/app-card/index.spec.tsx | 1 + .../app/create-app-dialog/app-card/index.tsx | 23 +- web/app/components/app/log/list.tsx | 3 +- .../app/text-generate/item/index.tsx | 23 +- web/app/components/apps/index.spec.tsx | 59 ++- web/app/components/apps/index.tsx | 130 ++++++- web/app/components/apps/list.tsx | 15 +- web/app/components/apps/new-app-card.tsx | 11 +- .../components/base/action-button/index.tsx | 9 +- web/app/components/base/alert.tsx | 59 +++ web/app/components/base/audio-btn/audio.ts | 4 +- web/app/components/base/carousel/index.tsx | 227 +++++++++++ .../chat/chat-with-history/chat-wrapper.tsx | 14 +- .../chat/chat-with-history/hooks.spec.tsx | 41 +- .../base/chat/chat-with-history/hooks.tsx | 27 +- .../base/chat/chat/answer/index.tsx | 2 +- .../chat/chat/answer/suggested-questions.tsx | 10 +- .../base/chat/chat/chat-input-area/index.tsx | 16 +- .../chat/chat/chat-input-area/operation.tsx | 8 +- web/app/components/base/chat/chat/context.tsx | 10 +- web/app/components/base/chat/chat/index.tsx | 19 +- .../chat/embedded-chatbot/chat-wrapper.tsx | 22 +- .../base/chat/embedded-chatbot/context.tsx | 4 + .../base/chat/embedded-chatbot/hooks.spec.tsx | 41 +- .../base/chat/embedded-chatbot/hooks.tsx | 83 +++- .../base/chat/embedded-chatbot/index.tsx | 7 +- .../embedded-chatbot/inputs-form/index.tsx | 6 +- .../inputs-form/view-form-dropdown.tsx | 2 +- .../new-feature-panel/feature-bar.tsx | 14 +- .../file-uploader-in-chat-input/index.tsx | 6 + .../text-generation-image-uploader.tsx | 6 +- web/app/components/base/tab-header/index.tsx | 7 +- web/app/components/base/voice-input/index.tsx | 4 +- .../explore/app-card/index.spec.tsx | 1 + web/app/components/explore/app-card/index.tsx | 28 +- .../explore/app-list/index.spec.tsx | 13 +- web/app/components/explore/app-list/index.tsx | 71 +++- .../components/explore/banner/banner-item.tsx | 187 +++++++++ web/app/components/explore/banner/banner.tsx | 94 +++++ .../explore/banner/indicator-button.tsx | 112 ++++++ web/app/components/explore/category.tsx | 2 +- web/app/components/explore/index.tsx | 14 + .../explore/installed-app/index.tsx | 5 +- .../explore/sidebar/app-nav-item/index.tsx | 2 +- .../components/explore/sidebar/index.spec.tsx | 7 +- web/app/components/explore/sidebar/index.tsx | 56 ++- .../explore/sidebar/no-apps/index.tsx | 24 ++ .../sidebar/no-apps/no-web-apps-dark.png | Bin 0 -> 22064 bytes .../sidebar/no-apps/no-web-apps-light.png | Bin 0 -> 21852 bytes .../explore/sidebar/no-apps/style.module.css | 7 + .../explore/try-app/app-info/index.tsx | 95 +++++ .../try-app/app-info/use-get-requirements.ts | 78 ++++ .../components/explore/try-app/app/chat.tsx | 104 +++++ .../components/explore/try-app/app/index.tsx | 44 +++ .../explore/try-app/app/text-generation.tsx | 262 +++++++++++++ web/app/components/explore/try-app/index.tsx | 74 ++++ .../try-app/preview/basic-app-preview.tsx | 367 ++++++++++++++++++ .../try-app/preview/flow-app-preview.tsx | 39 ++ .../explore/try-app/preview/index.tsx | 25 ++ web/app/components/explore/try-app/tab.tsx | 37 ++ .../share/text-generation/index.tsx | 17 +- .../share/text-generation/result/index.tsx | 28 +- .../share/text-generation/run-once/index.tsx | 22 +- .../components/share/text-generation/types.ts | 19 + .../components/before-run-form/bool-input.tsx | 3 + web/app/components/workflow/types.ts | 1 + .../workflow/workflow-preview/index.tsx | 5 +- web/context/app-list-context.ts | 19 + web/context/debug-configuration.ts | 2 + web/context/explore-context.ts | 15 +- web/contract/console/try-app.ts | 56 +++ web/contract/router.ts | 7 + web/eslint-suppressions.json | 23 +- web/i18n/ar-TN/explore.json | 4 - web/i18n/de-DE/explore.json | 7 - web/i18n/en-US/common.json | 1 + web/i18n/en-US/explore.json | 27 +- web/i18n/es-ES/explore.json | 7 - web/i18n/fa-IR/explore.json | 7 - web/i18n/fr-FR/explore.json | 7 - web/i18n/hi-IN/explore.json | 7 - web/i18n/id-ID/explore.json | 7 - web/i18n/it-IT/explore.json | 7 - web/i18n/ja-JP/common.json | 1 + web/i18n/ja-JP/explore.json | 27 +- web/i18n/ko-KR/explore.json | 7 - web/i18n/pl-PL/explore.json | 7 - web/i18n/pt-BR/explore.json | 7 - web/i18n/ro-RO/explore.json | 7 - web/i18n/ru-RU/explore.json | 7 - web/i18n/sl-SI/explore.json | 7 - web/i18n/th-TH/explore.json | 7 - web/i18n/tr-TR/explore.json | 7 - web/i18n/uk-UA/explore.json | 7 - web/i18n/vi-VN/explore.json | 7 - web/i18n/zh-Hans/common.json | 1 + web/i18n/zh-Hans/explore.json | 27 +- web/i18n/zh-Hant/explore.json | 7 - web/models/app.ts | 14 + web/models/debug.ts | 1 + web/models/explore.ts | 1 + web/models/share.ts | 3 +- web/models/try-app.ts | 21 + web/package.json | 2 + web/pnpm-lock.yaml | 40 ++ web/service/debug.ts | 20 +- web/service/explore.ts | 7 + web/service/share.ts | 165 ++++---- web/service/try-app.ts | 26 ++ web/service/use-explore.ts | 17 +- web/service/use-share.spec.tsx | 36 +- web/service/use-share.ts | 23 +- web/service/use-try-app.ts | 44 +++ web/tsconfig.json | 5 +- web/types/feature.ts | 4 + 130 files changed, 3233 insertions(+), 685 deletions(-) create mode 100644 web/app/components/base/alert.tsx create mode 100644 web/app/components/base/carousel/index.tsx create mode 100644 web/app/components/explore/banner/banner-item.tsx create mode 100644 web/app/components/explore/banner/banner.tsx create mode 100644 web/app/components/explore/banner/indicator-button.tsx create mode 100644 web/app/components/explore/sidebar/no-apps/index.tsx create mode 100644 web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png create mode 100644 web/app/components/explore/sidebar/no-apps/no-web-apps-light.png create mode 100644 web/app/components/explore/sidebar/no-apps/style.module.css create mode 100644 web/app/components/explore/try-app/app-info/index.tsx create mode 100644 web/app/components/explore/try-app/app-info/use-get-requirements.ts create mode 100644 web/app/components/explore/try-app/app/chat.tsx create mode 100644 web/app/components/explore/try-app/app/index.tsx create mode 100644 web/app/components/explore/try-app/app/text-generation.tsx create mode 100644 web/app/components/explore/try-app/index.tsx create mode 100644 web/app/components/explore/try-app/preview/basic-app-preview.tsx create mode 100644 web/app/components/explore/try-app/preview/flow-app-preview.tsx create mode 100644 web/app/components/explore/try-app/preview/index.tsx create mode 100644 web/app/components/explore/try-app/tab.tsx create mode 100644 web/app/components/share/text-generation/types.ts create mode 100644 web/context/app-list-context.ts create mode 100644 web/contract/console/try-app.ts create mode 100644 web/models/try-app.ts create mode 100644 web/service/try-app.ts create mode 100644 web/service/use-try-app.ts diff --git a/web/app/components/app/configuration/config-var/index.tsx b/web/app/components/app/configuration/config-var/index.tsx index 1a8810f7cd..4d9a4e480f 100644 --- a/web/app/components/app/configuration/config-var/index.tsx +++ b/web/app/components/app/configuration/config-var/index.tsx @@ -271,9 +271,9 @@ const ConfigVar: FC = ({ promptVariables, readonly, onPromptVar )} {hasVar && ( -
+
{ onPromptVariablesChange?.(list.map(item => item.variable)) }} handle=".handle" diff --git a/web/app/components/app/configuration/config-var/var-item.tsx b/web/app/components/app/configuration/config-var/var-item.tsx index 1fc21e3d33..b26249dac8 100644 --- a/web/app/components/app/configuration/config-var/var-item.tsx +++ b/web/app/components/app/configuration/config-var/var-item.tsx @@ -39,7 +39,7 @@ const VarItem: FC = ({ const [isDeleting, setIsDeleting] = useState(false) return ( -
+
{canDrag && ( diff --git a/web/app/components/app/configuration/config-vision/index.tsx b/web/app/components/app/configuration/config-vision/index.tsx index bc313b9ac1..481e6b5ab6 100644 --- a/web/app/components/app/configuration/config-vision/index.tsx +++ b/web/app/components/app/configuration/config-vision/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import { noop } from 'es-toolkit/function' import { produce } from 'immer' import * as React from 'react' import { useCallback } from 'react' @@ -10,14 +11,17 @@ import { useFeatures, useFeaturesStore } from '@/app/components/base/features/ho import { Vision } from '@/app/components/base/icons/src/vender/features' import Switch from '@/app/components/base/switch' import Tooltip from '@/app/components/base/tooltip' +import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import { SupportUploadFileTypes } from '@/app/components/workflow/types' // import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' import ConfigContext from '@/context/debug-configuration' +import { Resolution } from '@/types/app' +import { cn } from '@/utils/classnames' import ParamConfig from './param-config' const ConfigVision: FC = () => { const { t } = useTranslation() - const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext) + const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext) const file = useFeatures(s => s.features.file) const featuresStore = useFeaturesStore() @@ -54,7 +58,7 @@ const ConfigVision: FC = () => { setFeatures(newFeatures) }, [featuresStore, isAllowVideoUpload]) - if (!isShowVisionConfig) + if (!isShowVisionConfig || (readonly && !isImageEnabled)) return null return ( @@ -75,37 +79,55 @@ const ConfigVision: FC = () => { />
- {/*
-
{t('appDebug.vision.visionSettings.resolution')}
- - {t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => ( -
{item}
- ))} -
- } - /> -
*/} - {/*
- handleChange(Resolution.high)} - /> - handleChange(Resolution.low)} - /> -
*/} - -
- + {readonly + ? ( + <> +
+
{t('vision.visionSettings.resolution', { ns: 'appDebug' })}
+ + {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => ( +
{item}
+ ))} +
+ )} + /> +
+
+ + +
+ + ) + : ( + <> + +
+ + + )} +
) diff --git a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx index 7139ba66e0..486c0a8ac9 100644 --- a/web/app/components/app/configuration/config/agent/agent-tools/index.tsx +++ b/web/app/components/app/configuration/config/agent/agent-tools/index.tsx @@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection } const AgentTools: FC = () => { const { t } = useTranslation() const [isShowChooseTool, setIsShowChooseTool] = useState(false) - const { modelConfig, setModelConfig } = useContext(ConfigContext) + const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext) const { data: buildInTools } = useAllBuiltInTools() const { data: customTools } = useAllCustomTools() const { data: workflowTools } = useAllWorkflowTools() @@ -168,10 +168,10 @@ const AgentTools: FC = () => { {tools.filter(item => !!item.enabled).length} / {tools.length} -  +   {t('agent.tools.enabled', { ns: 'appDebug' })} - {tools.length < MAX_TOOLS_NUM && ( + {tools.length < MAX_TOOLS_NUM && !readonly && ( <>
{ )} > -
+
{tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => (
{ > {getProviderShowName(item)} {item.tool_label} - {!item.isDeleted && ( + {!item.isDeleted && !readonly && ( @@ -259,7 +259,7 @@ const AgentTools: FC = () => {
)} - {!item.isDeleted && ( + {!item.isDeleted && !readonly && (
{!item.notAuthor && ( { {!item.notAuthor && ( { const newModelConfig = produce(modelConfig, (draft) => { @@ -312,6 +312,7 @@ const AgentTools: FC = () => { {item.notAuthor && (
-
-
- -
+ {!readonly && ( +
+
+ +
+ )}
) } diff --git a/web/app/components/app/configuration/config/config-document.tsx b/web/app/components/app/configuration/config/config-document.tsx index 3f192fd401..7d48c1582a 100644 --- a/web/app/components/app/configuration/config/config-document.tsx +++ b/web/app/components/app/configuration/config/config-document.tsx @@ -17,7 +17,7 @@ const ConfigDocument: FC = () => { const { t } = useTranslation() const file = useFeatures(s => s.features.file) const featuresStore = useFeaturesStore() - const { isShowDocumentConfig } = useContext(ConfigContext) + const { isShowDocumentConfig, readonly } = useContext(ConfigContext) const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false @@ -45,7 +45,7 @@ const ConfigDocument: FC = () => { setFeatures(newFeatures) }, [featuresStore]) - if (!isShowDocumentConfig) + if (!isShowDocumentConfig || (readonly && !isDocumentEnabled)) return null return ( @@ -65,14 +65,16 @@ const ConfigDocument: FC = () => { )} /> -
-
- -
+ {!readonly && ( +
+
+ +
+ )} ) } diff --git a/web/app/components/app/configuration/config/index.tsx b/web/app/components/app/configuration/config/index.tsx index f208b99e59..3e2b201172 100644 --- a/web/app/components/app/configuration/config/index.tsx +++ b/web/app/components/app/configuration/config/index.tsx @@ -18,6 +18,7 @@ import ConfigDocument from './config-document' const Config: FC = () => { const { + readonly, mode, isAdvancedMode, modelModeType, @@ -27,6 +28,7 @@ const Config: FC = () => { modelConfig, setModelConfig, setPrevPromptConfig, + dataSets, } = useContext(ConfigContext) const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) const formattingChangedDispatcher = useFormattingChangedDispatcher() @@ -65,19 +67,27 @@ const Config: FC = () => { promptTemplate={promptTemplate} promptVariables={promptVariables} onChange={handlePromptChange} + readonly={readonly} /> {/* Variables */} - + {!(readonly && promptVariables.length === 0) && ( + + )} {/* Dataset */} - - + {!(readonly && dataSets.length === 0) && ( + + )} {/* Tools */} - {isAgent && ( + {isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && ( )} @@ -88,7 +98,7 @@ const Config: FC = () => { {/* Chat History */} - {isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( + {!readonly && isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( { expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) }) await waitFor(() => { - expect(screen.getByText('Mock settings modal')).not.toBeVisible() + expect(screen.queryByText('Mock settings modal')).not.toBeInTheDocument() }) }) diff --git a/web/app/components/app/configuration/dataset-config/card-item/index.tsx b/web/app/components/app/configuration/dataset-config/card-item/index.tsx index 00d3f6d6ad..a5ad3312ec 100644 --- a/web/app/components/app/configuration/dataset-config/card-item/index.tsx +++ b/web/app/components/app/configuration/dataset-config/card-item/index.tsx @@ -30,6 +30,7 @@ const Item: FC = ({ config, onSave, onRemove, + readonly = false, editable = true, }) => { const media = useBreakpoints() @@ -56,6 +57,7 @@ const Item: FC = ({
@@ -70,7 +72,7 @@ const Item: FC = ({
{ - editable && ( + editable && !readonly && ( { e.stopPropagation() @@ -81,14 +83,18 @@ const Item: FC = ({ ) } - onRemove(config.id)} - state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} - onMouseEnter={() => setIsDeleting(true)} - onMouseLeave={() => setIsDeleting(false)} - > - - + { + !readonly && ( + onRemove(config.id)} + state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} + onMouseEnter={() => setIsDeleting(true)} + onMouseLeave={() => setIsDeleting(false)} + > + + + ) + }
{ !!config.indexing_technique && ( @@ -107,11 +113,13 @@ const Item: FC = ({ ) } setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl"> - setShowSettingsModal(false)} - onSave={handleSave} - /> + {showSettingsModal && ( + setShowSettingsModal(false)} + onSave={handleSave} + /> + )}
) diff --git a/web/app/components/app/configuration/dataset-config/index.tsx b/web/app/components/app/configuration/dataset-config/index.tsx index 309c6e7ddb..6de77cad9e 100644 --- a/web/app/components/app/configuration/dataset-config/index.tsx +++ b/web/app/components/app/configuration/dataset-config/index.tsx @@ -30,6 +30,7 @@ import { import { useSelector as useAppContextSelector } from '@/context/app-context' import ConfigContext from '@/context/debug-configuration' import { AppModeEnum } from '@/types/app' +import { cn } from '@/utils/classnames' import { hasEditPermissionForDataset } from '@/utils/permission' import FeaturePanel from '../base/feature-panel' import OperationBtn from '../base/operation-btn' @@ -38,7 +39,11 @@ import CardItem from './card-item' import ContextVar from './context-var' import ParamsConfig from './params-config' -const DatasetConfig: FC = () => { +type Props = { + readonly?: boolean + hideMetadataFilter?: boolean +} +const DatasetConfig: FC = ({ readonly, hideMetadataFilter }) => { const { t } = useTranslation() const userProfile = useAppContextSelector(s => s.userProfile) const { @@ -259,17 +264,19 @@ const DatasetConfig: FC = () => { className="mt-2" title={t('feature.dataSet.title', { ns: 'appDebug' })} headerRight={( -
- {!isAgent && } - -
+ !readonly && ( +
+ {!isAgent && } + +
+ ) )} hasHeaderBottomBorder={!hasData} noBodySpacing > {hasData ? ( -
+
{formattedDataset.map(item => ( { onRemove={onRemove} onSave={handleSave} editable={item.editable} + readonly={readonly} /> ))}
@@ -287,27 +295,29 @@ const DatasetConfig: FC = () => {
)} -
- item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} - availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} - /> -
+ {!hideMetadataFilter && ( +
+ item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} + availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} + /> +
+ )} - {mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( + {!readonly && mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( { const { t } = useTranslation() - const { modelConfig, setInputs } = useContext(ConfigContext) + const { modelConfig, setInputs, readonly } = useContext(ConfigContext) const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { return key && key?.trim() && name && name?.trim() @@ -88,6 +88,7 @@ const ChatUserInput = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'paragraph' && ( @@ -96,6 +97,7 @@ const ChatUserInput = ({ placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} onChange={(e) => { handleInputValueChange(key, e.target.value) }} + readOnly={readonly} /> )} {type === 'select' && ( @@ -105,6 +107,7 @@ const ChatUserInput = ({ onSelect={(i) => { handleInputValueChange(key, i.value as string) }} items={(options || []).map(i => ({ name: i, value: i }))} allowSearch={false} + disabled={readonly} /> )} {type === 'number' && ( @@ -115,6 +118,7 @@ const ChatUserInput = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'checkbox' && ( @@ -123,6 +127,7 @@ const ChatUserInput = ({ value={!!inputs[key]} required={required} onChange={(value) => { handleInputValueChange(key, value) }} + readonly={readonly} /> )} diff --git a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx index d7918e7ad6..eb18ca45b1 100644 --- a/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx +++ b/web/app/components/app/configuration/debug/debug-with-multiple-model/text-generation-item.tsx @@ -15,6 +15,7 @@ import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/ import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useEventEmitterContextContext } from '@/context/event-emitter' import { useProviderContext } from '@/context/provider-context' +import { AppSourceType } from '@/service/share' import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types' @@ -130,11 +131,11 @@ const TextGenerationItem: FC = ({ return ( { const { userProfile } = useAppContext() const { + readonly, modelConfig, appId, inputs, @@ -150,6 +151,7 @@ const DebugWithSingleModel = ( return ( = ({ }) => { const { t } = useTranslation() const { + readonly, appId, mode, modelModeType, @@ -416,25 +418,33 @@ const Debug: FC = ({ } {mode !== AppModeEnum.COMPLETION && ( <> - - - - - - {varList.length > 0 && ( -
+ { + !readonly && ( - setExpanded(!expanded)}> - + + + - {expanded &&
} -
- )} + ) + } + + { + varList.length > 0 && ( +
+ + !readonly && setExpanded(!expanded)}> + + + + {expanded &&
} +
+ ) + } )}
@@ -444,19 +454,21 @@ const Debug: FC = ({
)} - {mode === AppModeEnum.COMPLETION && ( - - )} + { + mode === AppModeEnum.COMPLETION && ( + + ) + } { debugWithMultipleModel && ( @@ -510,12 +522,12 @@ const Debug: FC = ({
= ({
) } - {isShowFormattingChangeConfirm && ( - - )} - {!isAPIKeySet && ()} + { + isShowFormattingChangeConfirm && ( + + ) + } + {!isAPIKeySet && !readonly && ()} ) } diff --git a/web/app/components/app/configuration/prompt-value-panel/index.tsx b/web/app/components/app/configuration/prompt-value-panel/index.tsx index 613efb8710..e695616810 100644 --- a/web/app/components/app/configuration/prompt-value-panel/index.tsx +++ b/web/app/components/app/configuration/prompt-value-panel/index.tsx @@ -40,7 +40,7 @@ const PromptValuePanel: FC = ({ onVisionFilesChange, }) => { const { t } = useTranslation() - const { modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext) + const { readonly, modelModeType, modelConfig, setInputs, mode, isAdvancedMode, completionPromptConfig, chatPromptConfig } = useContext(ConfigContext) const [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false) const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { return key && key?.trim() && name && name?.trim() @@ -78,12 +78,12 @@ const PromptValuePanel: FC = ({ if (isAdvancedMode) { if (modelModeType === ModelModeType.chat) - return chatPromptConfig.prompt.every(({ text }) => !text) + return chatPromptConfig?.prompt.every(({ text }) => !text) return !completionPromptConfig.prompt?.text } else { return !modelConfig.configs.prompt_template } - }, [chatPromptConfig.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) + }, [chatPromptConfig?.prompt, completionPromptConfig.prompt?.text, isAdvancedMode, mode, modelConfig.configs.prompt_template, modelModeType]) const handleInputValueChange = (key: string, value: string | boolean) => { if (!(key in promptVariableObj)) @@ -142,6 +142,7 @@ const PromptValuePanel: FC = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'paragraph' && ( @@ -150,6 +151,7 @@ const PromptValuePanel: FC = ({ placeholder={name} value={inputs[key] ? `${inputs[key]}` : ''} onChange={(e) => { handleInputValueChange(key, e.target.value) }} + readOnly={readonly} /> )} {type === 'select' && ( @@ -160,6 +162,7 @@ const PromptValuePanel: FC = ({ items={(options || []).map(i => ({ name: i, value: i }))} allowSearch={false} bgClassName="bg-gray-50" + disabled={readonly} /> )} {type === 'number' && ( @@ -170,6 +173,7 @@ const PromptValuePanel: FC = ({ placeholder={name} autoFocus={index === 0} maxLength={max_length} + readOnly={readonly} /> )} {type === 'checkbox' && ( @@ -178,6 +182,7 @@ const PromptValuePanel: FC = ({ value={!!inputs[key]} required={required} onChange={(value) => { handleInputValueChange(key, value) }} + readonly={readonly} /> )} @@ -196,6 +201,7 @@ const PromptValuePanel: FC = ({ url: fileItem.url, upload_file_id: fileItem.fileId, })))} + disabled={readonly} /> @@ -204,12 +210,12 @@ const PromptValuePanel: FC = ({ )} {!userInputFieldCollapse && (
- + {canNotRun && (
diff --git a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx index e1f9773ac3..82e4fb8f94 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.spec.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.spec.tsx @@ -10,6 +10,7 @@ vi.mock('@heroicons/react/20/solid', () => ({ })) const mockApp: App = { + can_trial: true, app: { id: 'test-app-id', mode: AppModeEnum.CHAT, diff --git a/web/app/components/app/create-app-dialog/app-card/index.tsx b/web/app/components/app/create-app-dialog/app-card/index.tsx index 695faed5e0..15cfbd5411 100644 --- a/web/app/components/app/create-app-dialog/app-card/index.tsx +++ b/web/app/components/app/create-app-dialog/app-card/index.tsx @@ -1,9 +1,14 @@ 'use client' import type { App } from '@/models/explore' import { PlusIcon } from '@heroicons/react/20/solid' +import { RiInformation2Line } from '@remixicon/react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import AppIcon from '@/app/components/base/app-icon' import Button from '@/app/components/base/button' +import AppListContext from '@/context/app-list-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { cn } from '@/utils/classnames' import { AppTypeIcon, AppTypeLabel } from '../../type-selector' @@ -20,6 +25,14 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app + const { systemFeatures } = useGlobalPublicStore() + const isTrialApp = app.can_trial && systemFeatures.enable_trial_app + const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel) + const showTryAPPPanel = useCallback((appId: string) => { + return () => { + setShowTryAppPanel?.(true, { appId, app }) + } + }, [setShowTryAppPanel, app.category]) return (
@@ -51,11 +64,17 @@ const AppCard = ({
{canCreate && ( )} diff --git a/web/app/components/app/log/list.tsx b/web/app/components/app/log/list.tsx index 410953ccf7..5197a02bb3 100644 --- a/web/app/components/app/log/list.tsx +++ b/web/app/components/app/log/list.tsx @@ -39,6 +39,7 @@ import { useAppContext } from '@/context/app-context' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useTimestamp from '@/hooks/use-timestamp' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' +import { AppSourceType } from '@/service/share' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' @@ -638,12 +639,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
item.from_source === 'admin')} onFeedback={feedback => onFeedback(detail.message.id, feedback)} diff --git a/web/app/components/app/text-generate/item/index.tsx b/web/app/components/app/text-generate/item/index.tsx index 78f4f426f5..c39282a022 100644 --- a/web/app/components/app/text-generate/item/index.tsx +++ b/web/app/components/app/text-generate/item/index.tsx @@ -29,7 +29,7 @@ import { Markdown } from '@/app/components/base/markdown' import NewAudioButton from '@/app/components/base/new-audio-button' import Toast from '@/app/components/base/toast' import { fetchTextGenerationMessage } from '@/service/debug' -import { fetchMoreLikeThis, updateFeedback } from '@/service/share' +import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share' import { cn } from '@/utils/classnames' import ResultTab from './result-tab' @@ -53,7 +53,7 @@ export type IGenerationItemProps = { onFeedback?: (feedback: FeedbackType) => void onSave?: (messageId: string) => void isMobile?: boolean - isInstalledApp: boolean + appSourceType: AppSourceType installedAppId?: string taskId?: string controlClearMoreLikeThis?: number @@ -87,7 +87,7 @@ const GenerationItem: FC = ({ onSave, depth = 1, isMobile, - isInstalledApp, + appSourceType, installedAppId, taskId, controlClearMoreLikeThis, @@ -100,6 +100,7 @@ const GenerationItem: FC = ({ const { t } = useTranslation() const params = useParams() const isTop = depth === 1 + const isTryApp = appSourceType === AppSourceType.tryApp const [completionRes, setCompletionRes] = useState('') const [childMessageId, setChildMessageId] = useState(null) const [childFeedback, setChildFeedback] = useState({ @@ -113,7 +114,7 @@ const GenerationItem: FC = ({ const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) const handleFeedback = async (childFeedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId) + await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId) setChildFeedback(childFeedback) } @@ -131,7 +132,7 @@ const GenerationItem: FC = ({ onSave, isShowTextToSpeech, isMobile, - isInstalledApp, + appSourceType, installedAppId, controlClearMoreLikeThis, isWorkflow, @@ -145,7 +146,7 @@ const GenerationItem: FC = ({ return } startQuerying() - const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId) + const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId) setCompletionRes(res.answer) setChildFeedback({ rating: null, @@ -310,7 +311,7 @@ const GenerationItem: FC = ({ )} {/* action buttons */}
- {!isInWebApp && !isInstalledApp && !isResponding && ( + {!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
@@ -319,12 +320,12 @@ const GenerationItem: FC = ({
)}
- {moreLikeThis && ( + {moreLikeThis && !isTryApp && ( )} - {isShowTextToSpeech && ( + {isShowTextToSpeech && !isTryApp && ( = ({ )} - {isInWebApp && !isWorkflow && ( + {isInWebApp && !isWorkflow && !isTryApp && ( { onSave?.(messageId as string) }}> )}
- {(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && ( + {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
{!feedback?.rating && ( <> diff --git a/web/app/components/apps/index.spec.tsx b/web/app/components/apps/index.spec.tsx index c3dc39955d..c77c1bdb01 100644 --- a/web/app/components/apps/index.spec.tsx +++ b/web/app/components/apps/index.spec.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { render, screen } from '@testing-library/react' import * as React from 'react' @@ -22,6 +24,15 @@ vi.mock('@/app/education-apply/hooks', () => ({ }, })) +vi.mock('@/hooks/use-import-dsl', () => ({ + useImportDSL: () => ({ + handleImportDSL: vi.fn(), + handleImportDSLConfirm: vi.fn(), + versions: [], + isFetching: false, + }), +})) + // Mock List component vi.mock('./list', () => ({ default: () => { @@ -30,6 +41,25 @@ vi.mock('./list', () => ({ })) describe('Apps', () => { + const createQueryClient = () => new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + const renderWithClient = (ui: React.ReactElement) => { + const queryClient = createQueryClient() + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + return { + queryClient, + ...render(ui, { wrapper }), + } + } + beforeEach(() => { vi.clearAllMocks() documentTitleCalls = [] @@ -38,17 +68,17 @@ describe('Apps', () => { describe('Rendering', () => { it('should render without crashing', () => { - render() + renderWithClient() expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) it('should render List component', () => { - render() + renderWithClient() expect(screen.getByText('Apps List')).toBeInTheDocument() }) it('should have correct container structure', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col') }) @@ -56,19 +86,19 @@ describe('Apps', () => { describe('Hooks', () => { it('should call useDocumentTitle with correct title', () => { - render() + renderWithClient() expect(documentTitleCalls).toContain('common.menus.apps') }) it('should call useEducationInit', () => { - render() + renderWithClient() expect(educationInitCalls).toBeGreaterThan(0) }) }) describe('Integration', () => { it('should render full component tree', () => { - render() + renderWithClient() // Verify container exists expect(screen.getByTestId('apps-list')).toBeInTheDocument() @@ -79,23 +109,32 @@ describe('Apps', () => { }) it('should handle multiple renders', () => { - const { rerender } = render() + const queryClient = createQueryClient() + const { rerender } = render( + + + , + ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() - rerender() + rerender( + + + , + ) expect(screen.getByTestId('apps-list')).toBeInTheDocument() }) }) describe('Styling', () => { it('should have overflow-y-auto class', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('overflow-y-auto') }) it('should have background styling', () => { - const { container } = render() + const { container } = renderWithClient() const wrapper = container.firstChild as HTMLElement expect(wrapper).toHaveClass('bg-background-body') }) diff --git a/web/app/components/apps/index.tsx b/web/app/components/apps/index.tsx index b151df1e1f..255bfbf9c5 100644 --- a/web/app/components/apps/index.tsx +++ b/web/app/components/apps/index.tsx @@ -1,7 +1,17 @@ 'use client' +import type { CreateAppModalProps } from '../explore/create-app-modal' +import type { CurrentTryAppParams } from '@/context/explore-context' +import { useCallback, useState } from 'react' import { useTranslation } from 'react-i18next' import { useEducationInit } from '@/app/education-apply/hooks' +import AppListContext from '@/context/app-list-context' import useDocumentTitle from '@/hooks/use-document-title' +import { useImportDSL } from '@/hooks/use-import-dsl' +import { DSLImportMode } from '@/models/app' +import { fetchAppDetail } from '@/service/explore' +import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal' +import CreateAppModal from '../explore/create-app-modal' +import TryApp from '../explore/try-app' import List from './list' const Apps = () => { @@ -10,10 +20,124 @@ const Apps = () => { useDocumentTitle(t('menus.apps', { ns: 'common' })) useEducationInit() + const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const currApp = currentTryAppParams?.app + const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) + const hideTryAppPanel = useCallback(() => { + setIsShowTryAppPanel(false) + }, []) + const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => { + if (showTryAppPanel) + setCurrentTryAppParams(params) + else + setCurrentTryAppParams(undefined) + setIsShowTryAppPanel(showTryAppPanel) + } + const [isShowCreateModal, setIsShowCreateModal] = useState(false) + + const handleShowFromTryApp = useCallback(() => { + setIsShowCreateModal(true) + }, []) + + const [controlRefreshList, setControlRefreshList] = useState(0) + const [controlHideCreateFromTemplatePanel, setControlHideCreateFromTemplatePanel] = useState(0) + const onSuccess = useCallback(() => { + setControlRefreshList(prev => prev + 1) + setControlHideCreateFromTemplatePanel(prev => prev + 1) + }, []) + + const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + + const { + handleImportDSL, + handleImportDSLConfirm, + versions, + isFetching, + } = useImportDSL() + + const onConfirmDSL = useCallback(async () => { + await handleImportDSLConfirm({ + onSuccess, + }) + }, [handleImportDSLConfirm, onSuccess]) + + const onCreate: CreateAppModalProps['onConfirm'] = async ({ + name, + icon_type, + icon, + icon_background, + description, + }) => { + hideTryAppPanel() + + const { export_data } = await fetchAppDetail( + currApp?.app.id as string, + ) + const payload = { + mode: DSLImportMode.YAML_CONTENT, + yaml_content: export_data, + name, + icon_type, + icon, + icon_background, + description, + } + await handleImportDSL(payload, { + onSuccess: () => { + setIsShowCreateModal(false) + }, + onPending: () => { + setShowDSLConfirmModal(true) + }, + }) + } + return ( -
- -
+ +
+ + {isShowTryAppPanel && ( + + )} + + { + showDSLConfirmModal && ( + setShowDSLConfirmModal(false)} + onConfirm={onConfirmDSL} + confirmDisabled={isFetching} + /> + ) + } + + {isShowCreateModal && ( + setIsShowCreateModal(false)} + /> + )} +
+
) } diff --git a/web/app/components/apps/list.tsx b/web/app/components/apps/list.tsx index 8a236fe260..6bf79b7338 100644 --- a/web/app/components/apps/list.tsx +++ b/web/app/components/apps/list.tsx @@ -1,5 +1,6 @@ 'use client' +import type { FC } from 'react' import { RiApps2Line, RiDragDropLine, @@ -53,7 +54,12 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro ssr: false, }) -const List = () => { +type Props = { + controlRefreshList?: number +} +const List: FC = ({ + controlRefreshList = 0, +}) => { const { t } = useTranslation() const { systemFeatures } = useGlobalPublicStore() const router = useRouter() @@ -110,6 +116,13 @@ const List = () => { refetch, } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator }) + useEffect(() => { + if (controlRefreshList > 0) { + refetch() + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [controlRefreshList]) + const anchorRef = useRef(null) const options = [ { value: 'all', text: t('types.all', { ns: 'app' }), icon: }, diff --git a/web/app/components/apps/new-app-card.tsx b/web/app/components/apps/new-app-card.tsx index bfa7af3892..868da0dcb5 100644 --- a/web/app/components/apps/new-app-card.tsx +++ b/web/app/components/apps/new-app-card.tsx @@ -6,10 +6,12 @@ import { useSearchParams, } from 'next/navigation' import * as React from 'react' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' +import AppListContext from '@/context/app-list-context' import { useProviderContext } from '@/context/provider-context' import { cn } from '@/utils/classnames' @@ -55,6 +57,13 @@ const CreateAppCard = ({ return undefined }, [dslUrl]) + const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel) + useEffect(() => { + if (controlHideCreateFromTemplatePanel > 0) + // eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect + setShowNewAppTemplateDialog(false) + }, [controlHideCreateFromTemplatePanel]) + return (
{ +const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, disabled, ...props }: ActionButtonProps) => { return ( + ) + }, +) +CarouselPrevious.displayName = 'CarouselPrevious' + +const CarouselNext = React.forwardRef( + ({ children, ...props }, ref) => { + const { scrollNext, canScrollNext } = useCarousel() + + return ( + + ) + }, +) +CarouselNext.displayName = 'CarouselNext' + +const CarouselDot = React.forwardRef( + ({ children, ...props }, ref) => { + const { api, selectedIndex } = useCarousel() + + return api?.slideNodes().map((_, index) => { + return ( + + ) + }) + }, +) +CarouselDot.displayName = 'CarouselDot' + +const CarouselPlugins = { + Autoplay, +} + +Carousel.Content = CarouselContent +Carousel.Item = CarouselItem +Carousel.Previous = CarouselPrevious +Carousel.Next = CarouselNext +Carousel.Dot = CarouselDot +Carousel.Plugin = CarouselPlugins + +export { Carousel, useCarousel } diff --git a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx index 25ff39370f..38a3f6c6b2 100644 --- a/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx +++ b/web/app/components/base/chat/chat-with-history/chat-wrapper.tsx @@ -12,6 +12,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested import { Markdown } from '@/app/components/base/markdown' import { InputVarType } from '@/app/components/workflow/types' import { + AppSourceType, fetchSuggestedQuestions, getUrl, stopChatMessageResponding, @@ -52,6 +53,11 @@ const ChatWrapper = () => { initUserVariables, } = useChatWithHistoryContext() + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp + + // Semantic variable for better code readability + const isHistoryConversation = !!currentConversationId + const appConfig = useMemo(() => { const config = appParams || {} @@ -79,7 +85,7 @@ const ChatWrapper = () => { inputsForm: inputsForms, }, appPrevChatTree, - taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), + taskId => stopChatMessageResponding('', taskId, appSourceType, appId), clearChatList, setClearChatList, ) @@ -138,11 +144,11 @@ const ChatWrapper = () => { } handleSend( - getUrl('chat-messages', isInstalledApp, appId || ''), + getUrl('chat-messages', appSourceType, appId || ''), data, { - onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId), - onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, + onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId), + onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted, isPublicAPI: !isInstalledApp, }, ) diff --git a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx index f6a8f25cbb..399f16716d 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.spec.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.spec.tsx @@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { ToastProvider } from '@/app/components/base/toast' import { + AppSourceType, fetchChatList, fetchConversations, generationConversationName, @@ -49,20 +50,24 @@ vi.mock('../utils', async () => { } }) -vi.mock('@/service/share', () => ({ - fetchChatList: vi.fn(), - fetchConversations: vi.fn(), - generationConversationName: vi.fn(), - fetchAppInfo: vi.fn(), - fetchAppMeta: vi.fn(), - fetchAppParams: vi.fn(), - getAppAccessModeByAppCode: vi.fn(), - delConversation: vi.fn(), - pinConversation: vi.fn(), - renameConversation: vi.fn(), - unpinConversation: vi.fn(), - updateFeedback: vi.fn(), -})) +vi.mock('@/service/share', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + fetchChatList: vi.fn(), + fetchConversations: vi.fn(), + generationConversationName: vi.fn(), + fetchAppInfo: vi.fn(), + fetchAppMeta: vi.fn(), + fetchAppParams: vi.fn(), + getAppAccessModeByAppCode: vi.fn(), + delConversation: vi.fn(), + pinConversation: vi.fn(), + renameConversation: vi.fn(), + unpinConversation: vi.fn(), + updateFeedback: vi.fn(), + } +}) const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchChatList = vi.mocked(fetchChatList) @@ -162,13 +167,13 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) + expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100) }) await waitFor(() => { - expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) + expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100) }) await waitFor(() => { - expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') + expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1') }) await waitFor(() => { expect(result.current.pinnedConversationList).toEqual(pinnedData.data) @@ -204,7 +209,7 @@ describe('useChatWithHistory', () => { // Assert await waitFor(() => { - expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') + expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new') }) await waitFor(() => { expect(result.current.conversationList[0]).toEqual(generatedConversation) diff --git a/web/app/components/base/chat/chat-with-history/hooks.tsx b/web/app/components/base/chat/chat-with-history/hooks.tsx index ed1981b530..ad1de38d07 100644 --- a/web/app/components/base/chat/chat-with-history/hooks.tsx +++ b/web/app/components/base/chat/chat-with-history/hooks.tsx @@ -27,6 +27,7 @@ import { useWebAppStore } from '@/context/web-app-context' import { useAppFavicon } from '@/hooks/use-app-favicon' import { changeLanguage } from '@/i18n-config/client' import { + AppSourceType, delConversation, pinConversation, renameConversation, @@ -72,6 +73,7 @@ function getFormattedChatList(messages: any[]) { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const appInfo = useWebAppStore(s => s.appInfo) const appParams = useWebAppStore(s => s.appParams) const appMeta = useWebAppStore(s => s.appMeta) @@ -177,7 +179,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [currentConversationId, newConversationId]) const { data: appPinnedConversationData } = useShareConversations({ - isInstalledApp, + appSourceType, appId, pinned: true, limit: 100, @@ -190,7 +192,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { data: appConversationData, isLoading: appConversationDataLoading, } = useShareConversations({ - isInstalledApp, + appSourceType, appId, pinned: false, limit: 100, @@ -204,7 +206,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { isLoading: appChatListDataLoading, } = useShareChatList({ conversationId: chatShouldReloadKey, - isInstalledApp, + appSourceType, appId, }, { enabled: !!chatShouldReloadKey, @@ -334,10 +336,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { const { data: newConversation } = useShareConversationName({ conversationId: newConversationId, - isInstalledApp, + appSourceType, appId, }, { refetchOnWindowFocus: false, + enabled: !!newConversationId, }) const [originConversationList, setOriginConversationList] = useState([]) useEffect(() => { @@ -462,16 +465,16 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [invalidateShareConversations]) const handlePinConversation = useCallback(async (conversationId: string) => { - await pinConversation(isInstalledApp, appId, conversationId) + await pinConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) handleUpdateConversationList() - }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + }, [appSourceType, appId, notify, t, handleUpdateConversationList]) const handleUnpinConversation = useCallback(async (conversationId: string) => { - await unpinConversation(isInstalledApp, appId, conversationId) + await unpinConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) handleUpdateConversationList() - }, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) + }, [appSourceType, appId, notify, t, handleUpdateConversationList]) const [conversationDeleting, setConversationDeleting] = useState(false) const handleDeleteConversation = useCallback(async ( @@ -485,7 +488,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { try { setConversationDeleting(true) - await delConversation(isInstalledApp, appId, conversationId) + await delConversation(appSourceType, appId, conversationId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) onSuccess() } @@ -520,7 +523,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { setConversationRenaming(true) try { - await renameConversation(isInstalledApp, appId, conversationId, newName) + await renameConversation(appSourceType, appId, conversationId, newName) notify({ type: 'success', @@ -550,9 +553,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { }, [handleConversationIdInfoChange, invalidateShareConversations]) const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId) + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) - }, [isInstalledApp, appId, t, notify]) + }, [appSourceType, appId, t, notify]) return { isInstalledApp, diff --git a/web/app/components/base/chat/chat/answer/index.tsx b/web/app/components/base/chat/chat/answer/index.tsx index 9f1efa3ae0..da46f47c61 100644 --- a/web/app/components/base/chat/chat/answer/index.tsx +++ b/web/app/components/base/chat/chat/answer/index.tsx @@ -150,7 +150,7 @@ const Answer: FC = ({ data={workflowProcess} item={item} hideProcessDetail={hideProcessDetail} - readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined} + readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined} /> ) } diff --git a/web/app/components/base/chat/chat/answer/suggested-questions.tsx b/web/app/components/base/chat/chat/answer/suggested-questions.tsx index 019ed78348..ce997a49b6 100644 --- a/web/app/components/base/chat/chat/answer/suggested-questions.tsx +++ b/web/app/components/base/chat/chat/answer/suggested-questions.tsx @@ -1,6 +1,7 @@ import type { FC } from 'react' import type { ChatItem } from '../../types' import { memo } from 'react' +import { cn } from '@/utils/classnames' import { useChatContext } from '../context' type SuggestedQuestionsProps = { @@ -9,7 +10,7 @@ type SuggestedQuestionsProps = { const SuggestedQuestions: FC = ({ item, }) => { - const { onSend } = useChatContext() + const { onSend, readonly } = useChatContext() const { isOpeningStatement, @@ -24,8 +25,11 @@ const SuggestedQuestions: FC = ({ {suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
onSend?.(question)} + className={cn( + 'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover', + readonly && 'pointer-events-none opacity-50', + )} + onClick={() => !readonly && onSend?.(question)} > {question}
diff --git a/web/app/components/base/chat/chat/chat-input-area/index.tsx b/web/app/components/base/chat/chat/chat-input-area/index.tsx index 192f46fb23..9de52cb18c 100644 --- a/web/app/components/base/chat/chat/chat-input-area/index.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/index.tsx @@ -5,6 +5,7 @@ import type { } from '../../types' import type { InputForm } from '../type' import type { FileUpload } from '@/app/components/base/features/types' +import { noop } from 'es-toolkit/function' import { decode } from 'html-entities' import Recorder from 'js-audio-recorder' import { @@ -30,6 +31,7 @@ import { useTextAreaHeight } from './hooks' import Operation from './operation' type ChatInputAreaProps = { + readonly?: boolean botName?: string showFeatureBar?: boolean showFileUpload?: boolean @@ -45,6 +47,7 @@ type ChatInputAreaProps = { disabled?: boolean } const ChatInputArea = ({ + readonly, botName, showFeatureBar, showFileUpload, @@ -170,6 +173,7 @@ const ChatInputArea = ({ const operation = (
{ @@ -239,7 +244,14 @@ const ChatInputArea = ({ ) }
- {showFeatureBar && } + {showFeatureBar && ( + + )} ) } diff --git a/web/app/components/base/chat/chat/chat-input-area/operation.tsx b/web/app/components/base/chat/chat/chat-input-area/operation.tsx index 27e5bf6cad..5bce827754 100644 --- a/web/app/components/base/chat/chat/chat-input-area/operation.tsx +++ b/web/app/components/base/chat/chat/chat-input-area/operation.tsx @@ -8,6 +8,7 @@ import { RiMicLine, RiSendPlane2Fill, } from '@remixicon/react' +import { noop } from 'es-toolkit/function' import { memo } from 'react' import ActionButton from '@/app/components/base/action-button' import Button from '@/app/components/base/button' @@ -15,6 +16,7 @@ import { FileUploaderInChatInput } from '@/app/components/base/file-uploader' import { cn } from '@/utils/classnames' type OperationProps = { + readonly?: boolean fileConfig?: FileUpload speechToTextConfig?: EnableType onShowVoiceInput?: () => void @@ -23,6 +25,7 @@ type OperationProps = { ref?: Ref } const Operation: FC = ({ + readonly, ref, fileConfig, speechToTextConfig, @@ -41,11 +44,12 @@ const Operation: FC = ({ ref={ref} >
- {fileConfig?.enabled && } + {fileConfig?.enabled && } { speechToTextConfig?.enabled && ( @@ -56,7 +60,7 @@ const Operation: FC = ({ + { + !hideEditEntrance && ( + + ) + }
)}
diff --git a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx index 1ae328d67a..08bb8b45d1 100644 --- a/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx +++ b/web/app/components/base/file-uploader/file-uploader-in-chat-input/index.tsx @@ -13,21 +13,27 @@ import FileFromLinkOrLocal from '../file-from-link-or-local' type FileUploaderInChatInputProps = { fileConfig: FileUpload + readonly?: boolean } const FileUploaderInChatInput = ({ fileConfig, + readonly, }: FileUploaderInChatInputProps) => { const renderTrigger = useCallback((open: boolean) => { return ( ) }, []) + if (readonly) + return renderTrigger(false) + return ( = ({ type TextGenerationImageUploaderProps = { settings: VisionSettings onFilesChange: (files: ImageFile[]) => void + disabled?: boolean } const TextGenerationImageUploader: FC = ({ settings, onFilesChange, + disabled, }) => { const { t } = useTranslation() @@ -93,7 +95,7 @@ const TextGenerationImageUploader: FC = ({ const localUpload = ( = settings.number_limits} + disabled={files.length >= settings.number_limits || disabled} limit={+settings.image_file_size_limit!} > { @@ -115,7 +117,7 @@ const TextGenerationImageUploader: FC = ({ const urlUpload = ( = settings.number_limits} + disabled={files.length >= settings.number_limits || disabled} /> ) diff --git a/web/app/components/base/tab-header/index.tsx b/web/app/components/base/tab-header/index.tsx index e762e23232..6ba6a354a3 100644 --- a/web/app/components/base/tab-header/index.tsx +++ b/web/app/components/base/tab-header/index.tsx @@ -16,6 +16,8 @@ export type ITabHeaderProps = { items: Item[] value: string itemClassName?: string + itemWrapClassName?: string + activeItemClassName?: string onChange: (value: string) => void } @@ -23,6 +25,8 @@ const TabHeader: FC = ({ items, value, itemClassName, + itemWrapClassName, + activeItemClassName, onChange, }) => { const renderItem = ({ id, name, icon, extra, disabled }: Item) => ( @@ -30,8 +34,9 @@ const TabHeader: FC = ({ key={id} className={cn( 'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5', - id === value ? 'border-components-tab-active text-text-primary' : 'text-text-tertiary', + id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary', disabled && 'cursor-not-allowed opacity-30', + itemWrapClassName, )} onClick={() => !disabled && onChange(id)} > diff --git a/web/app/components/base/voice-input/index.tsx b/web/app/components/base/voice-input/index.tsx index 4fa2c774f4..52e3c754f8 100644 --- a/web/app/components/base/voice-input/index.tsx +++ b/web/app/components/base/voice-input/index.tsx @@ -8,7 +8,7 @@ import { useParams, usePathname } from 'next/navigation' import { useCallback, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' -import { audioToText } from '@/service/share' +import { AppSourceType, audioToText } from '@/service/share' import { cn } from '@/utils/classnames' import s from './index.module.css' import { convertToMp3 } from './utils' @@ -108,7 +108,7 @@ const VoiceInput = ({ } try { - const audioResponse = await audioToText(url, isPublic, formData) + const audioResponse = await audioToText(url, isPublic ? AppSourceType.webApp : AppSourceType.installedApp, formData) onConverted(audioResponse.text) onCancel() } diff --git a/web/app/components/explore/app-card/index.spec.tsx b/web/app/components/explore/app-card/index.spec.tsx index 769b317929..152eab92a9 100644 --- a/web/app/components/explore/app-card/index.spec.tsx +++ b/web/app/components/explore/app-card/index.spec.tsx @@ -10,6 +10,7 @@ vi.mock('../../app/type-selector', () => ({ })) const createApp = (overrides?: Partial): App => ({ + can_trial: true, app_id: 'app-id', description: 'App description', copyright: '2024', diff --git a/web/app/components/explore/app-card/index.tsx b/web/app/components/explore/app-card/index.tsx index 0b6cd9920d..5d82ab65cc 100644 --- a/web/app/components/explore/app-card/index.tsx +++ b/web/app/components/explore/app-card/index.tsx @@ -1,8 +1,13 @@ 'use client' import type { App } from '@/models/explore' import { PlusIcon } from '@heroicons/react/20/solid' +import { RiInformation2Line } from '@remixicon/react' +import { useCallback } from 'react' import { useTranslation } from 'react-i18next' +import { useContextSelector } from 'use-context-selector' import AppIcon from '@/app/components/base/app-icon' +import ExploreContext from '@/context/explore-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { AppModeEnum } from '@/types/app' import { cn } from '@/utils/classnames' import { AppTypeIcon } from '../../app/type-selector' @@ -23,8 +28,17 @@ const AppCard = ({ }: AppCardProps) => { const { t } = useTranslation() const { app: appBasicInfo } = app + const { systemFeatures } = useGlobalPublicStore() + const isTrialApp = app.can_trial && systemFeatures.enable_trial_app + const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel) + const showTryAPPPanel = useCallback((appId: string) => { + return () => { + setShowTryAppPanel?.(true, { appId, app }) + } + }, [setShowTryAppPanel, app]) + return ( -
+
- {isExplore && canCreate && ( + {isExplore && (canCreate || isTrialApp) && ( )} diff --git a/web/app/components/explore/app-list/index.spec.tsx b/web/app/components/explore/app-list/index.spec.tsx index a9e4feeba8..a87d5a2363 100644 --- a/web/app/components/explore/app-list/index.spec.tsx +++ b/web/app/components/explore/app-list/index.spec.tsx @@ -16,9 +16,13 @@ let mockIsError = false const mockHandleImportDSL = vi.fn() const mockHandleImportDSLConfirm = vi.fn() -vi.mock('nuqs', () => ({ - useQueryState: () => [mockTabValue, mockSetTab], -})) +vi.mock('nuqs', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useQueryState: () => [mockTabValue, mockSetTab], + } +}) vi.mock('ahooks', async () => { const actual = await vi.importActual('ahooks') @@ -102,6 +106,7 @@ const createApp = (overrides: Partial = {}): App => ({ description: overrides.app?.description ?? 'Alpha description', use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, }, + can_trial: true, app_id: overrides.app_id ?? 'app-1', description: overrides.description ?? 'Alpha description', copyright: overrides.copyright ?? '', @@ -127,6 +132,8 @@ const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) => setInstalledApps: vi.fn(), isFetchingInstalledApps: false, setIsFetchingInstalledApps: vi.fn(), + isShowTryAppPanel: false, + setShowTryAppPanel: vi.fn(), }} > diff --git a/web/app/components/explore/app-list/index.tsx b/web/app/components/explore/app-list/index.tsx index 5b318b780b..1749bde76a 100644 --- a/web/app/components/explore/app-list/index.tsx +++ b/web/app/components/explore/app-list/index.tsx @@ -7,14 +7,17 @@ import { useQueryState } from 'nuqs' import * as React from 'react' import { useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' -import { useContext } from 'use-context-selector' +import { useContext, useContextSelector } from 'use-context-selector' import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal' +import Button from '@/app/components/base/button' import Input from '@/app/components/base/input' import Loading from '@/app/components/base/loading' import AppCard from '@/app/components/explore/app-card' +import Banner from '@/app/components/explore/banner/banner' import Category from '@/app/components/explore/category' import CreateAppModal from '@/app/components/explore/create-app-modal' import ExploreContext from '@/context/explore-context' +import { useGlobalPublicStore } from '@/context/global-public-context' import { useImportDSL } from '@/hooks/use-import-dsl' import { DSLImportMode, @@ -22,6 +25,7 @@ import { import { fetchAppDetail } from '@/service/explore' import { useExploreAppList } from '@/service/use-explore' import { cn } from '@/utils/classnames' +import TryApp from '../try-app' import s from './style.module.css' type AppsProps = { @@ -32,12 +36,19 @@ const Apps = ({ onSuccess, }: AppsProps) => { const { t } = useTranslation() + const { systemFeatures } = useGlobalPublicStore() const { hasEditPermission } = useContext(ExploreContext) const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' }) const [keywords, setKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('') + const hasFilterCondition = !!keywords + const handleResetFilter = useCallback(() => { + setKeywords('') + setSearchKeywords('') + }, []) + const { run: handleSearch } = useDebounceFn(() => { setSearchKeywords(keywords) }, { wait: 500 }) @@ -84,6 +95,18 @@ const Apps = ({ isFetching, } = useImportDSL() const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) + + const isShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.isShowTryAppPanel) + const setShowTryAppPanel = useContextSelector(ExploreContext, ctx => ctx.setShowTryAppPanel) + const hideTryAppPanel = useCallback(() => { + setShowTryAppPanel(false) + }, [setShowTryAppPanel]) + const appParams = useContextSelector(ExploreContext, ctx => ctx.currentApp) + const handleShowFromTryApp = useCallback(() => { + setCurrApp(appParams?.app || null) + setIsShowCreateModal(true) + }, [appParams?.app]) + const onCreate: CreateAppModalProps['onConfirm'] = async ({ name, icon_type, @@ -91,6 +114,8 @@ const Apps = ({ icon_background, description, }) => { + hideTryAppPanel() + const { export_data } = await fetchAppDetail( currApp?.app.id as string, ) @@ -137,22 +162,24 @@ const Apps = ({ 'flex h-full flex-col border-l-[0.5px] border-divider-regular', )} > - -
-
{t('apps.title', { ns: 'explore' })}
-
{t('apps.description', { ns: 'explore' })}
-
- + {systemFeatures.enable_explore_banner && ( +
+ +
+ )}
- +
+
{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}
+ {hasFilterCondition && ( + <> +
+ + + )} +
+
+ +
+
) } + + {isShowTryAppPanel && ( + + )}
) } diff --git a/web/app/components/explore/banner/banner-item.tsx b/web/app/components/explore/banner/banner-item.tsx new file mode 100644 index 0000000000..5ce810bafb --- /dev/null +++ b/web/app/components/explore/banner/banner-item.tsx @@ -0,0 +1,187 @@ +/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */ +import type { FC } from 'react' +import type { Banner } from '@/models/app' +import { RiArrowRightLine } from '@remixicon/react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useCarousel } from '@/app/components/base/carousel' +import { cn } from '@/utils/classnames' +import { IndicatorButton } from './indicator-button' + +type BannerItemProps = { + banner: Banner + autoplayDelay: number + isPaused?: boolean +} + +const RESPONSIVE_BREAKPOINT = 1200 +const MAX_RESPONSIVE_WIDTH = 600 +const INDICATOR_WIDTH = 20 +const INDICATOR_GAP = 8 +const MIN_VIEW_MORE_WIDTH = 480 + +export const BannerItem: FC = ({ banner, autoplayDelay, isPaused = false }) => { + const { t } = useTranslation() + const { api, selectedIndex } = useCarousel() + const { category, title, description, 'img-src': imgSrc } = banner.content + + const [resetKey, setResetKey] = useState(0) + const textAreaRef = useRef(null) + const [maxWidth, setMaxWidth] = useState(undefined) + + const slideInfo = useMemo(() => { + const slides = api?.slideNodes() ?? [] + const totalSlides = slides.length + const nextIndex = totalSlides > 0 ? (selectedIndex + 1) % totalSlides : 0 + return { slides, totalSlides, nextIndex } + }, [api, selectedIndex]) + + const indicatorsWidth = useMemo(() => { + const count = slideInfo.totalSlides + if (count === 0) + return 0 + // Calculate: indicator buttons + gaps + extra spacing (3 * 20px for divider and padding) + return (count + 2) * INDICATOR_WIDTH + (count - 1) * INDICATOR_GAP + }, [slideInfo.totalSlides]) + + const viewMoreStyle = useMemo(() => { + if (!maxWidth) + return undefined + return { + maxWidth: `${maxWidth}px`, + minWidth: indicatorsWidth ? `${Math.min(maxWidth - indicatorsWidth, MIN_VIEW_MORE_WIDTH)}px` : undefined, + } + }, [maxWidth, indicatorsWidth]) + + const responsiveStyle = useMemo( + () => (maxWidth !== undefined ? { maxWidth: `${maxWidth}px` } : undefined), + [maxWidth], + ) + + const incrementResetKey = useCallback(() => setResetKey(prev => prev + 1), []) + + useEffect(() => { + const updateMaxWidth = () => { + if (window.innerWidth < RESPONSIVE_BREAKPOINT && textAreaRef.current) { + const textAreaWidth = textAreaRef.current.offsetWidth + setMaxWidth(Math.min(textAreaWidth, MAX_RESPONSIVE_WIDTH)) + } + else { + setMaxWidth(undefined) + } + } + + updateMaxWidth() + + const resizeObserver = new ResizeObserver(updateMaxWidth) + if (textAreaRef.current) + resizeObserver.observe(textAreaRef.current) + + window.addEventListener('resize', updateMaxWidth) + + return () => { + resizeObserver.disconnect() + window.removeEventListener('resize', updateMaxWidth) + } + }, []) + + useEffect(() => { + incrementResetKey() + }, [selectedIndex, incrementResetKey]) + + const handleBannerClick = useCallback(() => { + incrementResetKey() + if (banner.link) + window.open(banner.link, '_blank', 'noopener,noreferrer') + }, [banner.link, incrementResetKey]) + + const handleIndicatorClick = useCallback((index: number) => { + incrementResetKey() + api?.scrollTo(index) + }, [api, incrementResetKey]) + + return ( +
+ {/* Left content area */} +
+
+ {/* Text section */} +
+ {/* Title area */} +
+

+ {category} +

+

+ {title} +

+
+ {/* Description area */} +
+

+ {description} +

+
+
+ + {/* Actions section */} +
+ {/* View more button */} +
+
+ +
+ + {t('banner.viewMore', { ns: 'explore' })} + +
+ +
+ {/* Slide navigation indicators */} +
+ {slideInfo.slides.map((_: unknown, index: number) => ( + handleIndicatorClick(index)} + /> + ))} +
+
+
+
+
+
+ + {/* Right image area */} +
+ {title} +
+
+ ) +} diff --git a/web/app/components/explore/banner/banner.tsx b/web/app/components/explore/banner/banner.tsx new file mode 100644 index 0000000000..4ec0efdb2b --- /dev/null +++ b/web/app/components/explore/banner/banner.tsx @@ -0,0 +1,94 @@ +import type { FC } from 'react' +import * as React from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Carousel } from '@/app/components/base/carousel' +import { useLocale } from '@/context/i18n' +import { useGetBanners } from '@/service/use-explore' +import Loading from '../../base/loading' +import { BannerItem } from './banner-item' + +const AUTOPLAY_DELAY = 5000 +const MIN_LOADING_HEIGHT = 168 +const RESIZE_DEBOUNCE_DELAY = 50 + +const LoadingState: FC = () => ( +
+ +
+) + +const Banner: FC = () => { + const locale = useLocale() + const { data: banners, isLoading, isError } = useGetBanners(locale) + const [isHovered, setIsHovered] = useState(false) + const [isResizing, setIsResizing] = useState(false) + const resizeTimerRef = useRef(null) + + const enabledBanners = useMemo( + () => banners?.filter(banner => banner.status === 'enabled') ?? [], + [banners], + ) + + const isPaused = isHovered || isResizing + + // Handle window resize to pause animation + useEffect(() => { + const handleResize = () => { + setIsResizing(true) + + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + + resizeTimerRef.current = setTimeout(() => { + setIsResizing(false) + }, RESIZE_DEBOUNCE_DELAY) + } + + window.addEventListener('resize', handleResize) + + return () => { + window.removeEventListener('resize', handleResize) + if (resizeTimerRef.current) + clearTimeout(resizeTimerRef.current) + } + }, []) + + if (isLoading) + return + + if (isError || enabledBanners.length === 0) + return null + + return ( + setIsHovered(true)} + onMouseLeave={() => setIsHovered(false)} + > + + {enabledBanners.map(banner => ( + + + + ))} + + + ) +} + +export default React.memo(Banner) diff --git a/web/app/components/explore/banner/indicator-button.tsx b/web/app/components/explore/banner/indicator-button.tsx new file mode 100644 index 0000000000..332dae53ba --- /dev/null +++ b/web/app/components/explore/banner/indicator-button.tsx @@ -0,0 +1,112 @@ +/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */ +import type { FC } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { cn } from '@/utils/classnames' + +type IndicatorButtonProps = { + index: number + selectedIndex: number + isNextSlide: boolean + autoplayDelay: number + resetKey: number + isPaused?: boolean + onClick: () => void +} + +const PROGRESS_MAX = 100 +const DEGREES_PER_PERCENT = 3.6 + +export const IndicatorButton: FC = ({ + index, + selectedIndex, + isNextSlide, + autoplayDelay, + resetKey, + isPaused = false, + onClick, +}) => { + const [progress, setProgress] = useState(0) + const frameIdRef = useRef(undefined) + const startTimeRef = useRef(0) + + const isActive = index === selectedIndex + const shouldAnimate = !document.hidden && !isPaused + + useEffect(() => { + if (!isNextSlide) { + setProgress(0) + if (frameIdRef.current) + cancelAnimationFrame(frameIdRef.current) + return + } + + setProgress(0) + startTimeRef.current = Date.now() + + const animate = () => { + if (!document.hidden && !isPaused) { + const elapsed = Date.now() - startTimeRef.current + const newProgress = Math.min((elapsed / autoplayDelay) * PROGRESS_MAX, PROGRESS_MAX) + setProgress(newProgress) + + if (newProgress < PROGRESS_MAX) + frameIdRef.current = requestAnimationFrame(animate) + } + else { + frameIdRef.current = requestAnimationFrame(animate) + } + } + + if (shouldAnimate) + frameIdRef.current = requestAnimationFrame(animate) + + return () => { + if (frameIdRef.current) + cancelAnimationFrame(frameIdRef.current) + } + }, [isNextSlide, autoplayDelay, resetKey, isPaused]) + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation() + onClick() + }, [onClick]) + + const progressDegrees = progress * DEGREES_PER_PERCENT + + return ( + + ) +} diff --git a/web/app/components/explore/category.tsx b/web/app/components/explore/category.tsx index 97a9ca92b3..47c2a4e3a7 100644 --- a/web/app/components/explore/category.tsx +++ b/web/app/components/explore/category.tsx @@ -29,7 +29,7 @@ const Category: FC = ({ const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn const itemClassName = (isSelected: boolean) => cn( - 'flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active', + 'system-sm-medium flex h-7 cursor-pointer items-center rounded-lg border border-transparent px-3 text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active', isSelected && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs', ) diff --git a/web/app/components/explore/index.tsx b/web/app/components/explore/index.tsx index 30132eea66..0b5e18a1de 100644 --- a/web/app/components/explore/index.tsx +++ b/web/app/components/explore/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { CurrentTryAppParams } from '@/context/explore-context' import type { InstalledApp } from '@/models/explore' import { useRouter } from 'next/navigation' import * as React from 'react' @@ -41,6 +42,16 @@ const Explore: FC = ({ return router.replace('/datasets') }, [isCurrentWorkspaceDatasetOperator]) + const [currentTryAppParams, setCurrentTryAppParams] = useState(undefined) + const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false) + const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => { + if (showTryAppPanel) + setCurrentTryAppParams(params) + else + setCurrentTryAppParams(undefined) + setIsShowTryAppPanel(showTryAppPanel) + } + return (
= ({ setInstalledApps, isFetchingInstalledApps, setIsFetchingInstalledApps, + currentApp: currentTryAppParams, + isShowTryAppPanel, + setShowTryAppPanel, } } > diff --git a/web/app/components/explore/installed-app/index.tsx b/web/app/components/explore/installed-app/index.tsx index def66c0260..7366057445 100644 --- a/web/app/components/explore/installed-app/index.tsx +++ b/web/app/components/explore/installed-app/index.tsx @@ -1,5 +1,6 @@ 'use client' import type { FC } from 'react' +import type { AccessMode } from '@/models/access-control' import type { AppData } from '@/models/share' import * as React from 'react' import { useEffect } from 'react' @@ -62,8 +63,8 @@ const InstalledApp: FC = ({ if (appMeta) updateWebAppMeta(appMeta) if (webAppAccessMode) - updateWebAppAccessMode(webAppAccessMode.accessMode) - updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) + updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode) + updateUserCanAccessApp(Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result)) }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) if (appParamsError) { diff --git a/web/app/components/explore/sidebar/app-nav-item/index.tsx b/web/app/components/explore/sidebar/app-nav-item/index.tsx index 3347efeb3f..08558578f6 100644 --- a/web/app/components/explore/sidebar/app-nav-item/index.tsx +++ b/web/app/components/explore/sidebar/app-nav-item/index.tsx @@ -56,7 +56,7 @@ export default function AppNavItem({ <>
-
{name}
+
{name}
e.stopPropagation()}> { setInstalledApps: vi.fn(), isFetchingInstalledApps: false, setIsFetchingInstalledApps: vi.fn(), - }} + } as unknown as IExplore} > , @@ -97,8 +98,8 @@ describe('SideBar', () => { renderWithContext(mockInstalledApps) // Assert - expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument() - expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument() + expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument() }) }) diff --git a/web/app/components/explore/sidebar/index.tsx b/web/app/components/explore/sidebar/index.tsx index 1257886165..3e9b664580 100644 --- a/web/app/components/explore/sidebar/index.tsx +++ b/web/app/components/explore/sidebar/index.tsx @@ -1,5 +1,7 @@ 'use client' import type { FC } from 'react' +import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react' +import { useBoolean } from 'ahooks' import Link from 'next/link' import { useSelectedLayoutSegments } from 'next/navigation' import * as React from 'react' @@ -14,18 +16,7 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s import { cn } from '@/utils/classnames' import Toast from '../../base/toast' import Item from './app-nav-item' - -const SelectedDiscoveryIcon = () => ( - - - -) - -const DiscoveryIcon = () => ( - - - -) +import NoApps from './no-apps' export type IExploreSideBarProps = { controlUpdateInstalledApps: number @@ -45,6 +36,9 @@ const SideBar: FC = ({ const media = useBreakpoints() const isMobile = media === MediaType.mobile + const [isFold, { + toggle: toggleIsFold, + }] = useBoolean(false) const [showConfirm, setShowConfirm] = useState(false) const [currId, setCurrId] = useState('') @@ -84,22 +78,31 @@ const SideBar: FC = ({ const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length return ( -
+
- {isDiscoverySelected ? : } - {!isMobile &&
{t('sidebar.discovery', { ns: 'explore' })}
} +
+ +
+ {!isMobile && !isFold &&
{t('sidebar.title', { ns: 'explore' })}
}
+ + {installedApps.length === 0 && !isMobile && !isFold + && ( +
+ +
+ )} + {installedApps.length > 0 && ( -
-

{t('sidebar.workspace', { ns: 'explore' })}

+
+ {!isMobile && !isFold &&

{t('sidebar.webApps', { ns: 'explore' })}

}
= ({ {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( = ({
)} + + {!isMobile && ( +
+ {isFold + ? + : ( + + )} +
+ )} + {showConfirm && ( { + const { t } = useTranslation() + const { theme } = useTheme() + return ( +
+
+
{t(`${i18nPrefix}.title`, { ns: 'explore' })}
+
{t(`${i18nPrefix}.description`, { ns: 'explore' })}
+ {t(`${i18nPrefix}.learnMore`, { ns: 'explore' })} +
+ ) +} +export default React.memo(NoApps) diff --git a/web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png b/web/app/components/explore/sidebar/no-apps/no-web-apps-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e153686fcd09b6c7b38f5acf81d183279ad07e9e GIT binary patch literal 22064 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 f_V#?Dqp$bR3O<;%-AvA^WS$=HeP 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 void +} + +const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3' + +const AppInfo: FC = ({ + appId, + className, + category, + appDetail, + onCreate, +}) => { + const { t } = useTranslation() + const mode = appDetail?.mode + const { requirements } = useGetRequirements({ appDetail, appId }) + return ( +
+ {/* name and icon */} +
+
+ + +
+
+
+
{appDetail.name}
+
+
+ {mode === 'advanced-chat' &&
{t('types.advanced', { ns: 'app' }).toUpperCase()}
} + {mode === 'chat' &&
{t('types.chatbot', { ns: 'app' }).toUpperCase()}
} + {mode === 'agent-chat' &&
{t('types.agent', { ns: 'app' }).toUpperCase()}
} + {mode === 'workflow' &&
{t('types.workflow', { ns: 'app' }).toUpperCase()}
} + {mode === 'completion' &&
{t('types.completion', { ns: 'app' }).toUpperCase()}
} +
+
+
+ {appDetail.description && ( +
{appDetail.description}
+ )} + + + {category && ( +
+
{t('tryApp.category', { ns: 'explore' })}
+
{category}
+
+ )} + {requirements.length > 0 && ( +
+
{t('tryApp.requirements', { ns: 'explore' })}
+
+ {requirements.map(item => ( +
+
+
{item.name}
+
+ ))} +
+
+ )} + +
+ ) +} +export default React.memo(AppInfo) diff --git a/web/app/components/explore/try-app/app-info/use-get-requirements.ts b/web/app/components/explore/try-app/app-info/use-get-requirements.ts new file mode 100644 index 0000000000..976989be73 --- /dev/null +++ b/web/app/components/explore/try-app/app-info/use-get-requirements.ts @@ -0,0 +1,78 @@ +import type { LLMNodeType } from '@/app/components/workflow/nodes/llm/types' +import type { ToolNodeType } from '@/app/components/workflow/nodes/tool/types' +import type { TryAppInfo } from '@/service/try-app' +import type { AgentTool } from '@/types/app' +import { uniqBy } from 'es-toolkit/compat' +import { BlockEnum } from '@/app/components/workflow/types' +import { MARKETPLACE_API_PREFIX } from '@/config' +import { useGetTryAppFlowPreview } from '@/service/use-try-app' + +type Params = { + appDetail: TryAppInfo + appId: string +} + +type RequirementItem = { + name: string + iconUrl: string +} +const getIconUrl = (provider: string, tool: string) => { + return `${MARKETPLACE_API_PREFIX}/plugins/${provider}/${tool}/icon` +} + +const useGetRequirements = ({ appDetail, appId }: Params) => { + const isBasic = ['chat', 'completion', 'agent-chat'].includes(appDetail.mode) + const isAgent = appDetail.mode === 'agent-chat' + const isAdvanced = !isBasic + const { data: flowData } = useGetTryAppFlowPreview(appId, isBasic) + + const requirements: RequirementItem[] = [] + if (isBasic) { + const modelProviderAndName = appDetail.model_config.model.provider.split('/') + const name = appDetail.model_config.model.provider.split('/').pop() || '' + requirements.push({ + name, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + }) + } + if (isAgent) { + requirements.push(...appDetail.model_config.agent_mode.tools.filter(data => (data as AgentTool).enabled).map((data) => { + const tool = data as AgentTool + const modelProviderAndName = tool.provider_id.split('/') + return { + name: tool.tool_label, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + } + })) + } + if (isAdvanced && flowData && flowData?.graph?.nodes?.length > 0) { + const nodes = flowData.graph.nodes + const llmNodes = nodes.filter(node => node.data.type === BlockEnum.LLM) + requirements.push(...llmNodes.map((node) => { + const data = node.data as LLMNodeType + const modelProviderAndName = data.model.provider.split('/') + return { + name: data.model.name, + iconUrl: getIconUrl(modelProviderAndName[0], modelProviderAndName[1]), + } + })) + + const toolNodes = nodes.filter(node => node.data.type === BlockEnum.Tool) + requirements.push(...toolNodes.map((node) => { + const data = node.data as ToolNodeType + const toolProviderAndName = data.provider_id.split('/') + return { + name: data.tool_label, + iconUrl: getIconUrl(toolProviderAndName[0], toolProviderAndName[1]), + } + })) + } + + const uniqueRequirements = uniqBy(requirements, 'name') + + return { + requirements: uniqueRequirements, + } +} + +export default useGetRequirements diff --git a/web/app/components/explore/try-app/app/chat.tsx b/web/app/components/explore/try-app/app/chat.tsx new file mode 100644 index 0000000000..b6b4a76ad5 --- /dev/null +++ b/web/app/components/explore/try-app/app/chat.tsx @@ -0,0 +1,104 @@ +'use client' +import type { FC } from 'react' +import type { + EmbeddedChatbotContextValue, +} from '@/app/components/base/chat/embedded-chatbot/context' +import type { TryAppInfo } from '@/service/try-app' +import { RiResetLeftLine } from '@remixicon/react' +import { useBoolean } from 'ahooks' +import * as React from 'react' +import { useEffect } from 'react' +import { useTranslation } from 'react-i18next' +import ActionButton from '@/app/components/base/action-button' +import Alert from '@/app/components/base/alert' +import AppIcon from '@/app/components/base/app-icon' +import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper' +import { + EmbeddedChatbotContext, +} from '@/app/components/base/chat/embedded-chatbot/context' +import { + useEmbeddedChatbot, +} from '@/app/components/base/chat/embedded-chatbot/hooks' +import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown' +import Tooltip from '@/app/components/base/tooltip' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { AppSourceType } from '@/service/share' +import { cn } from '@/utils/classnames' +import { useThemeContext } from '../../../base/chat/embedded-chatbot/theme/theme-context' + +type Props = { + appId: string + appDetail: TryAppInfo + className: string +} + +const TryApp: FC = ({ + appId, + appDetail, + className, +}) => { + const { t } = useTranslation() + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + const themeBuilder = useThemeContext() + const { removeConversationIdInfo, ...chatData } = useEmbeddedChatbot(AppSourceType.tryApp, appId) + const currentConversationId = chatData.currentConversationId + const inputsForms = chatData.inputsForms + useEffect(() => { + if (appId) + removeConversationIdInfo(appId) + }, [appId]) + const [isHideTryNotice, { + setTrue: hideTryNotice, + }] = useBoolean(false) + + const handleNewConversation = () => { + removeConversationIdInfo(appId) + chatData.handleNewConversation() + } + return ( + +
+
+
+ +
{appDetail.name}
+
+
+ {currentConversationId && ( + + + + + + )} + {currentConversationId && inputsForms.length > 0 && ( + + )} +
+
+
+ {!isHideTryNotice && ( + + )} + +
+
+
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/app/index.tsx b/web/app/components/explore/try-app/app/index.tsx new file mode 100644 index 0000000000..f5dc14510d --- /dev/null +++ b/web/app/components/explore/try-app/app/index.tsx @@ -0,0 +1,44 @@ +'use client' +import type { FC } from 'react' +import type { AppData } from '@/models/share' +import type { TryAppInfo } from '@/service/try-app' +import * as React from 'react' +import useDocumentTitle from '@/hooks/use-document-title' +import Chat from './chat' +import TextGeneration from './text-generation' + +type Props = { + appId: string + appDetail: TryAppInfo +} + +const TryApp: FC = ({ + appId, + appDetail, +}) => { + const mode = appDetail?.mode + const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) + const isCompletion = !isChat + + useDocumentTitle(appDetail?.site?.title || '') + return ( +
+ {isChat && ( + + )} + {isCompletion && ( + + )} +
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/app/text-generation.tsx b/web/app/components/explore/try-app/app/text-generation.tsx new file mode 100644 index 0000000000..3189e621e9 --- /dev/null +++ b/web/app/components/explore/try-app/app/text-generation.tsx @@ -0,0 +1,262 @@ +'use client' +import type { FC } from 'react' +import type { InputValueTypes, Task } from '../../../share/text-generation/types' +import type { MoreLikeThisConfig, PromptConfig, TextToSpeechConfig } from '@/models/debug' +import type { AppData, CustomConfigValueType, SiteInfo } from '@/models/share' +import type { VisionFile, VisionSettings } from '@/types/app' +import { useBoolean } from 'ahooks' +import { noop } from 'es-toolkit/function' +import * as React from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Alert from '@/app/components/base/alert' +import AppIcon from '@/app/components/base/app-icon' +import Loading from '@/app/components/base/loading' +import Res from '@/app/components/share/text-generation/result' +import { TaskStatus } from '@/app/components/share/text-generation/types' +import { appDefaultIconBackground } from '@/config' +import { useWebAppStore } from '@/context/web-app-context' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { AppSourceType } from '@/service/share' +import { useGetTryAppParams } from '@/service/use-try-app' +import { Resolution, TransferMethod } from '@/types/app' +import { cn } from '@/utils/classnames' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import RunOnce from '../../../share/text-generation/run-once' + +type Props = { + appId: string + className?: string + isWorkflow?: boolean + appData: AppData | null +} + +const TextGeneration: FC = ({ + appId, + className, + isWorkflow, + appData, +}) => { + const { t } = useTranslation() + const media = useBreakpoints() + const isPC = media === MediaType.pc + + const [inputs, doSetInputs] = useState>({}) + const inputsRef = useRef>(inputs) + const setInputs = useCallback((newInputs: Record) => { + doSetInputs(newInputs) + inputsRef.current = newInputs + }, []) + + const updateAppInfo = useWebAppStore(s => s.updateAppInfo) + const { data: tryAppParams } = useGetTryAppParams(appId) + + const updateAppParams = useWebAppStore(s => s.updateAppParams) + const appParams = useWebAppStore(s => s.appParams) + const [siteInfo, setSiteInfo] = useState(null) + const [promptConfig, setPromptConfig] = useState(null) + const [customConfig, setCustomConfig] = useState | null>(null) + const [moreLikeThisConfig, setMoreLikeThisConfig] = useState(null) + const [textToSpeechConfig, setTextToSpeechConfig] = useState(null) + const [controlSend, setControlSend] = useState(0) + const [visionConfig, setVisionConfig] = useState({ + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + }) + const [completionFiles, setCompletionFiles] = useState([]) + const [isShowResultPanel, { setTrue: doShowResultPanel, setFalse: hideResultPanel }] = useBoolean(false) + const showResultPanel = () => { + // fix: useClickAway hideResSidebar will close sidebar + setTimeout(() => { + doShowResultPanel() + }, 0) + } + + const handleSend = () => { + setControlSend(Date.now()) + showResultPanel() + } + + const [resultExisted, setResultExisted] = useState(false) + + useEffect(() => { + if (!appData) + return + updateAppInfo(appData) + }, [appData, updateAppInfo]) + + useEffect(() => { + if (!tryAppParams) + return + updateAppParams(tryAppParams) + }, [tryAppParams, updateAppParams]) + + useEffect(() => { + (async () => { + if (!appData || !appParams) + return + const { site: siteInfo, custom_config } = appData + setSiteInfo(siteInfo as SiteInfo) + setCustomConfig(custom_config) + + const { user_input_form, more_like_this, file_upload, text_to_speech } = appParams + setVisionConfig({ + // legacy of image upload compatible + ...file_upload, + transfer_methods: file_upload?.allowed_file_upload_methods || file_upload?.allowed_upload_methods, + // legacy of image upload compatible + image_file_size_limit: appParams?.system_parameters.image_file_size_limit, + fileUploadConfig: appParams?.system_parameters, + // eslint-disable-next-line ts/no-explicit-any + } as any) + const prompt_variables = userInputsFormToPromptVariables(user_input_form) + setPromptConfig({ + prompt_template: '', // placeholder for future + prompt_variables, + } as PromptConfig) + setMoreLikeThisConfig(more_like_this) + setTextToSpeechConfig(text_to_speech) + })() + }, [appData, appParams]) + + const [isCompleted, setIsCompleted] = useState(false) + const handleCompleted = useCallback(() => { + setIsCompleted(true) + }, []) + const [isHideTryNotice, { + setTrue: hideTryNotice, + }] = useBoolean(false) + + const renderRes = (task?: Task) => ( + setResultExisted(true)} + /> + ) + + const renderResWrap = ( +
+
+ {isCompleted && !isHideTryNotice && ( + + )} + {renderRes()} +
+
+ ) + + if (!siteInfo || !promptConfig) { + return ( +
+ +
+ ) + } + + return ( +
+ {/* Left */} +
+ {/* Header */} +
+
+ +
{siteInfo.title}
+
+ {siteInfo.description && ( +
{siteInfo.description}
+ )} +
+ {/* form */} +
+ +
+
+ + {/* Result */} +
+ {!isPC && ( +
{ + if (isShowResultPanel) + hideResultPanel() + else + showResultPanel() + }} + > +
+
+ )} + {renderResWrap} +
+
+ ) +} + +export default React.memo(TextGeneration) diff --git a/web/app/components/explore/try-app/index.tsx b/web/app/components/explore/try-app/index.tsx new file mode 100644 index 0000000000..b2e2b72140 --- /dev/null +++ b/web/app/components/explore/try-app/index.tsx @@ -0,0 +1,74 @@ +/* eslint-disable style/multiline-ternary */ +'use client' +import type { FC } from 'react' +import { RiCloseLine } from '@remixicon/react' +import * as React from 'react' +import { useState } from 'react' +import Loading from '@/app/components/base/loading' +import Modal from '@/app/components/base/modal/index' +import { useGetTryAppInfo } from '@/service/use-try-app' +import Button from '../../base/button' +import App from './app' +import AppInfo from './app-info' +import Preview from './preview' +import Tab, { TypeEnum } from './tab' + +type Props = { + appId: string + category?: string + onClose: () => void + onCreate: () => void +} + +const TryApp: FC = ({ + appId, + category, + onClose, + onCreate, +}) => { + const [type, setType] = useState(TypeEnum.TRY) + const { data: appDetail, isLoading } = useGetTryAppInfo(appId) + + return ( + + {isLoading ? ( +
+ +
+ ) : ( +
+
+ + +
+ {/* Main content */} +
+ {type === TypeEnum.TRY ? : } + +
+
+ )} +
+ ) +} +export default React.memo(TryApp) diff --git a/web/app/components/explore/try-app/preview/basic-app-preview.tsx b/web/app/components/explore/try-app/preview/basic-app-preview.tsx new file mode 100644 index 0000000000..6954546b2e --- /dev/null +++ b/web/app/components/explore/try-app/preview/basic-app-preview.tsx @@ -0,0 +1,367 @@ +/* eslint-disable ts/no-explicit-any */ +'use client' +import type { FC } from 'react' +import type { Features as FeaturesData, FileUpload } from '@/app/components/base/features/types' +import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations' +import type { ModelConfig } from '@/models/debug' +import type { ModelConfig as BackendModelConfig, PromptVariable } from '@/types/app' +import { noop } from 'es-toolkit/function' +import { clone } from 'es-toolkit/object' +import * as React from 'react' +import { useMemo, useState } from 'react' +import Config from '@/app/components/app/configuration/config' +import Debug from '@/app/components/app/configuration/debug' +import { FeaturesProvider } from '@/app/components/base/features' +import Loading from '@/app/components/base/loading' +import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' +import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations' +import { SupportUploadFileTypes } from '@/app/components/workflow/types' +import { ANNOTATION_DEFAULT, DEFAULT_AGENT_SETTING, DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/config' +import ConfigContext from '@/context/debug-configuration' +import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' +import { PromptMode } from '@/models/debug' +import { useAllToolProviders } from '@/service/use-tools' +import { useGetTryAppDataSets, useGetTryAppInfo } from '@/service/use-try-app' +import { ModelModeType, Resolution, TransferMethod } from '@/types/app' +import { correctModelProvider, correctToolProvider } from '@/utils' +import { userInputsFormToPromptVariables } from '@/utils/model-config' +import { basePath } from '@/utils/var' +import { useTextGenerationCurrentProviderAndModelAndModelList } from '../../../header/account-setting/model-provider-page/hooks' + +type Props = { + appId: string +} + +const defaultModelConfig = { + provider: 'langgenius/openai/openai', + model_id: 'gpt-3.5-turbo', + mode: ModelModeType.unset, + configs: { + prompt_template: '', + prompt_variables: [] as PromptVariable[], + }, + more_like_this: null, + opening_statement: '', + suggested_questions: [], + sensitive_word_avoidance: null, + speech_to_text: null, + text_to_speech: null, + file_upload: null, + suggested_questions_after_answer: null, + retriever_resource: null, + annotation_reply: null, + dataSets: [], + agentConfig: DEFAULT_AGENT_SETTING, +} +const BasicAppPreview: FC = ({ + appId, +}) => { + const media = useBreakpoints() + const isMobile = media === MediaType.mobile + + const { data: appDetail, isLoading: isLoadingAppDetail } = useGetTryAppInfo(appId) + const { data: collectionListFromServer, isLoading: isLoadingToolProviders } = useAllToolProviders() + const collectionList = collectionListFromServer?.map((item) => { + return { + ...item, + icon: basePath && typeof item.icon == 'string' && !item.icon.includes(basePath) ? `${basePath}${item.icon}` : item.icon, + } + }) + const datasetIds = (() => { + if (isLoadingAppDetail) + return [] + const modelConfig = appDetail?.model_config + if (!modelConfig) + return [] + let datasets: any = null + + if (modelConfig.agent_mode?.tools?.find(({ dataset }: any) => dataset?.enabled)) + datasets = modelConfig.agent_mode?.tools.filter(({ dataset }: any) => dataset?.enabled) + // new dataset struct + else if (modelConfig.dataset_configs.datasets?.datasets?.length > 0) + datasets = modelConfig.dataset_configs?.datasets?.datasets + + if (datasets?.length && datasets?.length > 0) + return datasets.map(({ dataset }: any) => dataset.id) + + return [] + })() + const { data: dataSetData, isLoading: isLoadingDatasets } = useGetTryAppDataSets(appId, datasetIds) + const dataSets = dataSetData?.data || [] + const isLoading = isLoadingAppDetail || isLoadingDatasets || isLoadingToolProviders + + const modelConfig: ModelConfig = ((modelConfig?: BackendModelConfig) => { + if (isLoading || !modelConfig) + return defaultModelConfig + + const model = modelConfig.model + + const newModelConfig = { + provider: correctModelProvider(model.provider), + model_id: model.name, + mode: model.mode, + configs: { + prompt_template: modelConfig.pre_prompt || '', + prompt_variables: userInputsFormToPromptVariables( + [ + ...(modelConfig.user_input_form as any), + ...( + modelConfig.external_data_tools?.length + ? modelConfig.external_data_tools.map((item) => { + return { + external_data_tool: { + variable: item.variable as string, + label: item.label as string, + enabled: item.enabled, + type: item.type as string, + config: item.config, + required: true, + icon: item.icon, + icon_background: item.icon_background, + }, + } + }) + : [] + ), + ], + modelConfig.dataset_query_variable, + ), + }, + more_like_this: modelConfig.more_like_this, + opening_statement: modelConfig.opening_statement, + suggested_questions: modelConfig.suggested_questions, + sensitive_word_avoidance: modelConfig.sensitive_word_avoidance, + speech_to_text: modelConfig.speech_to_text, + text_to_speech: modelConfig.text_to_speech, + file_upload: modelConfig.file_upload, + suggested_questions_after_answer: modelConfig.suggested_questions_after_answer, + retriever_resource: modelConfig.retriever_resource, + annotation_reply: modelConfig.annotation_reply, + external_data_tools: modelConfig.external_data_tools, + dataSets, + agentConfig: appDetail?.mode === 'agent-chat' + // eslint-disable-next-line style/multiline-ternary + ? ({ + max_iteration: DEFAULT_AGENT_SETTING.max_iteration, + ...modelConfig.agent_mode, + // remove dataset + enabled: true, // modelConfig.agent_mode?.enabled is not correct. old app: the value of app with dataset's is always true + tools: modelConfig.agent_mode?.tools.filter((tool: any) => { + return !tool.dataset + }).map((tool: any) => { + const toolInCollectionList = collectionList?.find(c => tool.provider_id === c.id) + return { + ...tool, + isDeleted: appDetail?.deleted_tools?.some((deletedTool: any) => deletedTool.id === tool.id && deletedTool.tool_name === tool.tool_name), + notAuthor: toolInCollectionList?.is_team_authorization === false, + ...(tool.provider_type === 'builtin' + ? { + provider_id: correctToolProvider(tool.provider_name, !!toolInCollectionList), + provider_name: correctToolProvider(tool.provider_name, !!toolInCollectionList), + } + : {}), + } + }), + }) : DEFAULT_AGENT_SETTING, + } + return (newModelConfig as any) + })(appDetail?.model_config) + const mode = appDetail?.mode + // const isChatApp = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!) + + // chat configuration + const promptMode = modelConfig?.prompt_type === PromptMode.advanced ? PromptMode.advanced : PromptMode.simple + const isAdvancedMode = promptMode === PromptMode.advanced + const isAgent = mode === 'agent-chat' + const chatPromptConfig = isAdvancedMode ? (modelConfig?.chat_prompt_config || clone(DEFAULT_CHAT_PROMPT_CONFIG)) : undefined + const suggestedQuestions = modelConfig?.suggested_questions || [] + const moreLikeThisConfig = modelConfig?.more_like_this || { enabled: false } + const suggestedQuestionsAfterAnswerConfig = modelConfig?.suggested_questions_after_answer || { enabled: false } + const speechToTextConfig = modelConfig?.speech_to_text || { enabled: false } + const textToSpeechConfig = modelConfig?.text_to_speech || { enabled: false, voice: '', language: '' } + const citationConfig = modelConfig?.retriever_resource || { enabled: false } + const annotationConfig = modelConfig?.annotation_reply || { + id: '', + enabled: false, + score_threshold: ANNOTATION_DEFAULT.score_threshold, + embedding_model: { + embedding_provider_name: '', + embedding_model_name: '', + }, + } + const moderationConfig = modelConfig?.sensitive_word_avoidance || { enabled: false } + // completion configuration + const completionPromptConfig = modelConfig?.completion_prompt_config || clone(DEFAULT_COMPLETION_PROMPT_CONFIG) as any + + // prompt & model config + const inputs = {} + const query = '' + const completionParams = useState({}) + + const { + currentModel: currModel, + } = useTextGenerationCurrentProviderAndModelAndModelList( + { + provider: modelConfig.provider, + model: modelConfig.model_id, + }, + ) + + const isShowVisionConfig = !!currModel?.features?.includes(ModelFeatureEnum.vision) + const isShowDocumentConfig = !!currModel?.features?.includes(ModelFeatureEnum.document) + const isShowAudioConfig = !!currModel?.features?.includes(ModelFeatureEnum.audio) + const isAllowVideoUpload = !!currModel?.features?.includes(ModelFeatureEnum.video) + const visionConfig = { + enabled: false, + number_limits: 2, + detail: Resolution.low, + transfer_methods: [TransferMethod.local_file], + } + + const featuresData: FeaturesData = useMemo(() => { + return { + moreLikeThis: modelConfig.more_like_this || { enabled: false }, + opening: { + enabled: !!modelConfig.opening_statement, + opening_statement: modelConfig.opening_statement || '', + suggested_questions: modelConfig.suggested_questions || [], + }, + moderation: modelConfig.sensitive_word_avoidance || { enabled: false }, + speech2text: modelConfig.speech_to_text || { enabled: false }, + text2speech: modelConfig.text_to_speech || { enabled: false }, + file: { + image: { + detail: modelConfig.file_upload?.image?.detail || Resolution.high, + enabled: !!modelConfig.file_upload?.image?.enabled, + number_limits: modelConfig.file_upload?.image?.number_limits || 3, + transfer_methods: modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + }, + enabled: !!(modelConfig.file_upload?.enabled || modelConfig.file_upload?.image?.enabled), + allowed_file_types: modelConfig.file_upload?.allowed_file_types || [], + allowed_file_extensions: modelConfig.file_upload?.allowed_file_extensions || [...FILE_EXTS[SupportUploadFileTypes.image], ...FILE_EXTS[SupportUploadFileTypes.video]].map(ext => `.${ext}`), + allowed_file_upload_methods: modelConfig.file_upload?.allowed_file_upload_methods || modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'], + number_limits: modelConfig.file_upload?.number_limits || modelConfig.file_upload?.image?.number_limits || 3, + fileUploadConfig: {}, + } as FileUpload, + suggested: modelConfig.suggested_questions_after_answer || { enabled: false }, + citation: modelConfig.retriever_resource || { enabled: false }, + annotationReply: modelConfig.annotation_reply || { enabled: false }, + } + }, [modelConfig]) + + if (isLoading) { + return ( +
+ +
+ ) + } + const value = { + readonly: true, + appId, + isAPIKeySet: true, + isTrailFinished: false, + mode, + modelModeType: '', + promptMode, + isAdvancedMode, + isAgent, + isOpenAI: false, + isFunctionCall: false, + collectionList: [], + setPromptMode: noop, + canReturnToSimpleMode: false, + setCanReturnToSimpleMode: noop, + chatPromptConfig, + completionPromptConfig, + currentAdvancedPrompt: '', + setCurrentAdvancedPrompt: noop, + conversationHistoriesRole: completionPromptConfig.conversation_histories_role, + showHistoryModal: false, + setConversationHistoriesRole: noop, + hasSetBlockStatus: true, + conversationId: '', + introduction: '', + setIntroduction: noop, + suggestedQuestions, + setSuggestedQuestions: noop, + setConversationId: noop, + controlClearChatMessage: false, + setControlClearChatMessage: noop, + prevPromptConfig: {}, + setPrevPromptConfig: noop, + moreLikeThisConfig, + setMoreLikeThisConfig: noop, + suggestedQuestionsAfterAnswerConfig, + setSuggestedQuestionsAfterAnswerConfig: noop, + speechToTextConfig, + setSpeechToTextConfig: noop, + textToSpeechConfig, + setTextToSpeechConfig: noop, + citationConfig, + setCitationConfig: noop, + annotationConfig, + setAnnotationConfig: noop, + moderationConfig, + setModerationConfig: noop, + externalDataToolsConfig: {}, + setExternalDataToolsConfig: noop, + formattingChanged: false, + setFormattingChanged: noop, + inputs, + setInputs: noop, + query, + setQuery: noop, + completionParams, + setCompletionParams: noop, + modelConfig, + setModelConfig: noop, + showSelectDataSet: noop, + dataSets, + setDataSets: noop, + datasetConfigs: [], + datasetConfigsRef: {}, + setDatasetConfigs: noop, + hasSetContextVar: true, + isShowVisionConfig, + visionConfig, + setVisionConfig: noop, + isAllowVideoUpload, + isShowDocumentConfig, + isShowAudioConfig, + rerankSettingModalOpen: false, + setRerankSettingModalOpen: noop, + } + return ( + + +
+
+
+ +
+ {!isMobile && ( +
+
+ +
+
+ )} +
+
+
+
+ ) +} +export default React.memo(BasicAppPreview) diff --git a/web/app/components/explore/try-app/preview/flow-app-preview.tsx b/web/app/components/explore/try-app/preview/flow-app-preview.tsx new file mode 100644 index 0000000000..ba64aecfba --- /dev/null +++ b/web/app/components/explore/try-app/preview/flow-app-preview.tsx @@ -0,0 +1,39 @@ +'use client' +import type { FC } from 'react' +import * as React from 'react' +import Loading from '@/app/components/base/loading' +import WorkflowPreview from '@/app/components/workflow/workflow-preview' +import { useGetTryAppFlowPreview } from '@/service/use-try-app' +import { cn } from '@/utils/classnames' + +type Props = { + appId: string + className?: string +} + +const FlowAppPreview: FC = ({ + appId, + className, +}) => { + const { data, isLoading } = useGetTryAppFlowPreview(appId) + + if (isLoading) { + return ( +
+ +
+ ) + } + if (!data) + return null + return ( +
+ +
+ ) +} +export default React.memo(FlowAppPreview) diff --git a/web/app/components/explore/try-app/preview/index.tsx b/web/app/components/explore/try-app/preview/index.tsx new file mode 100644 index 0000000000..a0c5fdc594 --- /dev/null +++ b/web/app/components/explore/try-app/preview/index.tsx @@ -0,0 +1,25 @@ +'use client' +import type { FC } from 'react' +import type { TryAppInfo } from '@/service/try-app' +import * as React from 'react' +import BasicAppPreview from './basic-app-preview' +import FlowAppPreview from './flow-app-preview' + +type Props = { + appId: string + appDetail: TryAppInfo +} + +const Preview: FC = ({ + appId, + appDetail, +}) => { + const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode) + + return ( +
+ {isBasicApp ? : } +
+ ) +} +export default React.memo(Preview) diff --git a/web/app/components/explore/try-app/tab.tsx b/web/app/components/explore/try-app/tab.tsx new file mode 100644 index 0000000000..75ba402204 --- /dev/null +++ b/web/app/components/explore/try-app/tab.tsx @@ -0,0 +1,37 @@ +'use client' +import type { FC } from 'react' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import TabHeader from '../../base/tab-header' + +export enum TypeEnum { + TRY = 'try', + DETAIL = 'detail', +} + +type Props = { + value: TypeEnum + onChange: (value: TypeEnum) => void +} + +const Tab: FC = ({ + value, + onChange, +}) => { + const { t } = useTranslation() + const tabs = [ + { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }) }, + { id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) }, + ] + return ( + void} + itemClassName="ml-0 system-md-semibold-uppercase" + itemWrapClassName="pt-2" + activeItemClassName="border-util-colors-blue-brand-blue-brand-500" + /> + ) +} +export default React.memo(Tab) diff --git a/web/app/components/share/text-generation/index.tsx b/web/app/components/share/text-generation/index.tsx index 509687e245..90a2fb9277 100644 --- a/web/app/components/share/text-generation/index.tsx +++ b/web/app/components/share/text-generation/index.tsx @@ -34,7 +34,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useDocumentTitle from '@/hooks/use-document-title' import { changeLanguage } from '@/i18n-config/client' import { AccessMode } from '@/models/access-control' -import { fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' +import { AppSourceType, fetchSavedMessage as doFetchSavedMessage, removeMessage, saveMessage } from '@/service/share' import { Resolution, TransferMethod } from '@/types/app' import { cn } from '@/utils/classnames' import { userInputsFormToPromptVariables } from '@/utils/model-config' @@ -69,10 +69,10 @@ export type IMainProps = { const TextGeneration: FC = ({ isInstalledApp = false, - installedAppInfo, isWorkflow = false, }) => { const { notify } = Toast + const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp const { t } = useTranslation() const media = useBreakpoints() @@ -102,16 +102,18 @@ const TextGeneration: FC = ({ // save message const [savedMessages, setSavedMessages] = useState([]) const fetchSavedMessage = useCallback(async () => { - const res: any = await doFetchSavedMessage(isInstalledApp, appId) + if (!appId) + return + const res: any = await doFetchSavedMessage(appSourceType, appId) setSavedMessages(res.data) - }, [isInstalledApp, appId]) + }, [appSourceType, appId]) const handleSaveMessage = async (messageId: string) => { - await saveMessage(messageId, isInstalledApp, appId) + await saveMessage(messageId, appSourceType, appId) notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) fetchSavedMessage() } const handleRemoveSavedMessage = async (messageId: string) => { - await removeMessage(messageId, isInstalledApp, appId) + await removeMessage(messageId, appSourceType, appId) notify({ type: 'success', message: t('api.remove', { ns: 'common' }) }) fetchSavedMessage() } @@ -423,9 +425,8 @@ const TextGeneration: FC = ({ isCallBatchAPI={isCallBatchAPI} isPC={isPC} isMobile={!isPC} - isInstalledApp={isInstalledApp} + appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp} appId={appId} - installedAppInfo={installedAppInfo} isError={task?.status === TaskStatus.failed} promptConfig={promptConfig} moreLikeThisEnabled={!!moreLikeThisConfig?.enabled} diff --git a/web/app/components/share/text-generation/result/index.tsx b/web/app/components/share/text-generation/result/index.tsx index a0ffb31b06..fe518c6d25 100644 --- a/web/app/components/share/text-generation/result/index.tsx +++ b/web/app/components/share/text-generation/result/index.tsx @@ -4,8 +4,8 @@ import type { FeedbackType } from '@/app/components/base/chat/chat/type' import type { WorkflowProcess } from '@/app/components/base/chat/types' import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { PromptConfig } from '@/models/debug' -import type { InstalledApp } from '@/models/explore' import type { SiteInfo } from '@/models/share' +import type { AppSourceType } from '@/service/share' import type { VisionFile, VisionSettings } from '@/types/app' import { RiLoader2Line } from '@remixicon/react' import { useBoolean } from 'ahooks' @@ -35,9 +35,8 @@ export type IResultProps = { isCallBatchAPI: boolean isPC: boolean isMobile: boolean - isInstalledApp: boolean - appId: string - installedAppInfo?: InstalledApp + appSourceType: AppSourceType + appId?: string isError: boolean isShowTextToSpeech: boolean promptConfig: PromptConfig | null @@ -63,9 +62,8 @@ const Result: FC = ({ isCallBatchAPI, isPC, isMobile, - isInstalledApp, + appSourceType, appId, - installedAppInfo, isError, isShowTextToSpeech, promptConfig, @@ -133,7 +131,7 @@ const Result: FC = ({ }) const handleFeedback = async (feedback: FeedbackType) => { - await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, installedAppInfo?.id) + await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId) setFeedback(feedback) } @@ -147,9 +145,9 @@ const Result: FC = ({ setIsStopping(true) try { if (isWorkflow) - await stopWorkflowMessage(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '') else - await stopChatMessageResponding(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') + await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '') abortControllerRef.current?.abort() } catch (error) { @@ -159,7 +157,7 @@ const Result: FC = ({ finally { setIsStopping(false) } - }, [appId, currentTaskId, installedAppInfo?.id, isInstalledApp, isStopping, isWorkflow, notify]) + }, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify]) useEffect(() => { if (!onRunControlChange) @@ -468,8 +466,8 @@ const Result: FC = ({ })) }, }, - isInstalledApp, - installedAppInfo?.id, + appSourceType, + appId, ).catch((error) => { setRespondingFalse() resetRunState() @@ -514,7 +512,7 @@ const Result: FC = ({ getAbortController: (abortController) => { abortControllerRef.current = abortController }, - }, isInstalledApp, installedAppInfo?.id) + }, appSourceType, appId) } } @@ -562,8 +560,8 @@ const Result: FC = ({ feedback={feedback} onSave={handleSaveMessage} isMobile={isMobile} - isInstalledApp={isInstalledApp} - installedAppId={installedAppInfo?.id} + appSourceType={appSourceType} + installedAppId={appId} isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false} taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined} controlClearMoreLikeThis={controlClearMoreLikeThis} diff --git a/web/app/components/share/text-generation/run-once/index.tsx b/web/app/components/share/text-generation/run-once/index.tsx index ca29ce1a98..4531ff8beb 100644 --- a/web/app/components/share/text-generation/run-once/index.tsx +++ b/web/app/components/share/text-generation/run-once/index.tsx @@ -1,4 +1,5 @@ import type { ChangeEvent, FC, FormEvent } from 'react' +import type { InputValueTypes } from '../types' import type { PromptConfig } from '@/models/debug' import type { SiteInfo } from '@/models/share' import type { VisionFile, VisionSettings } from '@/types/app' @@ -25,9 +26,9 @@ import { cn } from '@/utils/classnames' export type IRunOnceProps = { siteInfo: SiteInfo promptConfig: PromptConfig - inputs: Record - inputsRef: React.RefObject> - onInputsChange: (inputs: Record) => void + inputs: Record + inputsRef: React.RefObject> + onInputsChange: (inputs: Record) => void onSend: () => void visionConfig: VisionSettings onVisionFilesChange: (files: VisionFile[]) => void @@ -52,7 +53,7 @@ const RunOnce: FC = ({ const [isInitialized, setIsInitialized] = useState(false) const onClear = () => { - const newInputs: Record = {} + const newInputs: Record = {} promptConfig.prompt_variables.forEach((item) => { if (item.type === 'string' || item.type === 'paragraph') newInputs[item.key] = '' @@ -127,7 +128,7 @@ const RunOnce: FC = ({ {item.type === 'select' && ( ) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} maxLength={item.max_length} /> @@ -146,7 +147,7 @@ const RunOnce: FC = ({