feat: frontend part of support try apps (#31287)

Co-authored-by: CodingOnStar <hanxujiang@dify.ai>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
This commit is contained in:
Joel
2026-01-22 18:16:37 +08:00
committed by GitHub
parent c575c34ca6
commit b9f718005c
130 changed files with 3233 additions and 685 deletions

View File

@@ -271,9 +271,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
</div> </div>
)} )}
{hasVar && ( {hasVar && (
<div className="mt-1 px-3 pb-3"> <div className={cn('mt-1 grid px-3 pb-3')}>
<ReactSortable <ReactSortable
className="space-y-1" className={cn('grid-col-1 grid space-y-1', readonly && 'grid-cols-2 gap-1 space-y-0')}
list={promptVariablesWithIds} list={promptVariablesWithIds}
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }} setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
handle=".handle" handle=".handle"

View File

@@ -39,7 +39,7 @@ const VarItem: FC<ItemProps> = ({
const [isDeleting, setIsDeleting] = useState(false) const [isDeleting, setIsDeleting] = useState(false)
return ( return (
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed opacity-30', className)}> <div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed', className)}>
<VarIcon className={cn('mr-1 h-4 w-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} /> <VarIcon className={cn('mr-1 h-4 w-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} />
{canDrag && ( {canDrag && (
<RiDraggable className="absolute left-3 top-3 hidden h-3 w-3 cursor-pointer text-text-tertiary group-hover:block" /> <RiDraggable className="absolute left-3 top-3 hidden h-3 w-3 cursor-pointer text-text-tertiary group-hover:block" />

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { noop } from 'es-toolkit/function'
import { produce } from 'immer' import { produce } from 'immer'
import * as React from 'react' import * as React from 'react'
import { useCallback } 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 { Vision } from '@/app/components/base/icons/src/vender/features'
import Switch from '@/app/components/base/switch' import Switch from '@/app/components/base/switch'
import Tooltip from '@/app/components/base/tooltip' 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 { SupportUploadFileTypes } from '@/app/components/workflow/types'
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card' // import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
import ConfigContext from '@/context/debug-configuration' import ConfigContext from '@/context/debug-configuration'
import { Resolution } from '@/types/app'
import { cn } from '@/utils/classnames'
import ParamConfig from './param-config' import ParamConfig from './param-config'
const ConfigVision: FC = () => { const ConfigVision: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext) const { isShowVisionConfig, isAllowVideoUpload, readonly } = useContext(ConfigContext)
const file = useFeatures(s => s.features.file) const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore() const featuresStore = useFeaturesStore()
@@ -54,7 +58,7 @@ const ConfigVision: FC = () => {
setFeatures(newFeatures) setFeatures(newFeatures)
}, [featuresStore, isAllowVideoUpload]) }, [featuresStore, isAllowVideoUpload])
if (!isShowVisionConfig) if (!isShowVisionConfig || (readonly && !isImageEnabled))
return null return null
return ( return (
@@ -75,37 +79,55 @@ const ConfigVision: FC = () => {
/> />
</div> </div>
<div className="flex shrink-0 items-center"> <div className="flex shrink-0 items-center">
{/* <div className='mr-2 flex items-center gap-0.5'> {readonly
<div className='text-text-tertiary system-xs-medium-uppercase'>{t('appDebug.vision.visionSettings.resolution')}</div> ? (
<Tooltip <>
popupContent={ <div className="mr-2 flex items-center gap-0.5">
<div className='w-[180px]' > <div className="system-xs-medium-uppercase text-text-tertiary">{t('vision.visionSettings.resolution', { ns: 'appDebug' })}</div>
{t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => ( <Tooltip
<div key={item}>{item}</div> popupContent={(
))} <div className="w-[180px]">
</div> {t('vision.visionSettings.resolutionTooltip', { ns: 'appDebug' }).split('\n').map(item => (
} <div key={item}>{item}</div>
/> ))}
</div> */} </div>
{/* <div className='flex items-center gap-1'> )}
<OptionCard />
title={t('appDebug.vision.visionSettings.high')} </div>
selected={file?.image?.detail === Resolution.high} <div className="flex items-center gap-1">
onSelect={() => handleChange(Resolution.high)} <OptionCard
/> title={t('vision.visionSettings.high', { ns: 'appDebug' })}
<OptionCard selected={file?.image?.detail === Resolution.high}
title={t('appDebug.vision.visionSettings.low')} onSelect={noop}
selected={file?.image?.detail === Resolution.low} className={cn(
onSelect={() => handleChange(Resolution.low)} 'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
/> file?.image?.detail !== Resolution.high && 'hover:border-components-option-card-option-border',
</div> */} )}
<ParamConfig /> />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div> <OptionCard
<Switch title={t('vision.visionSettings.low', { ns: 'appDebug' })}
defaultValue={isImageEnabled} selected={file?.image?.detail === Resolution.low}
onChange={handleChange} onSelect={noop}
size="md" className={cn(
/> 'cursor-not-allowed rounded-lg px-3 hover:shadow-none',
file?.image?.detail !== Resolution.low && 'hover:border-components-option-card-option-border',
)}
/>
</div>
</>
)
: (
<>
<ParamConfig />
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular"></div>
<Switch
defaultValue={isImageEnabled}
onChange={handleChange}
size="md"
/>
</>
)}
</div> </div>
</div> </div>
) )

View File

@@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection }
const AgentTools: FC = () => { const AgentTools: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false) const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const { modelConfig, setModelConfig } = useContext(ConfigContext) const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext)
const { data: buildInTools } = useAllBuiltInTools() const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools() const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools() const { data: workflowTools } = useAllWorkflowTools()
@@ -168,10 +168,10 @@ const AgentTools: FC = () => {
{tools.filter(item => !!item.enabled).length} {tools.filter(item => !!item.enabled).length}
/ /
{tools.length} {tools.length}
&nbsp; &nbsp;
{t('agent.tools.enabled', { ns: 'appDebug' })} {t('agent.tools.enabled', { ns: 'appDebug' })}
</div> </div>
{tools.length < MAX_TOOLS_NUM && ( {tools.length < MAX_TOOLS_NUM && !readonly && (
<> <>
<div className="ml-3 mr-1 h-3.5 w-px bg-divider-regular"></div> <div className="ml-3 mr-1 h-3.5 w-px bg-divider-regular"></div>
<ToolPicker <ToolPicker
@@ -189,7 +189,7 @@ const AgentTools: FC = () => {
</div> </div>
)} )}
> >
<div className="grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2"> <div className={cn('grid grid-cols-1 items-center gap-1 2xl:grid-cols-2', readonly && 'cursor-not-allowed grid-cols-2')}>
{tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => ( {tools.map((item: AgentTool & { icon: any, collection?: Collection }, index) => (
<div <div
key={index} key={index}
@@ -214,7 +214,7 @@ const AgentTools: FC = () => {
> >
<span className="system-xs-medium pr-1.5 text-text-secondary">{getProviderShowName(item)}</span> <span className="system-xs-medium pr-1.5 text-text-secondary">{getProviderShowName(item)}</span>
<span className="text-text-tertiary">{item.tool_label}</span> <span className="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && ( {!item.isDeleted && !readonly && (
<Tooltip <Tooltip
popupContent={( popupContent={(
<div className="w-[180px]"> <div className="w-[180px]">
@@ -259,7 +259,7 @@ const AgentTools: FC = () => {
</div> </div>
</div> </div>
)} )}
{!item.isDeleted && ( {!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex"> <div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && ( {!item.notAuthor && (
<Tooltip <Tooltip
@@ -298,7 +298,7 @@ const AgentTools: FC = () => {
{!item.notAuthor && ( {!item.notAuthor && (
<Switch <Switch
defaultValue={item.isDeleted ? false : item.enabled} defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted} disabled={item.isDeleted || readonly}
size="md" size="md"
onChange={(enabled) => { onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => { const newModelConfig = produce(modelConfig, (draft) => {
@@ -312,6 +312,7 @@ const AgentTools: FC = () => {
{item.notAuthor && ( {item.notAuthor && (
<Button <Button
variant="secondary" variant="secondary"
disabled={readonly}
size="small" size="small"
onClick={() => { onClick={() => {
setCurrentTool(item) setCurrentTool(item)

View File

@@ -17,7 +17,7 @@ const ConfigAudio: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const file = useFeatures(s => s.features.file) const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore() const featuresStore = useFeaturesStore()
const { isShowAudioConfig } = useContext(ConfigContext) const { isShowAudioConfig, readonly } = useContext(ConfigContext)
const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false const isAudioEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.audio) ?? false
@@ -45,7 +45,7 @@ const ConfigAudio: FC = () => {
setFeatures(newFeatures) setFeatures(newFeatures)
}, [featuresStore]) }, [featuresStore])
if (!isShowAudioConfig) if (!isShowAudioConfig || (readonly && !isAudioEnabled))
return null return null
return ( return (
@@ -65,14 +65,16 @@ const ConfigAudio: FC = () => {
)} )}
/> />
</div> </div>
<div className="flex shrink-0 items-center"> {!readonly && (
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div> <div className="flex shrink-0 items-center">
<Switch <div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
defaultValue={isAudioEnabled} <Switch
onChange={handleChange} defaultValue={isAudioEnabled}
size="md" onChange={handleChange}
/> size="md"
</div> />
</div>
)}
</div> </div>
) )
} }

View File

@@ -17,7 +17,7 @@ const ConfigDocument: FC = () => {
const { t } = useTranslation() const { t } = useTranslation()
const file = useFeatures(s => s.features.file) const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore() const featuresStore = useFeaturesStore()
const { isShowDocumentConfig } = useContext(ConfigContext) const { isShowDocumentConfig, readonly } = useContext(ConfigContext)
const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? false
@@ -45,7 +45,7 @@ const ConfigDocument: FC = () => {
setFeatures(newFeatures) setFeatures(newFeatures)
}, [featuresStore]) }, [featuresStore])
if (!isShowDocumentConfig) if (!isShowDocumentConfig || (readonly && !isDocumentEnabled))
return null return null
return ( return (
@@ -65,14 +65,16 @@ const ConfigDocument: FC = () => {
)} )}
/> />
</div> </div>
<div className="flex shrink-0 items-center"> {!readonly && (
<div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div> <div className="flex shrink-0 items-center">
<Switch <div className="ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle"></div>
defaultValue={isDocumentEnabled} <Switch
onChange={handleChange} defaultValue={isDocumentEnabled}
size="md" onChange={handleChange}
/> size="md"
</div> />
</div>
)}
</div> </div>
) )
} }

View File

@@ -18,6 +18,7 @@ import ConfigDocument from './config-document'
const Config: FC = () => { const Config: FC = () => {
const { const {
readonly,
mode, mode,
isAdvancedMode, isAdvancedMode,
modelModeType, modelModeType,
@@ -27,6 +28,7 @@ const Config: FC = () => {
modelConfig, modelConfig,
setModelConfig, setModelConfig,
setPrevPromptConfig, setPrevPromptConfig,
dataSets,
} = useContext(ConfigContext) } = useContext(ConfigContext)
const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode) const isChatApp = [AppModeEnum.ADVANCED_CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.CHAT].includes(mode)
const formattingChangedDispatcher = useFormattingChangedDispatcher() const formattingChangedDispatcher = useFormattingChangedDispatcher()
@@ -65,19 +67,27 @@ const Config: FC = () => {
promptTemplate={promptTemplate} promptTemplate={promptTemplate}
promptVariables={promptVariables} promptVariables={promptVariables}
onChange={handlePromptChange} onChange={handlePromptChange}
readonly={readonly}
/> />
{/* Variables */} {/* Variables */}
<ConfigVar {!(readonly && promptVariables.length === 0) && (
promptVariables={promptVariables} <ConfigVar
onPromptVariablesChange={handlePromptVariablesNameChange} promptVariables={promptVariables}
/> onPromptVariablesChange={handlePromptVariablesNameChange}
readonly={readonly}
/>
)}
{/* Dataset */} {/* Dataset */}
<DatasetConfig /> {!(readonly && dataSets.length === 0) && (
<DatasetConfig
readonly={readonly}
hideMetadataFilter={readonly}
/>
)}
{/* Tools */} {/* Tools */}
{isAgent && ( {isAgent && !(readonly && modelConfig.agentConfig.tools.length === 0) && (
<AgentTools /> <AgentTools />
)} )}
@@ -88,7 +98,7 @@ const Config: FC = () => {
<ConfigAudio /> <ConfigAudio />
{/* Chat History */} {/* Chat History */}
{isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && ( {!readonly && isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
<HistoryPanel <HistoryPanel
showWarning={!hasSetBlockStatus.history} showWarning={!hasSetBlockStatus.history}
onShowEditModal={showHistoryModal} onShowEditModal={showHistoryModal}

View File

@@ -183,7 +183,7 @@ describe('dataset-config/card-item', () => {
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' })) expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ name: 'Updated dataset' }))
}) })
await waitFor(() => { await waitFor(() => {
expect(screen.getByText('Mock settings modal')).not.toBeVisible() expect(screen.queryByText('Mock settings modal')).not.toBeInTheDocument()
}) })
}) })

View File

@@ -30,6 +30,7 @@ const Item: FC<ItemProps> = ({
config, config,
onSave, onSave,
onRemove, onRemove,
readonly = false,
editable = true, editable = true,
}) => { }) => {
const media = useBreakpoints() const media = useBreakpoints()
@@ -56,6 +57,7 @@ const Item: FC<ItemProps> = ({
<div className={cn( <div className={cn(
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover', 'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
readonly && 'cursor-not-allowed',
)} )}
> >
<div className="flex w-0 grow items-center space-x-1.5"> <div className="flex w-0 grow items-center space-x-1.5">
@@ -70,7 +72,7 @@ const Item: FC<ItemProps> = ({
</div> </div>
<div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex"> <div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex">
{ {
editable && ( editable && !readonly && (
<ActionButton <ActionButton
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
@@ -81,14 +83,18 @@ const Item: FC<ItemProps> = ({
</ActionButton> </ActionButton>
) )
} }
<ActionButton {
onClick={() => onRemove(config.id)} !readonly && (
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default} <ActionButton
onMouseEnter={() => setIsDeleting(true)} onClick={() => onRemove(config.id)}
onMouseLeave={() => setIsDeleting(false)} state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
> onMouseEnter={() => setIsDeleting(true)}
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} /> onMouseLeave={() => setIsDeleting(false)}
</ActionButton> >
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
)
}
</div> </div>
{ {
!!config.indexing_technique && ( !!config.indexing_technique && (
@@ -107,11 +113,13 @@ const Item: FC<ItemProps> = ({
) )
} }
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl"> <Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName="mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl">
<SettingsModal {showSettingsModal && (
currentDataset={config} <SettingsModal
onCancel={() => setShowSettingsModal(false)} currentDataset={config}
onSave={handleSave} onCancel={() => setShowSettingsModal(false)}
/> onSave={handleSave}
/>
)}
</Drawer> </Drawer>
</div> </div>
) )

View File

@@ -30,6 +30,7 @@ import {
import { useSelector as useAppContextSelector } from '@/context/app-context' import { useSelector as useAppContextSelector } from '@/context/app-context'
import ConfigContext from '@/context/debug-configuration' import ConfigContext from '@/context/debug-configuration'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames'
import { hasEditPermissionForDataset } from '@/utils/permission' import { hasEditPermissionForDataset } from '@/utils/permission'
import FeaturePanel from '../base/feature-panel' import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn' import OperationBtn from '../base/operation-btn'
@@ -38,7 +39,11 @@ import CardItem from './card-item'
import ContextVar from './context-var' import ContextVar from './context-var'
import ParamsConfig from './params-config' import ParamsConfig from './params-config'
const DatasetConfig: FC = () => { type Props = {
readonly?: boolean
hideMetadataFilter?: boolean
}
const DatasetConfig: FC<Props> = ({ readonly, hideMetadataFilter }) => {
const { t } = useTranslation() const { t } = useTranslation()
const userProfile = useAppContextSelector(s => s.userProfile) const userProfile = useAppContextSelector(s => s.userProfile)
const { const {
@@ -259,17 +264,19 @@ const DatasetConfig: FC = () => {
className="mt-2" className="mt-2"
title={t('feature.dataSet.title', { ns: 'appDebug' })} title={t('feature.dataSet.title', { ns: 'appDebug' })}
headerRight={( headerRight={(
<div className="flex items-center gap-1"> !readonly && (
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />} <div className="flex items-center gap-1">
<OperationBtn type="add" onClick={showSelectDataSet} /> {!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
</div> <OperationBtn type="add" onClick={showSelectDataSet} />
</div>
)
)} )}
hasHeaderBottomBorder={!hasData} hasHeaderBottomBorder={!hasData}
noBodySpacing noBodySpacing
> >
{hasData {hasData
? ( ? (
<div className="mt-1 flex flex-wrap justify-between px-3 pb-3"> <div className={cn('mt-1 grid grid-cols-1 px-3 pb-3', readonly && 'grid-cols-2 gap-1')}>
{formattedDataset.map(item => ( {formattedDataset.map(item => (
<CardItem <CardItem
key={item.id} key={item.id}
@@ -277,6 +284,7 @@ const DatasetConfig: FC = () => {
onRemove={onRemove} onRemove={onRemove}
onSave={handleSave} onSave={handleSave}
editable={item.editable} editable={item.editable}
readonly={readonly}
/> />
))} ))}
</div> </div>
@@ -287,27 +295,29 @@ const DatasetConfig: FC = () => {
</div> </div>
)} )}
<div className="border-t border-t-divider-subtle py-2"> {!hideMetadataFilter && (
<MetadataFilter <div className="border-t border-t-divider-subtle py-2">
metadataList={metadataList} <MetadataFilter
selectedDatasetsLoaded metadataList={metadataList}
metadataFilterMode={datasetConfigs.metadata_filtering_mode} selectedDatasetsLoaded
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions} metadataFilterMode={datasetConfigs.metadata_filtering_mode}
handleAddCondition={handleAddCondition} metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleMetadataFilterModeChange={handleMetadataFilterModeChange} handleAddCondition={handleAddCondition}
handleRemoveCondition={handleRemoveCondition} handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator} handleRemoveCondition={handleRemoveCondition}
handleUpdateCondition={handleUpdateCondition} handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
metadataModelConfig={datasetConfigs.metadata_model_config} handleUpdateCondition={handleUpdateCondition}
handleMetadataModelChange={handleMetadataModelChange} metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange} handleMetadataModelChange={handleMetadataModelChange}
isCommonVariable handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)} isCommonVariable
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)} availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
/> availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
</div> />
</div>
)}
{mode === AppModeEnum.COMPLETION && dataSet.length > 0 && ( {!readonly && mode === AppModeEnum.COMPLETION && dataSet.length > 0 && (
<ContextVar <ContextVar
value={selectedContextVar?.key} value={selectedContextVar?.key}
options={promptVariablesToSelect} options={promptVariablesToSelect}

View File

@@ -18,7 +18,7 @@ const ChatUserInput = ({
inputs, inputs,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { modelConfig, setInputs } = useContext(ConfigContext) const { modelConfig, setInputs, readonly } = useContext(ConfigContext)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim() return key && key?.trim() && name && name?.trim()
@@ -88,6 +88,7 @@ const ChatUserInput = ({
placeholder={name} placeholder={name}
autoFocus={index === 0} autoFocus={index === 0}
maxLength={max_length} maxLength={max_length}
readOnly={readonly}
/> />
)} )}
{type === 'paragraph' && ( {type === 'paragraph' && (
@@ -96,6 +97,7 @@ const ChatUserInput = ({
placeholder={name} placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''} value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }} onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/> />
)} )}
{type === 'select' && ( {type === 'select' && (
@@ -105,6 +107,7 @@ const ChatUserInput = ({
onSelect={(i) => { handleInputValueChange(key, i.value as string) }} onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))} items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false} allowSearch={false}
disabled={readonly}
/> />
)} )}
{type === 'number' && ( {type === 'number' && (
@@ -115,6 +118,7 @@ const ChatUserInput = ({
placeholder={name} placeholder={name}
autoFocus={index === 0} autoFocus={index === 0}
maxLength={max_length} maxLength={max_length}
readOnly={readonly}
/> />
)} )}
{type === 'checkbox' && ( {type === 'checkbox' && (
@@ -123,6 +127,7 @@ const ChatUserInput = ({
value={!!inputs[key]} value={!!inputs[key]}
required={required} required={required}
onChange={(value) => { handleInputValueChange(key, value) }} onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/> />
)} )}
</div> </div>

View File

@@ -15,6 +15,7 @@ import { DEFAULT_CHAT_PROMPT_CONFIG, DEFAULT_COMPLETION_PROMPT_CONFIG } from '@/
import { useDebugConfigurationContext } from '@/context/debug-configuration' import { useDebugConfigurationContext } from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { AppSourceType } from '@/service/share'
import { promptVariablesToUserInputsForm } from '@/utils/model-config' import { promptVariablesToUserInputsForm } from '@/utils/model-config'
import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types' import { APP_CHAT_WITH_MULTIPLE_MODEL } from '../types'
@@ -130,11 +131,11 @@ const TextGenerationItem: FC<TextGenerationItemProps> = ({
return ( return (
<TextGeneration <TextGeneration
appSourceType={AppSourceType.webApp}
className="flex h-full flex-col overflow-y-auto border-none" className="flex h-full flex-col overflow-y-auto border-none"
content={completion} content={completion}
isLoading={!completion && isResponding} isLoading={!completion && isResponding}
isResponding={isResponding} isResponding={isResponding}
isInstalledApp={false}
siteInfo={null} siteInfo={null}
messageId={messageId} messageId={messageId}
isError={false} isError={false}

View File

@@ -39,6 +39,7 @@ const DebugWithSingleModel = (
) => { ) => {
const { userProfile } = useAppContext() const { userProfile } = useAppContext()
const { const {
readonly,
modelConfig, modelConfig,
appId, appId,
inputs, inputs,
@@ -150,6 +151,7 @@ const DebugWithSingleModel = (
return ( return (
<Chat <Chat
readonly={readonly}
config={config} config={config}
chatList={chatList} chatList={chatList}
isResponding={isResponding} isResponding={isResponding}

View File

@@ -38,6 +38,7 @@ import ConfigContext from '@/context/debug-configuration'
import { useEventEmitterContextContext } from '@/context/event-emitter' import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context' import { useProviderContext } from '@/context/provider-context'
import { sendCompletionMessage } from '@/service/debug' import { sendCompletionMessage } from '@/service/debug'
import { AppSourceType } from '@/service/share'
import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app' import { AppModeEnum, ModelModeType, TransferMethod } from '@/types/app'
import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config' import { formatBooleanInputs, promptVariablesToUserInputsForm } from '@/utils/model-config'
import GroupName from '../base/group-name' import GroupName from '../base/group-name'
@@ -72,6 +73,7 @@ const Debug: FC<IDebug> = ({
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
readonly,
appId, appId,
mode, mode,
modelModeType, modelModeType,
@@ -416,25 +418,33 @@ const Debug: FC<IDebug> = ({
} }
{mode !== AppModeEnum.COMPLETION && ( {mode !== AppModeEnum.COMPLETION && (
<> <>
<TooltipPlus {
popupContent={t('operation.refresh', { ns: 'common' })} !readonly && (
>
<ActionButton onClick={clearConversation}>
<RefreshCcw01 className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus <TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })} popupContent={t('operation.refresh', { ns: 'common' })}
> >
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => setExpanded(!expanded)}> <ActionButton onClick={clearConversation}>
<RiEqualizer2Line className="h-4 w-4" /> <RefreshCcw01 className="h-4 w-4" />
</ActionButton> </ActionButton>
</TooltipPlus> </TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />} )
</div> }
)}
{
varList.length > 0 && (
<div className="relative ml-1 mr-2">
<TooltipPlus
popupContent={t('panel.userInputField', { ns: 'workflow' })}
>
<ActionButton state={expanded ? ActionButtonState.Active : undefined} onClick={() => !readonly && setExpanded(!expanded)}>
<RiEqualizer2Line className="h-4 w-4" />
</ActionButton>
</TooltipPlus>
{expanded && <div className="absolute bottom-[-14px] right-[5px] z-10 h-3 w-3 rotate-45 border-l-[0.5px] border-t-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg" />}
</div>
)
}
</> </>
)} )}
</div> </div>
@@ -444,19 +454,21 @@ const Debug: FC<IDebug> = ({
<ChatUserInput inputs={inputs} /> <ChatUserInput inputs={inputs} />
</div> </div>
)} )}
{mode === AppModeEnum.COMPLETION && ( {
<PromptValuePanel mode === AppModeEnum.COMPLETION && (
appType={mode as AppModeEnum} <PromptValuePanel
onSend={handleSendTextCompletion} appType={mode as AppModeEnum}
inputs={inputs} onSend={handleSendTextCompletion}
visionConfig={{ inputs={inputs}
...features.file! as VisionSettings, visionConfig={{
transfer_methods: features.file!.allowed_file_upload_methods || [], ...features.file! as VisionSettings,
image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit, transfer_methods: features.file!.allowed_file_upload_methods || [],
}} image_file_size_limit: features.file?.fileUploadConfig?.image_file_size_limit,
onVisionFilesChange={setCompletionFiles} }}
/> onVisionFilesChange={setCompletionFiles}
)} />
)
}
</div> </div>
{ {
debugWithMultipleModel && ( debugWithMultipleModel && (
@@ -510,12 +522,12 @@ const Debug: FC<IDebug> = ({
<div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div> <div className="mx-4 mt-3"><GroupName name={t('result', { ns: 'appDebug' })} /></div>
<div className="mx-3 mb-8"> <div className="mx-3 mb-8">
<TextGeneration <TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2" className="mt-2"
content={completionRes} content={completionRes}
isLoading={!completionRes && isResponding} isLoading={!completionRes && isResponding}
isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel} isShowTextToSpeech={textToSpeechConfig.enabled && !!text2speechDefaultModel}
isResponding={isResponding} isResponding={isResponding}
isInstalledApp={false}
messageId={messageId} messageId={messageId}
isError={false} isError={false}
onRetry={noop} onRetry={noop}
@@ -550,13 +562,15 @@ const Debug: FC<IDebug> = ({
</div> </div>
) )
} }
{isShowFormattingChangeConfirm && ( {
<FormattingChanged isShowFormattingChangeConfirm && (
onConfirm={handleConfirm} <FormattingChanged
onCancel={handleCancel} onConfirm={handleConfirm}
/> onCancel={handleCancel}
)} />
{!isAPIKeySet && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)} )
}
{!isAPIKeySet && !readonly && (<HasNotSetAPIKEY isTrailFinished={!IS_CE_EDITION} onSetting={onSetting} />)}
</> </>
) )
} }

View File

@@ -40,7 +40,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
onVisionFilesChange, onVisionFilesChange,
}) => { }) => {
const { t } = useTranslation() 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 [userInputFieldCollapse, setUserInputFieldCollapse] = useState(false)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => { const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim() return key && key?.trim() && name && name?.trim()
@@ -78,12 +78,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
if (isAdvancedMode) { if (isAdvancedMode) {
if (modelModeType === ModelModeType.chat) if (modelModeType === ModelModeType.chat)
return chatPromptConfig.prompt.every(({ text }) => !text) return chatPromptConfig?.prompt.every(({ text }) => !text)
return !completionPromptConfig.prompt?.text return !completionPromptConfig.prompt?.text
} }
else { return !modelConfig.configs.prompt_template } 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) => { const handleInputValueChange = (key: string, value: string | boolean) => {
if (!(key in promptVariableObj)) if (!(key in promptVariableObj))
@@ -142,6 +142,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name} placeholder={name}
autoFocus={index === 0} autoFocus={index === 0}
maxLength={max_length} maxLength={max_length}
readOnly={readonly}
/> />
)} )}
{type === 'paragraph' && ( {type === 'paragraph' && (
@@ -150,6 +151,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name} placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''} value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }} onChange={(e) => { handleInputValueChange(key, e.target.value) }}
readOnly={readonly}
/> />
)} )}
{type === 'select' && ( {type === 'select' && (
@@ -160,6 +162,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
items={(options || []).map(i => ({ name: i, value: i }))} items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false} allowSearch={false}
bgClassName="bg-gray-50" bgClassName="bg-gray-50"
disabled={readonly}
/> />
)} )}
{type === 'number' && ( {type === 'number' && (
@@ -170,6 +173,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
placeholder={name} placeholder={name}
autoFocus={index === 0} autoFocus={index === 0}
maxLength={max_length} maxLength={max_length}
readOnly={readonly}
/> />
)} )}
{type === 'checkbox' && ( {type === 'checkbox' && (
@@ -178,6 +182,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
value={!!inputs[key]} value={!!inputs[key]}
required={required} required={required}
onChange={(value) => { handleInputValueChange(key, value) }} onChange={(value) => { handleInputValueChange(key, value) }}
readonly={readonly}
/> />
)} )}
</div> </div>
@@ -196,6 +201,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
url: fileItem.url, url: fileItem.url,
upload_file_id: fileItem.fileId, upload_file_id: fileItem.fileId,
})))} })))}
disabled={readonly}
/> />
</div> </div>
</div> </div>
@@ -204,12 +210,12 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
)} )}
{!userInputFieldCollapse && ( {!userInputFieldCollapse && (
<div className="flex justify-between border-t border-divider-subtle p-4 pt-3"> <div className="flex justify-between border-t border-divider-subtle p-4 pt-3">
<Button className="w-[72px]" onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button> <Button className="w-[72px]" disabled={readonly} onClick={onClear}>{t('operation.clear', { ns: 'common' })}</Button>
{canNotRun && ( {canNotRun && (
<Tooltip popupContent={t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}> <Tooltip popupContent={t('otherError.promptNoBeEmpty', { ns: 'appDebug' })}>
<Button <Button
variant="primary" variant="primary"
disabled={canNotRun} disabled={canNotRun || readonly}
onClick={() => onSend?.()} onClick={() => onSend?.()}
className="w-[96px]" className="w-[96px]"
> >
@@ -221,7 +227,7 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
{!canNotRun && ( {!canNotRun && (
<Button <Button
variant="primary" variant="primary"
disabled={canNotRun} disabled={canNotRun || readonly}
onClick={() => onSend?.()} onClick={() => onSend?.()}
className="w-[96px]" className="w-[96px]"
> >
@@ -237,6 +243,8 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
showFileUpload={false} showFileUpload={false}
isChatMode={appType !== AppModeEnum.COMPLETION} isChatMode={appType !== AppModeEnum.COMPLETION}
onFeatureBarClick={setShowAppConfigureFeaturesModal} onFeatureBarClick={setShowAppConfigureFeaturesModal}
disabled={readonly}
hideEditEntrance={readonly}
/> />
</div> </div>
</> </>

View File

@@ -10,6 +10,7 @@ vi.mock('@heroicons/react/20/solid', () => ({
})) }))
const mockApp: App = { const mockApp: App = {
can_trial: true,
app: { app: {
id: 'test-app-id', id: 'test-app-id',
mode: AppModeEnum.CHAT, mode: AppModeEnum.CHAT,

View File

@@ -1,9 +1,14 @@
'use client' 'use client'
import type { App } from '@/models/explore' import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid' import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon' import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button' 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 { cn } from '@/utils/classnames'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector' import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
@@ -20,6 +25,14 @@ const AppCard = ({
}: AppCardProps) => { }: AppCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { app: appBasicInfo } = app 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 ( return (
<div className={cn('group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg')}> <div className={cn('group relative flex h-[132px] cursor-pointer flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-4 shadow-xs hover:shadow-lg')}>
<div className="flex shrink-0 grow-0 items-center gap-3 pb-2"> <div className="flex shrink-0 grow-0 items-center gap-3 pb-2">
@@ -51,11 +64,17 @@ const AppCard = ({
</div> </div>
{canCreate && ( {canCreate && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}> <div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}> <div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" className="grow" onClick={() => onCreate()}> <Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" /> <PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span> <span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
</Button> </Button>
{isTrialApp && (
<Button onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -39,6 +39,7 @@ import { useAppContext } from '@/context/app-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useTimestamp from '@/hooks/use-timestamp' import useTimestamp from '@/hooks/use-timestamp'
import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log' import { fetchChatMessages, updateLogMessageAnnotations, updateLogMessageFeedbacks } from '@/service/log'
import { AppSourceType } from '@/service/share'
import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log' import { useChatConversationDetail, useCompletionConversationDetail } from '@/service/use-log'
import { AppModeEnum } from '@/types/app' import { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@@ -638,12 +639,12 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
</div> </div>
</div> </div>
<TextGeneration <TextGeneration
appSourceType={AppSourceType.webApp}
className="mt-2" className="mt-2"
content={detail.message.answer} content={detail.message.answer}
messageId={detail.message.id} messageId={detail.message.id}
isError={false} isError={false}
onRetry={noop} onRetry={noop}
isInstalledApp={false}
supportFeedback supportFeedback
feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')} feedback={detail.message.feedbacks.find((item: any) => item.from_source === 'admin')}
onFeedback={feedback => onFeedback(detail.message.id, feedback)} onFeedback={feedback => onFeedback(detail.message.id, feedback)}

View File

@@ -29,7 +29,7 @@ import { Markdown } from '@/app/components/base/markdown'
import NewAudioButton from '@/app/components/base/new-audio-button' import NewAudioButton from '@/app/components/base/new-audio-button'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { fetchTextGenerationMessage } from '@/service/debug' import { fetchTextGenerationMessage } from '@/service/debug'
import { fetchMoreLikeThis, updateFeedback } from '@/service/share' import { AppSourceType, fetchMoreLikeThis, updateFeedback } from '@/service/share'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import ResultTab from './result-tab' import ResultTab from './result-tab'
@@ -53,7 +53,7 @@ export type IGenerationItemProps = {
onFeedback?: (feedback: FeedbackType) => void onFeedback?: (feedback: FeedbackType) => void
onSave?: (messageId: string) => void onSave?: (messageId: string) => void
isMobile?: boolean isMobile?: boolean
isInstalledApp: boolean appSourceType: AppSourceType
installedAppId?: string installedAppId?: string
taskId?: string taskId?: string
controlClearMoreLikeThis?: number controlClearMoreLikeThis?: number
@@ -87,7 +87,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave, onSave,
depth = 1, depth = 1,
isMobile, isMobile,
isInstalledApp, appSourceType,
installedAppId, installedAppId,
taskId, taskId,
controlClearMoreLikeThis, controlClearMoreLikeThis,
@@ -100,6 +100,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const { t } = useTranslation() const { t } = useTranslation()
const params = useParams() const params = useParams()
const isTop = depth === 1 const isTop = depth === 1
const isTryApp = appSourceType === AppSourceType.tryApp
const [completionRes, setCompletionRes] = useState('') const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null) const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({ const [childFeedback, setChildFeedback] = useState<FeedbackType>({
@@ -113,7 +114,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal) const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => { 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) setChildFeedback(childFeedback)
} }
@@ -131,7 +132,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave, onSave,
isShowTextToSpeech, isShowTextToSpeech,
isMobile, isMobile,
isInstalledApp, appSourceType,
installedAppId, installedAppId,
controlClearMoreLikeThis, controlClearMoreLikeThis,
isWorkflow, isWorkflow,
@@ -145,7 +146,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
return return
} }
startQuerying() startQuerying()
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId) const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId)
setCompletionRes(res.answer) setCompletionRes(res.answer)
setChildFeedback({ setChildFeedback({
rating: null, rating: null,
@@ -310,7 +311,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)} )}
{/* action buttons */} {/* action buttons */}
<div className="absolute bottom-1 right-2 flex items-center"> <div className="absolute bottom-1 right-2 flex items-center">
{!isInWebApp && !isInstalledApp && !isResponding && ( {!isInWebApp && (appSourceType !== AppSourceType.installedApp) && !isResponding && (
<div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm"> <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm">
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}> <ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
<RiFileList3Line className="h-4 w-4" /> <RiFileList3Line className="h-4 w-4" />
@@ -319,12 +320,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</div> </div>
)} )}
<div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm"> <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm">
{moreLikeThis && ( {moreLikeThis && !isTryApp && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}> <ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
<RiSparklingLine className="h-4 w-4" /> <RiSparklingLine className="h-4 w-4" />
</ActionButton> </ActionButton>
)} )}
{isShowTextToSpeech && ( {isShowTextToSpeech && !isTryApp && (
<NewAudioButton <NewAudioButton
id={messageId!} id={messageId!}
voice={config?.text_to_speech?.voice} voice={config?.text_to_speech?.voice}
@@ -350,13 +351,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
<RiReplay15Line className="h-4 w-4" /> <RiReplay15Line className="h-4 w-4" />
</ActionButton> </ActionButton>
)} )}
{isInWebApp && !isWorkflow && ( {isInWebApp && !isWorkflow && !isTryApp && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}> <ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className="h-4 w-4" /> <RiBookmark3Line className="h-4 w-4" />
</ActionButton> </ActionButton>
)} )}
</div> </div>
{(supportFeedback || isInWebApp) && !isWorkflow && !isError && messageId && ( {(supportFeedback || isInWebApp) && !isWorkflow && !isTryApp && !isError && messageId && (
<div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm"> <div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm">
{!feedback?.rating && ( {!feedback?.rating && (
<> <>

View File

@@ -1,3 +1,5 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import * as React from '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 // Mock List component
vi.mock('./list', () => ({ vi.mock('./list', () => ({
default: () => { default: () => {
@@ -30,6 +41,25 @@ vi.mock('./list', () => ({
})) }))
describe('Apps', () => { describe('Apps', () => {
const createQueryClient = () => new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
})
const renderWithClient = (ui: React.ReactElement) => {
const queryClient = createQueryClient()
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
return {
queryClient,
...render(ui, { wrapper }),
}
}
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
documentTitleCalls = [] documentTitleCalls = []
@@ -38,17 +68,17 @@ describe('Apps', () => {
describe('Rendering', () => { describe('Rendering', () => {
it('should render without crashing', () => { it('should render without crashing', () => {
render(<Apps />) renderWithClient(<Apps />)
expect(screen.getByTestId('apps-list')).toBeInTheDocument() expect(screen.getByTestId('apps-list')).toBeInTheDocument()
}) })
it('should render List component', () => { it('should render List component', () => {
render(<Apps />) renderWithClient(<Apps />)
expect(screen.getByText('Apps List')).toBeInTheDocument() expect(screen.getByText('Apps List')).toBeInTheDocument()
}) })
it('should have correct container structure', () => { it('should have correct container structure', () => {
const { container } = render(<Apps />) const { container } = renderWithClient(<Apps />)
const wrapper = container.firstChild as HTMLElement const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col') expect(wrapper).toHaveClass('relative', 'flex', 'h-0', 'shrink-0', 'grow', 'flex-col')
}) })
@@ -56,19 +86,19 @@ describe('Apps', () => {
describe('Hooks', () => { describe('Hooks', () => {
it('should call useDocumentTitle with correct title', () => { it('should call useDocumentTitle with correct title', () => {
render(<Apps />) renderWithClient(<Apps />)
expect(documentTitleCalls).toContain('common.menus.apps') expect(documentTitleCalls).toContain('common.menus.apps')
}) })
it('should call useEducationInit', () => { it('should call useEducationInit', () => {
render(<Apps />) renderWithClient(<Apps />)
expect(educationInitCalls).toBeGreaterThan(0) expect(educationInitCalls).toBeGreaterThan(0)
}) })
}) })
describe('Integration', () => { describe('Integration', () => {
it('should render full component tree', () => { it('should render full component tree', () => {
render(<Apps />) renderWithClient(<Apps />)
// Verify container exists // Verify container exists
expect(screen.getByTestId('apps-list')).toBeInTheDocument() expect(screen.getByTestId('apps-list')).toBeInTheDocument()
@@ -79,23 +109,32 @@ describe('Apps', () => {
}) })
it('should handle multiple renders', () => { it('should handle multiple renders', () => {
const { rerender } = render(<Apps />) const queryClient = createQueryClient()
const { rerender } = render(
<QueryClientProvider client={queryClient}>
<Apps />
</QueryClientProvider>,
)
expect(screen.getByTestId('apps-list')).toBeInTheDocument() expect(screen.getByTestId('apps-list')).toBeInTheDocument()
rerender(<Apps />) rerender(
<QueryClientProvider client={queryClient}>
<Apps />
</QueryClientProvider>,
)
expect(screen.getByTestId('apps-list')).toBeInTheDocument() expect(screen.getByTestId('apps-list')).toBeInTheDocument()
}) })
}) })
describe('Styling', () => { describe('Styling', () => {
it('should have overflow-y-auto class', () => { it('should have overflow-y-auto class', () => {
const { container } = render(<Apps />) const { container } = renderWithClient(<Apps />)
const wrapper = container.firstChild as HTMLElement const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('overflow-y-auto') expect(wrapper).toHaveClass('overflow-y-auto')
}) })
it('should have background styling', () => { it('should have background styling', () => {
const { container } = render(<Apps />) const { container } = renderWithClient(<Apps />)
const wrapper = container.firstChild as HTMLElement const wrapper = container.firstChild as HTMLElement
expect(wrapper).toHaveClass('bg-background-body') expect(wrapper).toHaveClass('bg-background-body')
}) })

View File

@@ -1,7 +1,17 @@
'use client' '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 { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks' import { useEducationInit } from '@/app/education-apply/hooks'
import AppListContext from '@/context/app-list-context'
import useDocumentTitle from '@/hooks/use-document-title' 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' import List from './list'
const Apps = () => { const Apps = () => {
@@ -10,10 +20,124 @@ const Apps = () => {
useDocumentTitle(t('menus.apps', { ns: 'common' })) useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit() useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(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 ( return (
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body"> <AppListContext.Provider value={{
<List /> currentApp: currentTryAppParams,
</div> isShowTryAppPanel,
setShowTryAppPanel,
controlHideCreateFromTemplatePanel,
}}
>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List controlRefreshList={controlRefreshList} />
{isShowTryAppPanel && (
<TryApp
appId={currentTryAppParams?.appId || ''}
category={currentTryAppParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
{
showDSLConfirmModal && (
<DSLConfirmModal
versions={versions}
onCancel={() => setShowDSLConfirmModal(false)}
onConfirm={onConfirmDSL}
confirmDisabled={isFetching}
/>
)
}
{isShowCreateModal && (
<CreateAppModal
appIconType={currApp?.app.icon_type || 'emoji'}
appIcon={currApp?.app.icon || ''}
appIconBackground={currApp?.app.icon_background || ''}
appIconUrl={currApp?.app.icon_url}
appName={currApp?.app.name || ''}
appDescription={currApp?.app.description || ''}
show
onConfirm={onCreate}
confirmDisabled={isFetching}
onHide={() => setIsShowCreateModal(false)}
/>
)}
</div>
</AppListContext.Provider>
) )
} }

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import type { FC } from 'react'
import { import {
RiApps2Line, RiApps2Line,
RiDragDropLine, RiDragDropLine,
@@ -53,7 +54,12 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false, ssr: false,
}) })
const List = () => { type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
}) => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore() const { systemFeatures } = useGlobalPublicStore()
const router = useRouter() const router = useRouter()
@@ -110,6 +116,13 @@ const List = () => {
refetch, refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator }) } = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null) const anchorRef = useRef<HTMLDivElement>(null)
const options = [ const options = [
{ value: 'all', text: t('types.all', { ns: 'app' }), icon: <RiApps2Line className="mr-1 h-[14px] w-[14px]" /> }, { value: 'all', text: t('types.all', { ns: 'app' }), icon: <RiApps2Line className="mr-1 h-[14px] w-[14px]" /> },

View File

@@ -6,10 +6,12 @@ import {
useSearchParams, useSearchParams,
} from 'next/navigation' } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
import { useMemo, useState } from 'react' import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal' import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files' 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 { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
@@ -55,6 +57,13 @@ const CreateAppCard = ({
return undefined return undefined
}, [dslUrl]) }, [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 ( return (
<div <div
ref={ref} ref={ref}

View File

@@ -51,11 +51,16 @@ function getActionButtonState(state: ActionButtonState) {
} }
} }
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => { const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, disabled, ...props }: ActionButtonProps) => {
return ( return (
<button <button
type="button" type="button"
className={cn(actionButtonVariants({ className, size }), getActionButtonState(state))} className={cn(
actionButtonVariants({ className, size }),
getActionButtonState(state),
disabled && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
)}
disabled={disabled}
ref={ref} ref={ref}
style={styleCss} style={styleCss}
{...props} {...props}

View File

@@ -0,0 +1,59 @@
import {
RiCloseLine,
RiInformation2Fill,
} from '@remixicon/react'
import { cva } from 'class-variance-authority'
import {
memo,
} from 'react'
import { cn } from '@/utils/classnames'
type Props = {
type?: 'info'
message: string
onHide: () => void
className?: string
}
const bgVariants = cva(
'',
{
variants: {
type: {
info: 'from-components-badge-status-light-normal-halo to-background-gradient-mask-transparent',
},
},
},
)
const Alert: React.FC<Props> = ({
type = 'info',
message,
onHide,
className,
}) => {
return (
<div className={cn('pointer-events-none w-full', className)}>
<div
className="relative flex space-x-1 overflow-hidden rounded-xl border border-components-panel-border bg-components-panel-bg-blur p-3 shadow-lg"
>
<div className={cn('pointer-events-none absolute inset-0 bg-gradient-to-r opacity-[0.4]', bgVariants({ type }))}>
</div>
<div className="flex h-6 w-6 items-center justify-center">
<RiInformation2Fill className="text-text-accent" />
</div>
<div className="p-1">
<div className="system-xs-regular text-text-secondary">
{message}
</div>
</div>
<div
className="pointer-events-auto flex h-6 w-6 cursor-pointer items-center justify-center"
onClick={onHide}
>
<RiCloseLine className="h-4 w-4 text-text-tertiary" />
</div>
</div>
</div>
)
}
export default memo(Alert)

View File

@@ -1,5 +1,5 @@
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
import { textToAudioStream } from '@/service/share' import { AppSourceType, textToAudioStream } from '@/service/share'
declare global { declare global {
// eslint-disable-next-line ts/consistent-type-definitions // eslint-disable-next-line ts/consistent-type-definitions
@@ -100,7 +100,7 @@ export default class AudioPlayer {
private async loadAudio() { private async loadAudio() {
try { try {
const audioResponse: any = await textToAudioStream(this.url, this.isPublic, { content_type: 'audio/mpeg' }, { const audioResponse: any = await textToAudioStream(this.url, this.isPublic ? AppSourceType.webApp : AppSourceType.installedApp, { content_type: 'audio/mpeg' }, {
message_id: this.msgId, message_id: this.msgId,
streaming: true, streaming: true,
voice: this.voice, voice: this.voice,

View File

@@ -0,0 +1,227 @@
/* eslint-disable react-hooks-extra/no-direct-set-state-in-use-effect */
import type { UseEmblaCarouselType } from 'embla-carousel-react'
import Autoplay from 'embla-carousel-autoplay'
import useEmblaCarousel from 'embla-carousel-react'
import * as React from 'react'
import { cn } from '@/utils/classnames'
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: 'horizontal' | 'vertical'
}
type CarouselContextValue = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
selectedIndex: number
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextValue | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context)
throw new Error('useCarousel must be used within a <Carousel />')
return context
}
type TCarousel = {
Content: typeof CarouselContent
Item: typeof CarouselItem
Previous: typeof CarouselPrevious
Next: typeof CarouselNext
Dot: typeof CarouselDot
Plugin: typeof CarouselPlugins
} & React.ForwardRefExoticComponent<
React.HTMLAttributes<HTMLDivElement> & CarouselProps & React.RefAttributes<CarouselContextValue>
>
const Carousel: TCarousel = React.forwardRef(
({ orientation = 'horizontal', opts, plugins, className, children, ...props }, ref) => {
const [carouselRef, api] = useEmblaCarousel(
{ ...opts, axis: orientation === 'horizontal' ? 'x' : 'y' },
plugins,
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const [selectedIndex, setSelectedIndex] = React.useState(0)
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
React.useEffect(() => {
if (!api)
return
const onSelect = (api: CarouselApi) => {
if (!api)
return
setSelectedIndex(api.selectedScrollSnap())
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}
onSelect(api)
api.on('reInit', onSelect)
api.on('select', onSelect)
return () => {
api?.off('select', onSelect)
}
}, [api])
React.useImperativeHandle(ref, () => ({
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
selectedIndex,
canScrollPrev,
canScrollNext,
}))
return (
<CarouselContext.Provider
value={{
carouselRef,
api,
opts,
orientation,
scrollPrev,
scrollNext,
selectedIndex,
canScrollPrev,
canScrollNext,
}}
>
<div
ref={carouselRef}
// onKeyDownCapture={handleKeyDown}
className={cn('relative overflow-hidden', className)}
role="region"
aria-roledescription="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
},
) as TCarousel
Carousel.displayName = 'Carousel'
const CarouselContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
const { orientation } = useCarousel()
return (
<div
ref={ref}
className={cn('flex', orientation === 'vertical' && 'flex-col', className)}
{...props}
/>
)
},
)
CarouselContent.displayName = 'CarouselContent'
const CarouselItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => {
return (
<div
ref={ref}
role="group"
aria-roledescription="slide"
className={cn('min-w-0 shrink-0 grow-0 basis-full', className)}
{...props}
/>
)
},
)
CarouselItem.displayName = 'CarouselItem'
type CarouselActionProps = {
children?: React.ReactNode
} & Omit<React.HTMLAttributes<HTMLButtonElement>, 'disabled' | 'onClick'>
const CarouselPrevious = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { scrollPrev, canScrollPrev } = useCarousel()
return (
<button ref={ref} {...props} disabled={!canScrollPrev} onClick={scrollPrev}>
{children}
</button>
)
},
)
CarouselPrevious.displayName = 'CarouselPrevious'
const CarouselNext = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { scrollNext, canScrollNext } = useCarousel()
return (
<button ref={ref} {...props} disabled={!canScrollNext} onClick={scrollNext}>
{children}
</button>
)
},
)
CarouselNext.displayName = 'CarouselNext'
const CarouselDot = React.forwardRef<HTMLButtonElement, CarouselActionProps>(
({ children, ...props }, ref) => {
const { api, selectedIndex } = useCarousel()
return api?.slideNodes().map((_, index) => {
return (
<button
key={index}
ref={ref}
{...props}
data-state={index === selectedIndex ? 'active' : 'inactive'}
onClick={() => {
api.scrollTo(index)
}}
>
{children}
</button>
)
})
},
)
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 }

View File

@@ -12,6 +12,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested
import { Markdown } from '@/app/components/base/markdown' import { Markdown } from '@/app/components/base/markdown'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { import {
AppSourceType,
fetchSuggestedQuestions, fetchSuggestedQuestions,
getUrl, getUrl,
stopChatMessageResponding, stopChatMessageResponding,
@@ -52,6 +53,11 @@ const ChatWrapper = () => {
initUserVariables, initUserVariables,
} = useChatWithHistoryContext() } = useChatWithHistoryContext()
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
// Semantic variable for better code readability
const isHistoryConversation = !!currentConversationId
const appConfig = useMemo(() => { const appConfig = useMemo(() => {
const config = appParams || {} const config = appParams || {}
@@ -79,7 +85,7 @@ const ChatWrapper = () => {
inputsForm: inputsForms, inputsForm: inputsForms,
}, },
appPrevChatTree, appPrevChatTree,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList, clearChatList,
setClearChatList, setClearChatList,
) )
@@ -138,11 +144,11 @@ const ChatWrapper = () => {
} }
handleSend( handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''), getUrl('chat-messages', appSourceType, appId || ''),
data, data,
{ {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId), onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp, isPublicAPI: !isInstalledApp,
}, },
) )

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react' import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast' import { ToastProvider } from '@/app/components/base/toast'
import { import {
AppSourceType,
fetchChatList, fetchChatList,
fetchConversations, fetchConversations,
generationConversationName, generationConversationName,
@@ -49,20 +50,24 @@ vi.mock('../utils', async () => {
} }
}) })
vi.mock('@/service/share', () => ({ vi.mock('@/service/share', async (importOriginal) => {
fetchChatList: vi.fn(), const actual = await importOriginal<typeof import('@/service/share')>()
fetchConversations: vi.fn(), return {
generationConversationName: vi.fn(), ...actual,
fetchAppInfo: vi.fn(), fetchChatList: vi.fn(),
fetchAppMeta: vi.fn(), fetchConversations: vi.fn(),
fetchAppParams: vi.fn(), generationConversationName: vi.fn(),
getAppAccessModeByAppCode: vi.fn(), fetchAppInfo: vi.fn(),
delConversation: vi.fn(), fetchAppMeta: vi.fn(),
pinConversation: vi.fn(), fetchAppParams: vi.fn(),
renameConversation: vi.fn(), getAppAccessModeByAppCode: vi.fn(),
unpinConversation: vi.fn(), delConversation: vi.fn(),
updateFeedback: vi.fn(), pinConversation: vi.fn(),
})) renameConversation: vi.fn(),
unpinConversation: vi.fn(),
updateFeedback: vi.fn(),
}
})
const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchConversations = vi.mocked(fetchConversations)
const mockFetchChatList = vi.mocked(fetchChatList) const mockFetchChatList = vi.mocked(fetchChatList)
@@ -162,13 +167,13 @@ describe('useChatWithHistory', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100)
}) })
await waitFor(() => { await waitFor(() => {
expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100)
}) })
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
}) })
await waitFor(() => { await waitFor(() => {
expect(result.current.pinnedConversationList).toEqual(pinnedData.data) expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
@@ -204,7 +209,7 @@ describe('useChatWithHistory', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new')
}) })
await waitFor(() => { await waitFor(() => {
expect(result.current.conversationList[0]).toEqual(generatedConversation) expect(result.current.conversationList[0]).toEqual(generatedConversation)

View File

@@ -27,6 +27,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon' import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client' import { changeLanguage } from '@/i18n-config/client'
import { import {
AppSourceType,
delConversation, delConversation,
pinConversation, pinConversation,
renameConversation, renameConversation,
@@ -72,6 +73,7 @@ function getFormattedChatList(messages: any[]) {
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => { export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo]) const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore(s => s.appInfo) const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams) const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta) const appMeta = useWebAppStore(s => s.appMeta)
@@ -177,7 +179,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [currentConversationId, newConversationId]) }, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({ const { data: appPinnedConversationData } = useShareConversations({
isInstalledApp, appSourceType,
appId, appId,
pinned: true, pinned: true,
limit: 100, limit: 100,
@@ -190,7 +192,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
data: appConversationData, data: appConversationData,
isLoading: appConversationDataLoading, isLoading: appConversationDataLoading,
} = useShareConversations({ } = useShareConversations({
isInstalledApp, appSourceType,
appId, appId,
pinned: false, pinned: false,
limit: 100, limit: 100,
@@ -204,7 +206,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
isLoading: appChatListDataLoading, isLoading: appChatListDataLoading,
} = useShareChatList({ } = useShareChatList({
conversationId: chatShouldReloadKey, conversationId: chatShouldReloadKey,
isInstalledApp, appSourceType,
appId, appId,
}, { }, {
enabled: !!chatShouldReloadKey, enabled: !!chatShouldReloadKey,
@@ -334,10 +336,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const { data: newConversation } = useShareConversationName({ const { data: newConversation } = useShareConversationName({
conversationId: newConversationId, conversationId: newConversationId,
isInstalledApp, appSourceType,
appId, appId,
}, { }, {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled: !!newConversationId,
}) })
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([]) const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => { useEffect(() => {
@@ -462,16 +465,16 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [invalidateShareConversations]) }, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => { 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' }) }) notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList() handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) }, [appSourceType, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => { 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' }) }) notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList() handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList]) }, [appSourceType, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false) const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async ( const handleDeleteConversation = useCallback(async (
@@ -485,7 +488,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
try { try {
setConversationDeleting(true) setConversationDeleting(true)
await delConversation(isInstalledApp, appId, conversationId) await delConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) }) notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
onSuccess() onSuccess()
} }
@@ -520,7 +523,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setConversationRenaming(true) setConversationRenaming(true)
try { try {
await renameConversation(isInstalledApp, appId, conversationId, newName) await renameConversation(appSourceType, appId, conversationId, newName)
notify({ notify({
type: 'success', type: 'success',
@@ -550,9 +553,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [handleConversationIdInfoChange, invalidateShareConversations]) }, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { 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' }) }) notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [isInstalledApp, appId, t, notify]) }, [appSourceType, appId, t, notify])
return { return {
isInstalledApp, isInstalledApp,

View File

@@ -150,7 +150,7 @@ const Answer: FC<AnswerProps> = ({
data={workflowProcess} data={workflowProcess}
item={item} item={item}
hideProcessDetail={hideProcessDetail} hideProcessDetail={hideProcessDetail}
readonly={hideProcessDetail && appData ? !appData.site.show_workflow_steps : undefined} readonly={hideProcessDetail && appData ? !appData.site?.show_workflow_steps : undefined}
/> />
) )
} }

View File

@@ -1,6 +1,7 @@
import type { FC } from 'react' import type { FC } from 'react'
import type { ChatItem } from '../../types' import type { ChatItem } from '../../types'
import { memo } from 'react' import { memo } from 'react'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context' import { useChatContext } from '../context'
type SuggestedQuestionsProps = { type SuggestedQuestionsProps = {
@@ -9,7 +10,7 @@ type SuggestedQuestionsProps = {
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item, item,
}) => { }) => {
const { onSend } = useChatContext() const { onSend, readonly } = useChatContext()
const { const {
isOpeningStatement, isOpeningStatement,
@@ -24,8 +25,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => ( {suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div <div
key={index} key={index}
className="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" className={cn(
onClick={() => onSend?.(question)} '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} {question}
</div> </div>

View File

@@ -5,6 +5,7 @@ import type {
} from '../../types' } from '../../types'
import type { InputForm } from '../type' import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types' import type { FileUpload } from '@/app/components/base/features/types'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities' import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder' import Recorder from 'js-audio-recorder'
import { import {
@@ -30,6 +31,7 @@ import { useTextAreaHeight } from './hooks'
import Operation from './operation' import Operation from './operation'
type ChatInputAreaProps = { type ChatInputAreaProps = {
readonly?: boolean
botName?: string botName?: string
showFeatureBar?: boolean showFeatureBar?: boolean
showFileUpload?: boolean showFileUpload?: boolean
@@ -45,6 +47,7 @@ type ChatInputAreaProps = {
disabled?: boolean disabled?: boolean
} }
const ChatInputArea = ({ const ChatInputArea = ({
readonly,
botName, botName,
showFeatureBar, showFeatureBar,
showFileUpload, showFileUpload,
@@ -170,6 +173,7 @@ const ChatInputArea = ({
const operation = ( const operation = (
<Operation <Operation
ref={holdSpaceRef} ref={holdSpaceRef}
readonly={readonly}
fileConfig={visionConfig} fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig} speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput} onShowVoiceInput={handleShowVoiceInput}
@@ -205,7 +209,7 @@ const ChatInputArea = ({
className={cn( className={cn(
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none', 'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
)} )}
placeholder={decode(t('chat.inputPlaceholder', { ns: 'common', botName }) || '')} placeholder={decode(t(readonly ? 'chat.inputDisabledPlaceholder' : 'chat.inputPlaceholder', { ns: 'common', botName }) || '')}
autoFocus autoFocus
minRows={1} minRows={1}
value={query} value={query}
@@ -218,6 +222,7 @@ const ChatInputArea = ({
onDragLeave={handleDragFileLeave} onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver} onDragOver={handleDragFileOver}
onDrop={handleDropFile} onDrop={handleDropFile}
readOnly={readonly}
/> />
</div> </div>
{ {
@@ -239,7 +244,14 @@ const ChatInputArea = ({
) )
} }
</div> </div>
{showFeatureBar && <FeatureBar showFileUpload={showFileUpload} disabled={featureBarDisabled} onFeatureBarClick={onFeatureBarClick} />} {showFeatureBar && (
<FeatureBar
showFileUpload={showFileUpload}
disabled={featureBarDisabled}
onFeatureBarClick={readonly ? noop : onFeatureBarClick}
hideEditEntrance={readonly}
/>
)}
</> </>
) )
} }

View File

@@ -8,6 +8,7 @@ import {
RiMicLine, RiMicLine,
RiSendPlane2Fill, RiSendPlane2Fill,
} from '@remixicon/react' } from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react' import { memo } from 'react'
import ActionButton from '@/app/components/base/action-button' import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/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' import { cn } from '@/utils/classnames'
type OperationProps = { type OperationProps = {
readonly?: boolean
fileConfig?: FileUpload fileConfig?: FileUpload
speechToTextConfig?: EnableType speechToTextConfig?: EnableType
onShowVoiceInput?: () => void onShowVoiceInput?: () => void
@@ -23,6 +25,7 @@ type OperationProps = {
ref?: Ref<HTMLDivElement> ref?: Ref<HTMLDivElement>
} }
const Operation: FC<OperationProps> = ({ const Operation: FC<OperationProps> = ({
readonly,
ref, ref,
fileConfig, fileConfig,
speechToTextConfig, speechToTextConfig,
@@ -41,11 +44,12 @@ const Operation: FC<OperationProps> = ({
ref={ref} ref={ref}
> >
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />} {fileConfig?.enabled && <FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} />}
{ {
speechToTextConfig?.enabled && ( speechToTextConfig?.enabled && (
<ActionButton <ActionButton
size="l" size="l"
disabled={readonly}
onClick={onShowVoiceInput} onClick={onShowVoiceInput}
> >
<RiMicLine className="h-5 w-5" /> <RiMicLine className="h-5 w-5" />
@@ -56,7 +60,7 @@ const Operation: FC<OperationProps> = ({
<Button <Button
className="ml-3 w-8 px-0" className="ml-3 w-8 px-0"
variant="primary" variant="primary"
onClick={onSend} onClick={readonly ? noop : onSend}
style={ style={
theme theme
? { ? {

View File

@@ -15,10 +15,14 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationEdited' | 'onAnnotationEdited'
| 'onAnnotationAdded' | 'onAnnotationAdded'
| 'onAnnotationRemoved' | 'onAnnotationRemoved'
| 'onFeedback'> | 'disableFeedback'
| 'onFeedback'> & {
readonly?: boolean
}
const ChatContext = createContext<ChatContextValue>({ const ChatContext = createContext<ChatContextValue>({
chatList: [], chatList: [],
readonly: false,
}) })
type ChatContextProviderProps = { type ChatContextProviderProps = {
@@ -27,6 +31,7 @@ type ChatContextProviderProps = {
export const ChatContextProvider = ({ export const ChatContextProvider = ({
children, children,
readonly = false,
config, config,
isResponding, isResponding,
chatList, chatList,
@@ -38,11 +43,13 @@ export const ChatContextProvider = ({
onAnnotationEdited, onAnnotationEdited,
onAnnotationAdded, onAnnotationAdded,
onAnnotationRemoved, onAnnotationRemoved,
disableFeedback,
onFeedback, onFeedback,
}: ChatContextProviderProps) => { }: ChatContextProviderProps) => {
return ( return (
<ChatContext.Provider value={{ <ChatContext.Provider value={{
config, config,
readonly,
isResponding, isResponding,
chatList: chatList || [], chatList: chatList || [],
showPromptLog, showPromptLog,
@@ -53,6 +60,7 @@ export const ChatContextProvider = ({
onAnnotationEdited, onAnnotationEdited,
onAnnotationAdded, onAnnotationAdded,
onAnnotationRemoved, onAnnotationRemoved,
disableFeedback,
onFeedback, onFeedback,
}} }}
> >

View File

@@ -36,6 +36,8 @@ import Question from './question'
import TryToAsk from './try-to-ask' import TryToAsk from './try-to-ask'
export type ChatProps = { export type ChatProps = {
isTryApp?: boolean
readonly?: boolean
appData?: AppData appData?: AppData
chatList: ChatItem[] chatList: ChatItem[]
config?: ChatConfig config?: ChatConfig
@@ -60,6 +62,7 @@ export type ChatProps = {
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode chatNode?: ReactNode
disableFeedback?: boolean
onFeedback?: (messageId: string, feedback: Feedback) => void onFeedback?: (messageId: string, feedback: Feedback) => void
chatAnswerContainerInner?: string chatAnswerContainerInner?: string
hideProcessDetail?: boolean hideProcessDetail?: boolean
@@ -75,6 +78,8 @@ export type ChatProps = {
} }
const Chat: FC<ChatProps> = ({ const Chat: FC<ChatProps> = ({
isTryApp,
readonly = false,
appData, appData,
config, config,
onSend, onSend,
@@ -98,6 +103,7 @@ const Chat: FC<ChatProps> = ({
onAnnotationEdited, onAnnotationEdited,
onAnnotationRemoved, onAnnotationRemoved,
chatNode, chatNode,
disableFeedback,
onFeedback, onFeedback,
chatAnswerContainerInner, chatAnswerContainerInner,
hideProcessDetail, hideProcessDetail,
@@ -245,6 +251,7 @@ const Chat: FC<ChatProps> = ({
return ( return (
<ChatContextProvider <ChatContextProvider
readonly={readonly}
config={config} config={config}
chatList={chatList} chatList={chatList}
isResponding={isResponding} isResponding={isResponding}
@@ -256,17 +263,18 @@ const Chat: FC<ChatProps> = ({
onAnnotationAdded={onAnnotationAdded} onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited} onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved} onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback} onFeedback={onFeedback}
> >
<div className="relative h-full"> <div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div <div
ref={chatContainerRef} ref={chatContainerRef}
className={cn('relative h-full overflow-y-auto overflow-x-hidden', chatContainerClassName)} className={cn('relative h-full overflow-y-auto overflow-x-hidden', isTryApp && 'h-0 grow', chatContainerClassName)}
> >
{chatNode} {chatNode}
<div <div
ref={chatContainerInnerRef} ref={chatContainerInnerRef}
className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName)} className={cn('w-full', !noSpacing && 'px-8', chatContainerInnerClassName, isTryApp && 'px-0')}
> >
{ {
chatList.map((item, index) => { chatList.map((item, index) => {
@@ -310,7 +318,7 @@ const Chat: FC<ChatProps> = ({
> >
<div <div
ref={chatFooterInnerRef} ref={chatFooterInnerRef}
className={cn('relative', chatFooterInnerClassName)} className={cn('relative', chatFooterInnerClassName, isTryApp && 'px-0')}
> >
{ {
!noStopResponding && isResponding && ( !noStopResponding && isResponding && (
@@ -333,7 +341,7 @@ const Chat: FC<ChatProps> = ({
{ {
!noChatInput && ( !noChatInput && (
<ChatInputArea <ChatInputArea
botName={appData?.site.title || 'Bot'} botName={appData?.site?.title || 'Bot'}
disabled={inputDisabled} disabled={inputDisabled}
showFeatureBar={showFeatureBar} showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload} showFileUpload={showFileUpload}
@@ -346,6 +354,7 @@ const Chat: FC<ChatProps> = ({
inputsForm={inputsForm} inputsForm={inputsForm}
theme={themeBuilder?.theme} theme={themeBuilder?.theme}
isResponding={isResponding} isResponding={isResponding}
readonly={readonly}
/> />
) )
} }

View File

@@ -13,6 +13,7 @@ import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import { Markdown } from '@/app/components/base/markdown' import { Markdown } from '@/app/components/base/markdown'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { import {
AppSourceType,
fetchSuggestedQuestions, fetchSuggestedQuestions,
getUrl, getUrl,
stopChatMessageResponding, stopChatMessageResponding,
@@ -42,6 +43,7 @@ const ChatWrapper = () => {
isInstalledApp, isInstalledApp,
appId, appId,
appMeta, appMeta,
disableFeedback,
handleFeedback, handleFeedback,
currentChatInstanceRef, currentChatInstanceRef,
themeBuilder, themeBuilder,
@@ -50,7 +52,9 @@ const ChatWrapper = () => {
setIsResponding, setIsResponding,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
appSourceType,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const appConfig = useMemo(() => { const appConfig = useMemo(() => {
const config = appParams || {} const config = appParams || {}
@@ -78,7 +82,7 @@ const ChatWrapper = () => {
inputsForm: inputsForms, inputsForm: inputsForms,
}, },
appPrevChatList, appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId), taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList, clearChatList,
setClearChatList, setClearChatList,
) )
@@ -134,14 +138,13 @@ const ChatWrapper = () => {
conversation_id: currentConversationId, conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null, parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
} }
handleSend( handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''), getUrl('chat-messages', appSourceType, appId || ''),
data, data,
{ {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId), onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted, onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp, isPublicAPI: appSourceType === AppSourceType.webApp,
}, },
) )
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted]) }, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
@@ -159,7 +162,8 @@ const ChatWrapper = () => {
return chatList.filter(item => !item.isOpeningStatement) return chatList.filter(item => !item.isOpeningStatement)
}, [chatList, currentConversationId]) }, [chatList, currentConversationId])
const [collapsed, setCollapsed] = useState(!!currentConversationId) const isTryApp = appSourceType === AppSourceType.tryApp
const [collapsed, setCollapsed] = useState(!!currentConversationId && !isTryApp) // try app always use the new chat
const chatNode = useMemo(() => { const chatNode = useMemo(() => {
if (allInputsHidden || !inputsForms.length) if (allInputsHidden || !inputsForms.length)
@@ -184,6 +188,8 @@ const ChatWrapper = () => {
return null return null
if (!collapsed && inputsForms.length > 0 && !allInputsHidden) if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
return null return null
if (!appData?.site)
return null
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) { if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
return ( return (
<div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}> <div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
@@ -217,7 +223,7 @@ const ChatWrapper = () => {
</div> </div>
</div> </div>
) )
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden]) }, [appData?.site, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
const answerIcon = isDify() const answerIcon = isDify()
? <LogoAvatar className="relative shrink-0" /> ? <LogoAvatar className="relative shrink-0" />
@@ -234,6 +240,7 @@ const ChatWrapper = () => {
return ( return (
<Chat <Chat
isTryApp={isTryApp}
appData={appData || undefined} appData={appData || undefined}
config={appConfig} config={appConfig}
chatList={messageList} chatList={messageList}
@@ -253,6 +260,7 @@ const ChatWrapper = () => {
</> </>
)} )}
allToolIcons={appMeta?.tool_icons || {}} allToolIcons={appMeta?.tool_icons || {}}
disableFeedback={disableFeedback}
onFeedback={handleFeedback} onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions} suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon} answerIcon={answerIcon}

View File

@@ -15,6 +15,7 @@ import type {
} from '@/models/share' } from '@/models/share'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { createContext, useContext } from 'use-context-selector' import { createContext, useContext } from 'use-context-selector'
import { AppSourceType } from '@/service/share'
export type EmbeddedChatbotContextValue = { export type EmbeddedChatbotContextValue = {
appMeta: AppMeta | null appMeta: AppMeta | null
@@ -37,8 +38,10 @@ export type EmbeddedChatbotContextValue = {
chatShouldReloadKey: string chatShouldReloadKey: string
isMobile: boolean isMobile: boolean
isInstalledApp: boolean isInstalledApp: boolean
appSourceType: AppSourceType
allowResetChat: boolean allowResetChat: boolean
appId?: string appId?: string
disableFeedback?: boolean
handleFeedback: (messageId: string, feedback: Feedback) => void handleFeedback: (messageId: string, feedback: Feedback) => void
currentChatInstanceRef: RefObject<{ handleStop: () => void }> currentChatInstanceRef: RefObject<{ handleStop: () => void }>
themeBuilder?: ThemeBuilder themeBuilder?: ThemeBuilder
@@ -74,6 +77,7 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
handleNewConversationCompleted: noop, handleNewConversationCompleted: noop,
chatShouldReloadKey: '', chatShouldReloadKey: '',
isMobile: false, isMobile: false,
appSourceType: AppSourceType.webApp,
isInstalledApp: false, isInstalledApp: false,
allowResetChat: true, allowResetChat: true,
handleFeedback: noop, handleFeedback: noop,

View File

@@ -5,6 +5,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react' import { act, renderHook, waitFor } from '@testing-library/react'
import { ToastProvider } from '@/app/components/base/toast' import { ToastProvider } from '@/app/components/base/toast'
import { import {
AppSourceType,
fetchChatList, fetchChatList,
fetchConversations, fetchConversations,
generationConversationName, generationConversationName,
@@ -49,16 +50,20 @@ vi.mock('../utils', async () => {
} }
}) })
vi.mock('@/service/share', () => ({ vi.mock('@/service/share', async (importOriginal) => {
fetchChatList: vi.fn(), const actual = await importOriginal<typeof import('@/service/share')>()
fetchConversations: vi.fn(), return {
generationConversationName: vi.fn(), ...actual,
fetchAppInfo: vi.fn(), fetchChatList: vi.fn(),
fetchAppMeta: vi.fn(), fetchConversations: vi.fn(),
fetchAppParams: vi.fn(), generationConversationName: vi.fn(),
getAppAccessModeByAppCode: vi.fn(), fetchAppInfo: vi.fn(),
updateFeedback: vi.fn(), fetchAppMeta: vi.fn(),
})) fetchAppParams: vi.fn(),
getAppAccessModeByAppCode: vi.fn(),
updateFeedback: vi.fn(),
}
})
const mockFetchConversations = vi.mocked(fetchConversations) const mockFetchConversations = vi.mocked(fetchConversations)
const mockFetchChatList = vi.mocked(fetchChatList) const mockFetchChatList = vi.mocked(fetchChatList)
@@ -145,17 +150,17 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] }) mockFetchChatList.mockResolvedValue({ data: [] })
// Act // Act
const { result } = renderWithClient(() => useEmbeddedChatbot()) const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Assert // Assert
await waitFor(() => { await waitFor(() => {
expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, true, 100) expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, true, 100)
}) })
await waitFor(() => { await waitFor(() => {
expect(mockFetchConversations).toHaveBeenCalledWith(false, 'app-1', undefined, false, 100) expect(mockFetchConversations).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', undefined, false, 100)
}) })
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', false, 'app-1') expect(mockFetchChatList).toHaveBeenCalledWith('conversation-1', AppSourceType.webApp, 'app-1')
}) })
expect(result.current.pinnedConversationList).toEqual(pinnedData.data) expect(result.current.pinnedConversationList).toEqual(pinnedData.data)
expect(result.current.conversationList).toEqual(listData.data) expect(result.current.conversationList).toEqual(listData.data)
@@ -177,7 +182,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] }) mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(generatedConversation) mockGenerationConversationName.mockResolvedValue(generatedConversation)
const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot()) const { result, queryClient } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries')
// Act // Act
@@ -187,7 +192,7 @@ describe('useEmbeddedChatbot', () => {
// Assert // Assert
await waitFor(() => { await waitFor(() => {
expect(mockGenerationConversationName).toHaveBeenCalledWith(false, 'app-1', 'conversation-new') expect(mockGenerationConversationName).toHaveBeenCalledWith(AppSourceType.webApp, 'app-1', 'conversation-new')
}) })
await waitFor(() => { await waitFor(() => {
expect(result.current.conversationList[0]).toEqual(generatedConversation) expect(result.current.conversationList[0]).toEqual(generatedConversation)
@@ -207,7 +212,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] }) mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' })) mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-1' }))
const { result } = renderWithClient(() => useEmbeddedChatbot()) const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
await waitFor(() => { await waitFor(() => {
expect(mockFetchChatList).toHaveBeenCalledTimes(1) expect(mockFetchChatList).toHaveBeenCalledTimes(1)
@@ -237,7 +242,7 @@ describe('useEmbeddedChatbot', () => {
mockFetchChatList.mockResolvedValue({ data: [] }) mockFetchChatList.mockResolvedValue({ data: [] })
mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' })) mockGenerationConversationName.mockResolvedValue(createConversationItem({ id: 'conversation-new' }))
const { result } = renderWithClient(() => useEmbeddedChatbot()) const { result } = renderWithClient(() => useEmbeddedChatbot(AppSourceType.webApp))
// Act // Act
act(() => { act(() => {

View File

@@ -1,11 +1,13 @@
/* eslint-disable ts/no-explicit-any */
import type { import type {
ChatConfig, ChatConfig,
ChatItem, ChatItem,
Feedback, Feedback,
} from '../types' } from '../types'
import type { InputValueTypes } from '@/app/components/share/text-generation/types'
import type { Locale } from '@/i18n-config' import type { Locale } from '@/i18n-config'
import type { import type {
// AppData, AppData,
ConversationItem, ConversationItem,
} from '@/models/share' } from '@/models/share'
import { useLocalStorageState } from 'ahooks' import { useLocalStorageState } from 'ahooks'
@@ -24,13 +26,14 @@ import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
import { InputVarType } from '@/app/components/workflow/types' import { InputVarType } from '@/app/components/workflow/types'
import { useWebAppStore } from '@/context/web-app-context' import { useWebAppStore } from '@/context/web-app-context'
import { changeLanguage } from '@/i18n-config/client' import { changeLanguage } from '@/i18n-config/client'
import { updateFeedback } from '@/service/share' import { AppSourceType, updateFeedback } from '@/service/share'
import { import {
useInvalidateShareConversations, useInvalidateShareConversations,
useShareChatList, useShareChatList,
useShareConversationName, useShareConversationName,
useShareConversations, useShareConversations,
} from '@/service/use-share' } from '@/service/use-share'
import { useGetTryAppInfo, useGetTryAppParams } from '@/service/use-try-app'
import { TransferMethod } from '@/types/app' import { TransferMethod } from '@/types/app'
import { getProcessedFilesFromResponse } from '../../file-uploader/utils' import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
import { CONVERSATION_ID_INFO } from '../constants' import { CONVERSATION_ID_INFO } from '../constants'
@@ -62,18 +65,36 @@ function getFormattedChatList(messages: any[]) {
return newChatList return newChatList
} }
export const useEmbeddedChatbot = () => { export const useEmbeddedChatbot = (appSourceType: AppSourceType, tryAppId?: string) => {
const isInstalledApp = false const isInstalledApp = false // just can be webapp and try app
const appInfo = useWebAppStore(s => s.appInfo) const isTryApp = appSourceType === AppSourceType.tryApp
const { data: tryAppInfo } = useGetTryAppInfo(isTryApp ? tryAppId! : '')
const webAppInfo = useWebAppStore(s => s.appInfo)
const appInfo = isTryApp ? tryAppInfo : webAppInfo
const appMeta = useWebAppStore(s => s.appMeta) const appMeta = useWebAppStore(s => s.appMeta)
const appParams = useWebAppStore(s => s.appParams) const { data: tryAppParams } = useGetTryAppParams(isTryApp ? tryAppId! : '')
const webAppParams = useWebAppStore(s => s.appParams)
const appParams = isTryApp ? tryAppParams : webAppParams
const appId = useMemo(() => {
return isTryApp ? tryAppId : (appInfo as any)?.app_id
}, [appInfo, isTryApp, tryAppId])
const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId) const embeddedConversationId = useWebAppStore(s => s.embeddedConversationId)
const embeddedUserId = useWebAppStore(s => s.embeddedUserId) const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const appId = useMemo(() => appInfo?.app_id, [appInfo])
const [userId, setUserId] = useState<string>() const [userId, setUserId] = useState<string>()
const [conversationId, setConversationId] = useState<string>() const [conversationId, setConversationId] = useState<string>()
useEffect(() => {
if (isTryApp)
return
getProcessedSystemVariablesFromUrlParams().then(({ user_id, conversation_id }) => {
setUserId(user_id)
setConversationId(conversation_id)
})
}, [])
useEffect(() => { useEffect(() => {
setUserId(embeddedUserId || undefined) setUserId(embeddedUserId || undefined)
}, [embeddedUserId]) }, [embeddedUserId])
@@ -83,6 +104,8 @@ export const useEmbeddedChatbot = () => {
}, [embeddedConversationId]) }, [embeddedConversationId])
useEffect(() => { useEffect(() => {
if (isTryApp)
return
const setLanguageFromParams = async () => { const setLanguageFromParams = async () => {
// Check URL parameters for language override // Check URL parameters for language override
const urlParams = new URLSearchParams(window.location.search) const urlParams = new URLSearchParams(window.location.search)
@@ -100,9 +123,9 @@ export const useEmbeddedChatbot = () => {
// If locale is set as a system variable, use that // If locale is set as a system variable, use that
await changeLanguage(localeFromSysVar) await changeLanguage(localeFromSysVar)
} }
else if (appInfo?.site.default_language) { else if ((appInfo as unknown as AppData)?.site?.default_language) {
// Otherwise use the default from app config // Otherwise use the default from app config
await changeLanguage(appInfo.site.default_language) await changeLanguage((appInfo as unknown as AppData).site?.default_language)
} }
} }
@@ -112,6 +135,13 @@ export const useEmbeddedChatbot = () => {
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, { const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
defaultValue: {}, defaultValue: {},
}) })
const removeConversationIdInfo = useCallback((appId: string) => {
setConversationIdInfo((prev) => {
const newInfo = { ...prev }
delete newInfo[appId]
return newInfo
})
}, [setConversationIdInfo])
const allowResetChat = !conversationId const allowResetChat = !conversationId
const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '', [appId, conversationIdInfo, userId, conversationId]) const currentConversationId = useMemo(() => conversationIdInfo?.[appId || '']?.[userId || 'DEFAULT'] || conversationId || '', [appId, conversationIdInfo, userId, conversationId])
const handleConversationIdInfoChange = useCallback((changeConversationId: string) => { const handleConversationIdInfoChange = useCallback((changeConversationId: string) => {
@@ -138,7 +168,7 @@ export const useEmbeddedChatbot = () => {
}, [currentConversationId, newConversationId]) }, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({ const { data: appPinnedConversationData } = useShareConversations({
isInstalledApp, appSourceType,
appId, appId,
pinned: true, pinned: true,
limit: 100, limit: 100,
@@ -147,7 +177,7 @@ export const useEmbeddedChatbot = () => {
data: appConversationData, data: appConversationData,
isLoading: appConversationDataLoading, isLoading: appConversationDataLoading,
} = useShareConversations({ } = useShareConversations({
isInstalledApp, appSourceType,
appId, appId,
pinned: false, pinned: false,
limit: 100, limit: 100,
@@ -157,7 +187,7 @@ export const useEmbeddedChatbot = () => {
isLoading: appChatListDataLoading, isLoading: appChatListDataLoading,
} = useShareChatList({ } = useShareChatList({
conversationId: chatShouldReloadKey, conversationId: chatShouldReloadKey,
isInstalledApp, appSourceType,
appId, appId,
}) })
const invalidateShareConversations = useInvalidateShareConversations() const invalidateShareConversations = useInvalidateShareConversations()
@@ -183,6 +213,7 @@ export const useEmbeddedChatbot = () => {
const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({}) const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({})
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => { const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
newConversationInputsRef.current = newInputs newConversationInputsRef.current = newInputs
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setNewConversationInputs(newInputs) setNewConversationInputs(newInputs)
}, []) }, [])
const inputsForms = useMemo(() => { const inputsForms = useMemo(() => {
@@ -265,6 +296,8 @@ export const useEmbeddedChatbot = () => {
useEffect(() => { useEffect(() => {
// init inputs from url params // init inputs from url params
(async () => { (async () => {
if (isTryApp)
return
const inputs = await getProcessedInputsFromUrlParams() const inputs = await getProcessedInputsFromUrlParams()
const userVariables = await getProcessedUserVariablesFromUrlParams() const userVariables = await getProcessedUserVariablesFromUrlParams()
setInitInputs(inputs) setInitInputs(inputs)
@@ -272,9 +305,9 @@ export const useEmbeddedChatbot = () => {
})() })()
}, []) }, [])
useEffect(() => { useEffect(() => {
const conversationInputs: Record<string, any> = {} const conversationInputs: Record<string, InputValueTypes> = {}
inputsForms.forEach((item: any) => { inputsForms.forEach((item) => {
conversationInputs[item.variable] = item.default || null conversationInputs[item.variable] = item.default || null
}) })
handleNewConversationInputsChange(conversationInputs) handleNewConversationInputsChange(conversationInputs)
@@ -282,14 +315,16 @@ export const useEmbeddedChatbot = () => {
const { data: newConversation } = useShareConversationName({ const { data: newConversation } = useShareConversationName({
conversationId: newConversationId, conversationId: newConversationId,
isInstalledApp, appSourceType,
appId, appId,
}, { }, {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
enabled: !isTryApp,
}) })
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([]) const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => { useEffect(() => {
if (appConversationData?.data && !appConversationDataLoading) if (appConversationData?.data && !appConversationDataLoading)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setOriginConversationList(appConversationData?.data) setOriginConversationList(appConversationData?.data)
}, [appConversationData, appConversationDataLoading]) }, [appConversationData, appConversationDataLoading])
const conversationList = useMemo(() => { const conversationList = useMemo(() => {
@@ -335,7 +370,8 @@ export const useEmbeddedChatbot = () => {
}, [appChatListData, currentConversationId]) }, [appChatListData, currentConversationId])
const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {}) const [currentConversationInputs, setCurrentConversationInputs] = useState<Record<string, any>>(currentConversationLatestInputs || {})
useEffect(() => { useEffect(() => {
if (currentConversationItem) if (currentConversationItem && !isTryApp)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setCurrentConversationInputs(currentConversationLatestInputs || {}) setCurrentConversationInputs(currentConversationLatestInputs || {})
}, [currentConversationItem, currentConversationLatestInputs]) }, [currentConversationItem, currentConversationLatestInputs])
@@ -380,7 +416,7 @@ export const useEmbeddedChatbot = () => {
return true return true
}, [inputsForms, notify, t, allInputsHidden]) }, [inputsForms, notify, t, allInputsHidden])
const handleStartChat = useCallback((callback?: any) => { const handleStartChat = useCallback((callback?: () => void) => {
if (checkInputsRequired()) { if (checkInputsRequired()) {
setShowNewConversationItemInList(true) setShowNewConversationItemInList(true)
callback?.() callback?.()
@@ -395,12 +431,17 @@ export const useEmbeddedChatbot = () => {
setClearChatList(false) setClearChatList(false)
}, [handleConversationIdInfoChange, setClearChatList]) }, [handleConversationIdInfoChange, setClearChatList])
const handleNewConversation = useCallback(async () => { const handleNewConversation = useCallback(async () => {
if (isTryApp) {
setClearChatList(true)
return
}
currentChatInstanceRef.current.handleStop() currentChatInstanceRef.current.handleStop()
setShowNewConversationItemInList(true) setShowNewConversationItemInList(true)
handleChangeConversation('') handleChangeConversation('')
handleNewConversationInputsChange(await getProcessedInputsFromUrlParams()) handleNewConversationInputsChange(await getProcessedInputsFromUrlParams())
setClearChatList(true) setClearChatList(true)
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList]) }, [isTryApp, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
const handleNewConversationCompleted = useCallback((newConversationId: string) => { const handleNewConversationCompleted = useCallback((newConversationId: string) => {
setNewConversationId(newConversationId) setNewConversationId(newConversationId)
@@ -410,16 +451,18 @@ export const useEmbeddedChatbot = () => {
}, [handleConversationIdInfoChange, invalidateShareConversations]) }, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => { 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' }) }) notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [isInstalledApp, appId, t, notify]) }, [appSourceType, appId, t, notify])
return { return {
appSourceType,
isInstalledApp, isInstalledApp,
allowResetChat, allowResetChat,
appId, appId,
currentConversationId, currentConversationId,
currentConversationItem, currentConversationItem,
removeConversationIdInfo,
handleConversationIdInfoChange, handleConversationIdInfoChange,
appData: appInfo, appData: appInfo,
appParams: appParams || {} as ChatConfig, appParams: appParams || {} as ChatConfig,

View File

@@ -1,4 +1,5 @@
'use client' 'use client'
import type { AppData } from '@/models/share'
import { import {
useEffect, useEffect,
} from 'react' } from 'react'
@@ -11,6 +12,7 @@ import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
import { useGlobalPublicStore } from '@/context/global-public-context' import { useGlobalPublicStore } from '@/context/global-public-context'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints' import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { import {
EmbeddedChatbotContext, EmbeddedChatbotContext,
@@ -132,11 +134,12 @@ const EmbeddedChatbotWrapper = () => {
setCurrentConversationInputs, setCurrentConversationInputs,
allInputsHidden, allInputsHidden,
initUserVariables, initUserVariables,
} = useEmbeddedChatbot() } = useEmbeddedChatbot(AppSourceType.webApp)
return ( return (
<EmbeddedChatbotContext.Provider value={{ <EmbeddedChatbotContext.Provider value={{
appData, appSourceType: AppSourceType.webApp,
appData: (appData as AppData) || null,
appParams, appParams,
appMeta, appMeta,
appChatListDataLoading, appChatListDataLoading,

View File

@@ -4,6 +4,7 @@ import Button from '@/app/components/base/button'
import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content' import InputsFormContent from '@/app/components/base/chat/embedded-chatbot/inputs-form/content'
import Divider from '@/app/components/base/divider' import Divider from '@/app/components/base/divider'
import { Message3Fill } from '@/app/components/base/icons/src/public/other' import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { useEmbeddedChatbotContext } from '../context' import { useEmbeddedChatbotContext } from '../context'
@@ -18,6 +19,7 @@ const InputsFormNode = ({
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const { const {
appSourceType,
isMobile, isMobile,
currentConversationId, currentConversationId,
themeBuilder, themeBuilder,
@@ -25,15 +27,17 @@ const InputsFormNode = ({
allInputsHidden, allInputsHidden,
inputsForms, inputsForms,
} = useEmbeddedChatbotContext() } = useEmbeddedChatbotContext()
const isTryApp = appSourceType === AppSourceType.tryApp
if (allInputsHidden || inputsForms.length === 0) if (allInputsHidden || inputsForms.length === 0)
return null return null
return ( return (
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4')}> <div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4', isTryApp && 'mb-0 px-0')}>
<div className={cn( <div className={cn(
'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md', 'w-full max-w-[672px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-md',
collapsed && 'border border-components-card-border bg-components-card-bg shadow-none', collapsed && 'border border-components-card-border bg-components-card-bg shadow-none',
isTryApp && 'max-w-[auto]',
)} )}
> >
<div className={cn( <div className={cn(

View File

@@ -33,7 +33,7 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} /> <RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
</ActionButton> </ActionButton>
</PortalToFollowElemTrigger> </PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-50"> <PortalToFollowElemContent className="z-[99]">
<div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm"> <div className="w-[400px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg backdrop-blur-sm">
<div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4"> <div className="flex items-center gap-3 rounded-t-2xl border-b border-divider-subtle px-6 py-4">
<Message3Fill className="h-6 w-6 shrink-0" /> <Message3Fill className="h-6 w-6 shrink-0" />

View File

@@ -14,6 +14,7 @@ type Props = {
showFileUpload?: boolean showFileUpload?: boolean
disabled?: boolean disabled?: boolean
onFeatureBarClick?: (state: boolean) => void onFeatureBarClick?: (state: boolean) => void
hideEditEntrance?: boolean
} }
const FeatureBar = ({ const FeatureBar = ({
@@ -21,6 +22,7 @@ const FeatureBar = ({
showFileUpload = true, showFileUpload = true,
disabled, disabled,
onFeatureBarClick, onFeatureBarClick,
hideEditEntrance = false,
}: Props) => { }: Props) => {
const { t } = useTranslation() const { t } = useTranslation()
const features = useFeatures(s => s.features) const features = useFeatures(s => s.features)
@@ -133,10 +135,14 @@ const FeatureBar = ({
)} )}
</div> </div>
<div className="body-xs-regular grow text-text-tertiary">{t('feature.bar.enableText', { ns: 'appDebug' })}</div> <div className="body-xs-regular grow text-text-tertiary">{t('feature.bar.enableText', { ns: 'appDebug' })}</div>
<Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}> {
<div className="mx-1">{t('feature.bar.manage', { ns: 'appDebug' })}</div> !hideEditEntrance && (
<RiArrowRightLine className="h-3.5 w-3.5 text-text-accent" /> <Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}>
</Button> <div className="mx-1">{t('feature.bar.manage', { ns: 'appDebug' })}</div>
<RiArrowRightLine className="h-3.5 w-3.5 text-text-accent" />
</Button>
)
}
</div> </div>
)} )}
</div> </div>

View File

@@ -13,21 +13,27 @@ import FileFromLinkOrLocal from '../file-from-link-or-local'
type FileUploaderInChatInputProps = { type FileUploaderInChatInputProps = {
fileConfig: FileUpload fileConfig: FileUpload
readonly?: boolean
} }
const FileUploaderInChatInput = ({ const FileUploaderInChatInput = ({
fileConfig, fileConfig,
readonly,
}: FileUploaderInChatInputProps) => { }: FileUploaderInChatInputProps) => {
const renderTrigger = useCallback((open: boolean) => { const renderTrigger = useCallback((open: boolean) => {
return ( return (
<ActionButton <ActionButton
size="l" size="l"
className={cn(open && 'bg-state-base-hover')} className={cn(open && 'bg-state-base-hover')}
disabled={readonly}
> >
<RiAttachmentLine className="h-5 w-5" /> <RiAttachmentLine className="h-5 w-5" />
</ActionButton> </ActionButton>
) )
}, []) }, [])
if (readonly)
return renderTrigger(false)
return ( return (
<FileFromLinkOrLocal <FileFromLinkOrLocal
trigger={renderTrigger} trigger={renderTrigger}

View File

@@ -70,10 +70,12 @@ const PasteImageLinkButton: FC<PasteImageLinkButtonProps> = ({
type TextGenerationImageUploaderProps = { type TextGenerationImageUploaderProps = {
settings: VisionSettings settings: VisionSettings
onFilesChange: (files: ImageFile[]) => void onFilesChange: (files: ImageFile[]) => void
disabled?: boolean
} }
const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
settings, settings,
onFilesChange, onFilesChange,
disabled,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -93,7 +95,7 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
const localUpload = ( const localUpload = (
<Uploader <Uploader
onUpload={onUpload} onUpload={onUpload}
disabled={files.length >= settings.number_limits} disabled={files.length >= settings.number_limits || disabled}
limit={+settings.image_file_size_limit!} limit={+settings.image_file_size_limit!}
> >
{ {
@@ -115,7 +117,7 @@ const TextGenerationImageUploader: FC<TextGenerationImageUploaderProps> = ({
const urlUpload = ( const urlUpload = (
<PasteImageLinkButton <PasteImageLinkButton
onUpload={onUpload} onUpload={onUpload}
disabled={files.length >= settings.number_limits} disabled={files.length >= settings.number_limits || disabled}
/> />
) )

View File

@@ -16,6 +16,8 @@ export type ITabHeaderProps = {
items: Item[] items: Item[]
value: string value: string
itemClassName?: string itemClassName?: string
itemWrapClassName?: string
activeItemClassName?: string
onChange: (value: string) => void onChange: (value: string) => void
} }
@@ -23,6 +25,8 @@ const TabHeader: FC<ITabHeaderProps> = ({
items, items,
value, value,
itemClassName, itemClassName,
itemWrapClassName,
activeItemClassName,
onChange, onChange,
}) => { }) => {
const renderItem = ({ id, name, icon, extra, disabled }: Item) => ( const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
@@ -30,8 +34,9 @@ const TabHeader: FC<ITabHeaderProps> = ({
key={id} key={id}
className={cn( className={cn(
'system-md-semibold relative flex cursor-pointer items-center border-b-2 border-transparent pb-2 pt-2.5', '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', disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
)} )}
onClick={() => !disabled && onChange(id)} onClick={() => !disabled && onChange(id)}
> >

View File

@@ -8,7 +8,7 @@ import { useParams, usePathname } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react' import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices' 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 { cn } from '@/utils/classnames'
import s from './index.module.css' import s from './index.module.css'
import { convertToMp3 } from './utils' import { convertToMp3 } from './utils'
@@ -108,7 +108,7 @@ const VoiceInput = ({
} }
try { try {
const audioResponse = await audioToText(url, isPublic, formData) const audioResponse = await audioToText(url, isPublic ? AppSourceType.webApp : AppSourceType.installedApp, formData)
onConverted(audioResponse.text) onConverted(audioResponse.text)
onCancel() onCancel()
} }

View File

@@ -10,6 +10,7 @@ vi.mock('../../app/type-selector', () => ({
})) }))
const createApp = (overrides?: Partial<App>): App => ({ const createApp = (overrides?: Partial<App>): App => ({
can_trial: true,
app_id: 'app-id', app_id: 'app-id',
description: 'App description', description: 'App description',
copyright: '2024', copyright: '2024',

View File

@@ -1,8 +1,13 @@
'use client' 'use client'
import type { App } from '@/models/explore' import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid' import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon' 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 { AppModeEnum } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { AppTypeIcon } from '../../app/type-selector' import { AppTypeIcon } from '../../app/type-selector'
@@ -23,8 +28,17 @@ const AppCard = ({
}: AppCardProps) => { }: AppCardProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { app: appBasicInfo } = app 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 ( return (
<div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:shadow-lg')}> <div className={cn('group relative col-span-1 flex cursor-pointer flex-col overflow-hidden rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pb-2 shadow-sm transition-all duration-200 ease-in-out hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-lg')}>
<div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]"> <div className="flex h-[66px] shrink-0 grow-0 items-center gap-3 px-[14px] pb-3 pt-[14px]">
<div className="relative shrink-0"> <div className="relative shrink-0">
<AppIcon <AppIcon
@@ -58,13 +72,19 @@ const AppCard = ({
{app.description} {app.description}
</div> </div>
</div> </div>
{isExplore && canCreate && ( {isExplore && (canCreate || isTrialApp) && (
<div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}> <div className={cn('absolute bottom-0 left-0 right-0 hidden bg-gradient-to-t from-components-panel-gradient-2 from-[60.27%] to-transparent p-4 pt-8 group-hover:flex')}>
<div className={cn('flex h-8 w-full items-center space-x-2')}> <div className={cn('grid h-8 w-full grid-cols-1 space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" className="h-7 grow" onClick={() => onCreate()}> <Button variant="primary" className="h-7" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" /> <PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span> <span className="text-xs">{t('appCard.addToWorkspace', { ns: 'explore' })}</span>
</Button> </Button>
{isTrialApp && (
<Button className="h-7" onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -16,9 +16,13 @@ let mockIsError = false
const mockHandleImportDSL = vi.fn() const mockHandleImportDSL = vi.fn()
const mockHandleImportDSLConfirm = vi.fn() const mockHandleImportDSLConfirm = vi.fn()
vi.mock('nuqs', () => ({ vi.mock('nuqs', async (importOriginal) => {
useQueryState: () => [mockTabValue, mockSetTab], const actual = await importOriginal<typeof import('nuqs')>()
})) return {
...actual,
useQueryState: () => [mockTabValue, mockSetTab],
}
})
vi.mock('ahooks', async () => { vi.mock('ahooks', async () => {
const actual = await vi.importActual<typeof import('ahooks')>('ahooks') const actual = await vi.importActual<typeof import('ahooks')>('ahooks')
@@ -102,6 +106,7 @@ const createApp = (overrides: Partial<App> = {}): App => ({
description: overrides.app?.description ?? 'Alpha description', description: overrides.app?.description ?? 'Alpha description',
use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false, use_icon_as_answer_icon: overrides.app?.use_icon_as_answer_icon ?? false,
}, },
can_trial: true,
app_id: overrides.app_id ?? 'app-1', app_id: overrides.app_id ?? 'app-1',
description: overrides.description ?? 'Alpha description', description: overrides.description ?? 'Alpha description',
copyright: overrides.copyright ?? '', copyright: overrides.copyright ?? '',
@@ -127,6 +132,8 @@ const renderWithContext = (hasEditPermission = false, onSuccess?: () => void) =>
setInstalledApps: vi.fn(), setInstalledApps: vi.fn(),
isFetchingInstalledApps: false, isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(), setIsFetchingInstalledApps: vi.fn(),
isShowTryAppPanel: false,
setShowTryAppPanel: vi.fn(),
}} }}
> >
<AppList onSuccess={onSuccess} /> <AppList onSuccess={onSuccess} />

View File

@@ -7,14 +7,17 @@ import { useQueryState } from 'nuqs'
import * as React from 'react' import * as React from 'react'
import { useCallback, useMemo, useState } from 'react' import { useCallback, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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 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 Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading' import Loading from '@/app/components/base/loading'
import AppCard from '@/app/components/explore/app-card' import AppCard from '@/app/components/explore/app-card'
import Banner from '@/app/components/explore/banner/banner'
import Category from '@/app/components/explore/category' import Category from '@/app/components/explore/category'
import CreateAppModal from '@/app/components/explore/create-app-modal' import CreateAppModal from '@/app/components/explore/create-app-modal'
import ExploreContext from '@/context/explore-context' import ExploreContext from '@/context/explore-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useImportDSL } from '@/hooks/use-import-dsl' import { useImportDSL } from '@/hooks/use-import-dsl'
import { import {
DSLImportMode, DSLImportMode,
@@ -22,6 +25,7 @@ import {
import { fetchAppDetail } from '@/service/explore' import { fetchAppDetail } from '@/service/explore'
import { useExploreAppList } from '@/service/use-explore' import { useExploreAppList } from '@/service/use-explore'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import TryApp from '../try-app'
import s from './style.module.css' import s from './style.module.css'
type AppsProps = { type AppsProps = {
@@ -32,12 +36,19 @@ const Apps = ({
onSuccess, onSuccess,
}: AppsProps) => { }: AppsProps) => {
const { t } = useTranslation() const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const { hasEditPermission } = useContext(ExploreContext) const { hasEditPermission } = useContext(ExploreContext)
const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' }) const allCategoriesEn = t('apps.allCategories', { ns: 'explore', lng: 'en' })
const [keywords, setKeywords] = useState('') const [keywords, setKeywords] = useState('')
const [searchKeywords, setSearchKeywords] = useState('') const [searchKeywords, setSearchKeywords] = useState('')
const hasFilterCondition = !!keywords
const handleResetFilter = useCallback(() => {
setKeywords('')
setSearchKeywords('')
}, [])
const { run: handleSearch } = useDebounceFn(() => { const { run: handleSearch } = useDebounceFn(() => {
setSearchKeywords(keywords) setSearchKeywords(keywords)
}, { wait: 500 }) }, { wait: 500 })
@@ -84,6 +95,18 @@ const Apps = ({
isFetching, isFetching,
} = useImportDSL() } = useImportDSL()
const [showDSLConfirmModal, setShowDSLConfirmModal] = useState(false) 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 ({ const onCreate: CreateAppModalProps['onConfirm'] = async ({
name, name,
icon_type, icon_type,
@@ -91,6 +114,8 @@ const Apps = ({
icon_background, icon_background,
description, description,
}) => { }) => {
hideTryAppPanel()
const { export_data } = await fetchAppDetail( const { export_data } = await fetchAppDetail(
currApp?.app.id as string, currApp?.app.id as string,
) )
@@ -137,22 +162,24 @@ const Apps = ({
'flex h-full flex-col border-l-[0.5px] border-divider-regular', 'flex h-full flex-col border-l-[0.5px] border-divider-regular',
)} )}
> >
{systemFeatures.enable_explore_banner && (
<div className="shrink-0 px-12 pt-6"> <div className="mt-4 px-12">
<div className={`mb-1 ${s.textGradient} text-xl font-semibold`}>{t('apps.title', { ns: 'explore' })}</div> <Banner />
<div className="text-sm text-text-tertiary">{t('apps.description', { ns: 'explore' })}</div> </div>
</div> )}
<div className={cn( <div className={cn(
'mt-6 flex items-center justify-between px-12', 'mt-6 flex items-center justify-between px-12',
)} )}
> >
<Category <div className="flex items-center">
list={categories} <div className="system-xl-semibold grow truncate text-text-primary">{!hasFilterCondition ? t('apps.title', { ns: 'explore' }) : t('apps.resultNum', { num: searchFilteredList.length, ns: 'explore' })}</div>
value={currCategory} {hasFilterCondition && (
onChange={setCurrCategory} <>
allCategoriesEn={allCategoriesEn} <div className="mx-3 h-4 w-px bg-divider-regular"></div>
/> <Button size="medium" onClick={handleResetFilter}>{t('apps.resetFilter', { ns: 'explore' })}</Button>
</>
)}
</div>
<Input <Input
showLeftIcon showLeftIcon
showClearIcon showClearIcon
@@ -163,6 +190,15 @@ const Apps = ({
/> />
</div> </div>
<div className="mt-2 px-12">
<Category
list={categories}
value={currCategory}
onChange={setCurrCategory}
allCategoriesEn={allCategoriesEn}
/>
</div>
<div className={cn( <div className={cn(
'relative mt-4 flex flex-1 shrink-0 grow flex-col overflow-auto pb-6', 'relative mt-4 flex flex-1 shrink-0 grow flex-col overflow-auto pb-6',
)} )}
@@ -211,6 +247,15 @@ const Apps = ({
/> />
) )
} }
{isShowTryAppPanel && (
<TryApp
appId={appParams?.appId || ''}
category={appParams?.app?.category}
onClose={hideTryAppPanel}
onCreate={handleShowFromTryApp}
/>
)}
</div> </div>
) )
} }

View File

@@ -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<BannerItemProps> = ({ 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<HTMLDivElement>(null)
const [maxWidth, setMaxWidth] = useState<number | undefined>(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 (
<div
className="relative flex w-full min-w-[784px] cursor-pointer overflow-hidden rounded-2xl bg-components-panel-on-panel-item-bg pr-[288px] transition-shadow hover:shadow-md"
onClick={handleBannerClick}
>
{/* Left content area */}
<div className="min-w-0 flex-1">
<div className="flex h-full flex-col gap-3 py-6 pl-8 pr-0">
{/* Text section */}
<div className="flex min-h-24 flex-wrap items-end gap-1 py-1">
{/* Title area */}
<div
ref={textAreaRef}
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] flex-col pr-4"
style={responsiveStyle}
>
<p className="title-4xl-semi-bold line-clamp-1 text-dify-logo-dify-logo-blue">
{category}
</p>
<p className="title-4xl-semi-bold line-clamp-2 text-dify-logo-dify-logo-black">
{title}
</p>
</div>
{/* Description area */}
<div
className="min-w-60 max-w-[600px] flex-[1_0_0] self-end overflow-hidden py-1 pr-4"
style={responsiveStyle}
>
<p className="body-sm-regular line-clamp-4 overflow-hidden text-text-tertiary">
{description}
</p>
</div>
</div>
{/* Actions section */}
<div className="flex items-center gap-1">
{/* View more button */}
<div
className="flex min-w-[480px] max-w-[680px] flex-[1_0_0] items-center gap-[6px] py-1 pr-8"
style={viewMoreStyle}
>
<div className="flex h-4 w-4 items-center justify-center rounded-full bg-text-accent p-[2px]">
<RiArrowRightLine className="h-3 w-3 text-text-primary-on-surface" />
</div>
<span className="system-sm-semibold-uppercase text-text-accent">
{t('banner.viewMore', { ns: 'explore' })}
</span>
</div>
<div
className={cn('flex max-w-[600px] flex-[1_0_0] items-center gap-2 py-1 pr-10', maxWidth ? '' : 'min-w-60')}
style={responsiveStyle}
>
{/* Slide navigation indicators */}
<div className="flex items-center gap-2">
{slideInfo.slides.map((_: unknown, index: number) => (
<IndicatorButton
key={index}
index={index}
selectedIndex={selectedIndex}
isNextSlide={index === slideInfo.nextIndex}
autoplayDelay={autoplayDelay}
resetKey={resetKey}
isPaused={isPaused}
onClick={() => handleIndicatorClick(index)}
/>
))}
</div>
<div className="hidden h-[1px] flex-1 bg-divider-regular min-[1380px]:block" />
</div>
</div>
</div>
</div>
{/* Right image area */}
<div className="absolute right-0 top-0 flex h-full items-center p-2">
<img
src={imgSrc}
alt={title}
className="aspect-[4/3] h-full max-w-[296px] rounded-xl"
/>
</div>
</div>
)
}

View File

@@ -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 = () => (
<div
className="flex items-center justify-center rounded-2xl bg-components-panel-on-panel-item-bg shadow-md"
style={{ minHeight: MIN_LOADING_HEIGHT }}
>
<Loading />
</div>
)
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<NodeJS.Timeout | null>(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 <LoadingState />
if (isError || enabledBanners.length === 0)
return null
return (
<Carousel
opts={{ loop: true }}
plugins={[
Carousel.Plugin.Autoplay({
delay: AUTOPLAY_DELAY,
stopOnInteraction: false,
stopOnMouseEnter: true,
}),
]}
className="rounded-2xl"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
<Carousel.Content>
{enabledBanners.map(banner => (
<Carousel.Item key={banner.id}>
<BannerItem
banner={banner}
autoplayDelay={AUTOPLAY_DELAY}
isPaused={isPaused}
/>
</Carousel.Item>
))}
</Carousel.Content>
</Carousel>
)
}
export default React.memo(Banner)

View File

@@ -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<IndicatorButtonProps> = ({
index,
selectedIndex,
isNextSlide,
autoplayDelay,
resetKey,
isPaused = false,
onClick,
}) => {
const [progress, setProgress] = useState(0)
const frameIdRef = useRef<number | undefined>(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 (
<button
onClick={handleClick}
className={cn(
'system-2xs-semibold-uppercase relative flex h-[18px] w-[20px] items-center justify-center rounded-[7px] border border-divider-subtle p-[2px] text-center transition-colors',
isActive
? 'bg-text-primary text-components-panel-on-panel-item-bg'
: 'bg-components-panel-on-panel-item-bg text-text-tertiary hover:text-text-secondary',
)}
>
{/* progress border for next slide */}
{isNextSlide && !isActive && (
<span
key={resetKey}
className="absolute inset-[-1px] rounded-[7px]"
style={{
background: `conic-gradient(
from 0deg,
var(--color-text-primary) ${progressDegrees}deg,
transparent ${progressDegrees}deg
)`,
WebkitMask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
WebkitMaskComposite: 'xor',
mask: 'linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
maskComposite: 'exclude',
padding: '1px',
}}
/>
)}
{/* number content */}
<span className="relative z-10">
{String(index + 1).padStart(2, '0')}
</span>
</button>
)
}

View File

@@ -29,7 +29,7 @@ const Category: FC<ICategoryProps> = ({
const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn const isAllCategories = !list.includes(value as AppCategory) || value === allCategoriesEn
const itemClassName = (isSelected: boolean) => cn( 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', 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',
) )

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { CurrentTryAppParams } from '@/context/explore-context'
import type { InstalledApp } from '@/models/explore' import type { InstalledApp } from '@/models/explore'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
@@ -41,6 +42,16 @@ const Explore: FC<IExploreProps> = ({
return router.replace('/datasets') return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator]) }, [isCurrentWorkspaceDatasetOperator])
const [currentTryAppParams, setCurrentTryAppParams] = useState<CurrentTryAppParams | undefined>(undefined)
const [isShowTryAppPanel, setIsShowTryAppPanel] = useState(false)
const setShowTryAppPanel = (showTryAppPanel: boolean, params?: CurrentTryAppParams) => {
if (showTryAppPanel)
setCurrentTryAppParams(params)
else
setCurrentTryAppParams(undefined)
setIsShowTryAppPanel(showTryAppPanel)
}
return ( return (
<div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body"> <div className="flex h-full overflow-hidden border-t border-divider-regular bg-background-body">
<ExploreContext.Provider <ExploreContext.Provider
@@ -53,6 +64,9 @@ const Explore: FC<IExploreProps> = ({
setInstalledApps, setInstalledApps,
isFetchingInstalledApps, isFetchingInstalledApps,
setIsFetchingInstalledApps, setIsFetchingInstalledApps,
currentApp: currentTryAppParams,
isShowTryAppPanel,
setShowTryAppPanel,
} }
} }
> >

View File

@@ -1,5 +1,6 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import type { AccessMode } from '@/models/access-control'
import type { AppData } from '@/models/share' import type { AppData } from '@/models/share'
import * as React from 'react' import * as React from 'react'
import { useEffect } from 'react' import { useEffect } from 'react'
@@ -62,8 +63,8 @@ const InstalledApp: FC<IInstalledAppProps> = ({
if (appMeta) if (appMeta)
updateWebAppMeta(appMeta) updateWebAppMeta(appMeta)
if (webAppAccessMode) if (webAppAccessMode)
updateWebAppAccessMode(webAppAccessMode.accessMode) updateWebAppAccessMode((webAppAccessMode as { accessMode: AccessMode }).accessMode)
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result)) updateUserCanAccessApp(Boolean(userCanAccessApp && (userCanAccessApp as { result: boolean })?.result))
}, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode]) }, [installedApp, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp, webAppAccessMode, updateWebAppAccessMode])
if (appParamsError) { if (appParamsError) {

View File

@@ -56,7 +56,7 @@ export default function AppNavItem({
<> <>
<div className="flex w-0 grow items-center space-x-2"> <div className="flex w-0 grow items-center space-x-2">
<AppIcon size="tiny" iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} /> <AppIcon size="tiny" iconType={icon_type} icon={icon} background={icon_background} imageUrl={icon_url} />
<div className="overflow-hidden text-ellipsis whitespace-nowrap" title={name}>{name}</div> <div className="system-sm-regular truncate text-components-menu-item-text" title={name}>{name}</div>
</div> </div>
<div className="h-6 shrink-0" onClick={e => e.stopPropagation()}> <div className="h-6 shrink-0" onClick={e => e.stopPropagation()}>
<ItemOperation <ItemOperation

View File

@@ -1,3 +1,4 @@
import type { IExplore } from '@/context/explore-context'
import type { InstalledApp } from '@/models/explore' import type { InstalledApp } from '@/models/explore'
import { fireEvent, render, screen, waitFor } from '@testing-library/react' import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import Toast from '@/app/components/base/toast' import Toast from '@/app/components/base/toast'
@@ -72,7 +73,7 @@ const renderWithContext = (installedApps: InstalledApp[] = []) => {
setInstalledApps: vi.fn(), setInstalledApps: vi.fn(),
isFetchingInstalledApps: false, isFetchingInstalledApps: false,
setIsFetchingInstalledApps: vi.fn(), setIsFetchingInstalledApps: vi.fn(),
}} } as unknown as IExplore}
> >
<SideBar controlUpdateInstalledApps={0} /> <SideBar controlUpdateInstalledApps={0} />
</ExploreContext.Provider>, </ExploreContext.Provider>,
@@ -97,8 +98,8 @@ describe('SideBar', () => {
renderWithContext(mockInstalledApps) renderWithContext(mockInstalledApps)
// Assert // Assert
expect(screen.getByText('explore.sidebar.discovery')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.title')).toBeInTheDocument()
expect(screen.getByText('explore.sidebar.workspace')).toBeInTheDocument() expect(screen.getByText('explore.sidebar.webApps')).toBeInTheDocument()
expect(screen.getByText('My App')).toBeInTheDocument() expect(screen.getByText('My App')).toBeInTheDocument()
}) })
}) })

View File

@@ -1,5 +1,7 @@
'use client' 'use client'
import type { FC } from 'react' import type { FC } from 'react'
import { RiAppsFill, RiExpandRightLine, RiLayoutLeft2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import Link from 'next/link' import Link from 'next/link'
import { useSelectedLayoutSegments } from 'next/navigation' import { useSelectedLayoutSegments } from 'next/navigation'
import * as React from 'react' import * as React from 'react'
@@ -14,18 +16,7 @@ import { useGetInstalledApps, useUninstallApp, useUpdateAppPinStatus } from '@/s
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import Toast from '../../base/toast' import Toast from '../../base/toast'
import Item from './app-nav-item' import Item from './app-nav-item'
import NoApps from './no-apps'
const SelectedDiscoveryIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M13.4135 1.11725C13.5091 1.09983 13.6483 1.08355 13.8078 1.11745C14.0143 1.16136 14.2017 1.26953 14.343 1.42647C14.4521 1.54766 14.5076 1.67634 14.5403 1.76781C14.5685 1.84673 14.593 1.93833 14.6136 2.01504L15.5533 5.5222C15.5739 5.5989 15.5985 5.69049 15.6135 5.77296C15.6309 5.86852 15.6472 6.00771 15.6133 6.16722C15.5694 6.37378 15.4612 6.56114 15.3043 6.70245C15.1831 6.81157 15.0544 6.86706 14.9629 6.89975C14.884 6.92796 14.7924 6.95247 14.7157 6.97299L14.676 6.98364C14.3365 7.07461 14.0437 7.15309 13.7972 7.19802C13.537 7.24543 13.2715 7.26736 12.9946 7.20849C12.7513 7.15677 12.5213 7.06047 12.3156 6.92591L9.63273 7.64477C9.86399 7.97104 9.99992 8.36965 9.99992 8.80001C9.99992 9.2424 9.85628 9.65124 9.6131 9.98245L12.5508 14.291C12.7582 14.5952 12.6797 15.01 12.3755 15.2174C12.0713 15.4248 11.6566 15.3464 11.4492 15.0422L8.51171 10.7339C8.34835 10.777 8.17682 10.8 7.99992 10.8C7.82305 10.8 7.65155 10.777 7.48823 10.734L4.5508 15.0422C4.34338 15.3464 3.92863 15.4248 3.62442 15.2174C3.32021 15.01 3.24175 14.5952 3.44916 14.291L6.3868 9.98254C6.14358 9.65132 5.99992 9.24244 5.99992 8.80001C5.99992 8.73795 6.00274 8.67655 6.00827 8.61594L4.59643 8.99424C4.51973 9.01483 4.42813 9.03941 4.34567 9.05444C4.25011 9.07185 4.11092 9.08814 3.95141 9.05423C3.74485 9.01033 3.55748 8.90215 3.41618 8.74522C3.38535 8.71097 3.3588 8.67614 3.33583 8.64171L2.49206 8.8678C2.41536 8.88838 2.32376 8.91296 2.2413 8.92799C2.14574 8.94541 2.00655 8.96169 1.84704 8.92779C1.64048 8.88388 1.45311 8.77571 1.31181 8.61877C1.20269 8.49759 1.1472 8.3689 1.1145 8.27744C1.08629 8.1985 1.06177 8.10689 1.04125 8.03018L0.791701 7.09885C0.771119 7.02215 0.746538 6.93055 0.731508 6.84809C0.714092 6.75253 0.697808 6.61334 0.731712 6.45383C0.775619 6.24726 0.883793 6.0599 1.04073 5.9186C1.16191 5.80948 1.2906 5.75399 1.38206 5.72129C1.461 5.69307 1.55261 5.66856 1.62932 5.64804L2.47318 5.42193C2.47586 5.38071 2.48143 5.33735 2.49099 5.29237C2.5349 5.08581 2.64307 4.89844 2.80001 4.75714C2.92119 4.64802 3.04988 4.59253 3.14134 4.55983C3.22027 4.53162 3.31189 4.50711 3.3886 4.48658L11.1078 2.41824C11.2186 2.19888 11.3697 2.00049 11.5545 1.83406C11.7649 1.64462 12.0058 1.53085 12.2548 1.44183C12.4907 1.35749 12.7836 1.27904 13.123 1.18809L13.1628 1.17744C13.2395 1.15686 13.3311 1.13228 13.4135 1.11725ZM13.3642 2.5039C13.0648 2.58443 12.8606 2.64126 12.7036 2.69735C12.5325 2.75852 12.4742 2.80016 12.4467 2.82492C12.3421 2.91912 12.2699 3.04403 12.2407 3.18174C12.233 3.21793 12.2261 3.28928 12.2587 3.46805C12.2927 3.6545 12.3564 3.89436 12.4559 4.26563L12.5594 4.652C12.6589 5.02328 12.7236 5.26287 12.7874 5.44133C12.8486 5.61244 12.8902 5.67079 12.915 5.69829C13.0092 5.80291 13.1341 5.87503 13.2718 5.9043C13.308 5.91199 13.3793 5.91887 13.5581 5.88629C13.7221 5.85641 13.9273 5.80352 14.2269 5.72356L13.3642 2.5039Z" fill="currentColor" />
</svg>
)
const DiscoveryIcon = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="current" xmlns="http://www.w3.org/2000/svg">
<path d="M8.74786 9.89676L12.0003 14.6669M7.25269 9.89676L4.00027 14.6669M9.3336 8.80031C9.3336 9.53669 8.73665 10.1336 8.00027 10.1336C7.26389 10.1336 6.66694 9.53669 6.66694 8.80031C6.66694 8.06393 7.26389 7.46698 8.00027 7.46698C8.73665 7.46698 9.3336 8.06393 9.3336 8.80031ZM11.4326 3.02182L3.57641 5.12689C3.39609 5.1752 3.30593 5.19936 3.24646 5.25291C3.19415 5.30001 3.15809 5.36247 3.14345 5.43132C3.12681 5.5096 3.15097 5.59976 3.19929 5.78008L3.78595 7.96951C3.83426 8.14984 3.85842 8.24 3.91197 8.29947C3.95907 8.35178 4.02153 8.38784 4.09038 8.40248C4.16866 8.41911 4.25882 8.39496 4.43914 8.34664L12.2953 6.24158L11.4326 3.02182ZM14.5285 6.33338C13.8072 6.52665 13.4466 6.62328 13.1335 6.55673C12.8581 6.49819 12.6082 6.35396 12.4198 6.14471C12.2056 5.90682 12.109 5.54618 11.9157 4.82489L11.8122 4.43852C11.6189 3.71722 11.5223 3.35658 11.5889 3.04347C11.6474 2.76805 11.7916 2.51823 12.0009 2.32982C12.2388 2.11563 12.5994 2.019 13.3207 1.82573C13.501 1.77741 13.5912 1.75325 13.6695 1.76989C13.7383 1.78452 13.8008 1.82058 13.8479 1.87289C13.9014 1.93237 13.9256 2.02253 13.9739 2.20285L14.9057 5.68018C14.954 5.86051 14.9781 5.95067 14.9615 6.02894C14.9469 6.0978 14.9108 6.16025 14.8585 6.20736C14.799 6.2609 14.7088 6.28506 14.5285 6.33338ZM2.33475 8.22033L3.23628 7.97876C3.4166 7.93044 3.50676 7.90628 3.56623 7.85274C3.61854 7.80563 3.6546 7.74318 3.66924 7.67433C3.68588 7.59605 3.66172 7.50589 3.6134 7.32556L3.37184 6.42403C3.32352 6.24371 3.29936 6.15355 3.24581 6.09408C3.19871 6.04176 3.13626 6.00571 3.0674 5.99107C2.98912 5.97443 2.89896 5.99859 2.71864 6.04691L1.81711 6.28847C1.63678 6.33679 1.54662 6.36095 1.48715 6.4145C1.43484 6.4616 1.39878 6.52405 1.38415 6.59291C1.36751 6.67119 1.39167 6.76135 1.43998 6.94167L1.68155 7.8432C1.72987 8.02352 1.75402 8.11369 1.80757 8.17316C1.85467 8.22547 1.91713 8.26153 1.98598 8.27616C2.06426 8.2928 2.15442 8.26864 2.33475 8.22033Z" stroke="currentColor" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
)
export type IExploreSideBarProps = { export type IExploreSideBarProps = {
controlUpdateInstalledApps: number controlUpdateInstalledApps: number
@@ -45,6 +36,9 @@ const SideBar: FC<IExploreSideBarProps> = ({
const media = useBreakpoints() const media = useBreakpoints()
const isMobile = media === MediaType.mobile const isMobile = media === MediaType.mobile
const [isFold, {
toggle: toggleIsFold,
}] = useBoolean(false)
const [showConfirm, setShowConfirm] = useState(false) const [showConfirm, setShowConfirm] = useState(false)
const [currId, setCurrId] = useState('') const [currId, setCurrId] = useState('')
@@ -84,22 +78,31 @@ const SideBar: FC<IExploreSideBarProps> = ({
const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length const pinnedAppsCount = installedApps.filter(({ is_pinned }) => is_pinned).length
return ( return (
<div className="w-fit shrink-0 cursor-pointer border-r border-divider-burn px-4 pt-6 sm:w-[216px]"> <div className={cn('relative w-fit shrink-0 cursor-pointer px-3 pt-6 sm:w-[240px]', isFold && 'sm:w-[56px]')}>
<div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}> <div className={cn(isDiscoverySelected ? 'text-text-accent' : 'text-text-tertiary')}>
<Link <Link
href="/explore/apps" href="/explore/apps"
className={cn(isDiscoverySelected ? ' bg-components-main-nav-nav-button-bg-active' : 'font-medium hover:bg-state-base-hover', 'flex h-9 items-center gap-2 rounded-lg px-3 mobile:w-fit mobile:justify-center mobile:px-2 pc:w-full pc:justify-start')} className={cn(isDiscoverySelected ? 'bg-state-base-active' : 'hover:bg-state-base-hover', 'flex h-8 items-center gap-2 rounded-lg px-1 mobile:w-fit mobile:justify-center pc:w-full pc:justify-start')}
style={isDiscoverySelected ? { boxShadow: '0px 1px 2px rgba(16, 24, 40, 0.05)' } : {}}
> >
{isDiscoverySelected ? <SelectedDiscoveryIcon /> : <DiscoveryIcon />} <div className="flex size-6 shrink-0 items-center justify-center rounded-md bg-components-icon-bg-blue-solid">
{!isMobile && <div className="text-sm">{t('sidebar.discovery', { ns: 'explore' })}</div>} <RiAppsFill className="size-3.5 text-components-avatar-shape-fill-stop-100" />
</div>
{!isMobile && !isFold && <div className={cn('truncate', isDiscoverySelected ? 'system-sm-semibold text-components-menu-item-text-active' : 'system-sm-regular text-components-menu-item-text')}>{t('sidebar.title', { ns: 'explore' })}</div>}
</Link> </Link>
</div> </div>
{installedApps.length === 0 && !isMobile && !isFold
&& (
<div className="mt-5">
<NoApps />
</div>
)}
{installedApps.length > 0 && ( {installedApps.length > 0 && (
<div className="mt-10"> <div className="mt-5">
<p className="break-all pl-2 text-xs font-medium uppercase text-text-tertiary mobile:px-0">{t('sidebar.workspace', { ns: 'explore' })}</p> {!isMobile && !isFold && <p className="system-xs-medium-uppercase mb-1.5 break-all pl-2 uppercase text-text-tertiary mobile:px-0">{t('sidebar.webApps', { ns: 'explore' })}</p>}
<div <div
className="mt-3 space-y-1 overflow-y-auto overflow-x-hidden" className="space-y-0.5 overflow-y-auto overflow-x-hidden"
style={{ style={{
height: 'calc(100vh - 250px)', height: 'calc(100vh - 250px)',
}} }}
@@ -107,7 +110,7 @@ const SideBar: FC<IExploreSideBarProps> = ({
{installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => ( {installedApps.map(({ id, is_pinned, uninstallable, app: { name, icon_type, icon, icon_url, icon_background } }, index) => (
<React.Fragment key={id}> <React.Fragment key={id}>
<Item <Item
isMobile={isMobile} isMobile={isMobile || isFold}
name={name} name={name}
icon_type={icon_type} icon_type={icon_type}
icon={icon} icon={icon}
@@ -129,6 +132,17 @@ const SideBar: FC<IExploreSideBarProps> = ({
</div> </div>
</div> </div>
)} )}
{!isMobile && (
<div className="absolute bottom-3 left-3 flex size-8 cursor-pointer items-center justify-center text-text-tertiary" onClick={toggleIsFold}>
{isFold
? <RiExpandRightLine className="size-4.5" />
: (
<RiLayoutLeft2Line className="size-4.5" />
)}
</div>
)}
{showConfirm && ( {showConfirm && (
<Confirm <Confirm
title={t('sidebar.delete.title', { ns: 'explore' })} title={t('sidebar.delete.title', { ns: 'explore' })}

View File

@@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import useTheme from '@/hooks/use-theme'
import { Theme } from '@/types/app'
import { cn } from '@/utils/classnames'
import s from './style.module.css'
const i18nPrefix = 'sidebar.noApps'
const NoApps: FC = () => {
const { t } = useTranslation()
const { theme } = useTheme()
return (
<div className="rounded-xl bg-background-default-subtle p-4">
<div className={cn('h-[35px] w-[86px] bg-contain bg-center bg-no-repeat', theme === Theme.dark ? s.dark : s.light)}></div>
<div className="system-sm-semibold mt-2 text-text-secondary">{t(`${i18nPrefix}.title`, { ns: 'explore' })}</div>
<div className="system-xs-regular my-1 text-text-tertiary">{t(`${i18nPrefix}.description`, { ns: 'explore' })}</div>
<a className="system-xs-regular text-text-accent" target="_blank" rel="noopener noreferrer" href="https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README">{t(`${i18nPrefix}.learnMore`, { ns: 'explore' })}</a>
</div>
)
}
export default React.memo(NoApps)

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -0,0 +1,7 @@
.light {
background-image: url('./no-web-apps-light.png');
}
.dark {
background-image: url('./no-web-apps-dark.png');
}

View File

@@ -0,0 +1,95 @@
'use client'
import type { FC } from 'react'
import type { TryAppInfo } from '@/service/try-app'
import { RiAddLine } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { AppTypeIcon } from '@/app/components/app/type-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import { cn } from '@/utils/classnames'
import useGetRequirements from './use-get-requirements'
type Props = {
appId: string
appDetail: TryAppInfo
category?: string
className?: string
onCreate: () => void
}
const headerClassName = 'system-sm-semibold-uppercase text-text-secondary mb-3'
const AppInfo: FC<Props> = ({
appId,
className,
category,
appDetail,
onCreate,
}) => {
const { t } = useTranslation()
const mode = appDetail?.mode
const { requirements } = useGetRequirements({ appDetail, appId })
return (
<div className={cn('flex h-full flex-col px-4 pt-2', className)}>
{/* name and icon */}
<div className="flex shrink-0 grow-0 items-center gap-3">
<div className="relative shrink-0">
<AppIcon
size="large"
iconType={appDetail.site.icon_type}
icon={appDetail.site.icon}
background={appDetail.site.icon_background}
imageUrl={appDetail.site.icon_url}
/>
<AppTypeIcon
wrapperClassName="absolute -bottom-0.5 -right-0.5 w-4 h-4 shadow-sm"
className="h-3 w-3"
type={mode}
/>
</div>
<div className="w-0 grow py-[1px]">
<div className="flex items-center text-sm font-semibold leading-5 text-text-secondary">
<div className="truncate" title={appDetail.name}>{appDetail.name}</div>
</div>
<div className="flex items-center text-[10px] font-medium leading-[18px] text-text-tertiary">
{mode === 'advanced-chat' && <div className="truncate">{t('types.advanced', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'chat' && <div className="truncate">{t('types.chatbot', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'agent-chat' && <div className="truncate">{t('types.agent', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'workflow' && <div className="truncate">{t('types.workflow', { ns: 'app' }).toUpperCase()}</div>}
{mode === 'completion' && <div className="truncate">{t('types.completion', { ns: 'app' }).toUpperCase()}</div>}
</div>
</div>
</div>
{appDetail.description && (
<div className="system-sm-regular mt-[14px] shrink-0 text-text-secondary">{appDetail.description}</div>
)}
<Button variant="primary" className="mt-3 flex w-full max-w-full" onClick={onCreate}>
<RiAddLine className="mr-1 size-4 shrink-0" />
<span className="truncate">{t('tryApp.createFromSampleApp', { ns: 'explore' })}</span>
</Button>
{category && (
<div className="mt-6 shrink-0">
<div className={headerClassName}>{t('tryApp.category', { ns: 'explore' })}</div>
<div className="system-md-regular text-text-secondary">{category}</div>
</div>
)}
{requirements.length > 0 && (
<div className="mt-5 grow overflow-y-auto">
<div className={headerClassName}>{t('tryApp.requirements', { ns: 'explore' })}</div>
<div className="space-y-0.5">
{requirements.map(item => (
<div className="flex items-center space-x-2 py-1" key={item.name}>
<div className="size-5 rounded-md bg-cover shadow-xs" style={{ backgroundImage: `url(${item.iconUrl})` }} />
<div className="system-md-regular w-0 grow truncate text-text-secondary">{item.name}</div>
</div>
))}
</div>
</div>
)}
</div>
)
}
export default React.memo(AppInfo)

View File

@@ -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

View File

@@ -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<Props> = ({
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 (
<EmbeddedChatbotContext.Provider value={{
...chatData,
disableFeedback: true,
isMobile,
themeBuilder,
} as EmbeddedChatbotContextValue}
>
<div className={cn('flex h-full flex-col rounded-2xl bg-background-section-burn', className)}>
<div className="flex shrink-0 justify-between p-3">
<div className="flex grow items-center space-x-2">
<AppIcon
size="large"
iconType={appDetail.site.icon_type}
icon={appDetail.site.icon}
background={appDetail.site.icon_background}
imageUrl={appDetail.site.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-primary" title={appDetail.name}>{appDetail.name}</div>
</div>
<div className="flex items-center gap-1">
{currentConversationId && (
<Tooltip
popupContent={t('chat.resetChat', { ns: 'share' })}
>
<ActionButton size="l" onClick={handleNewConversation}>
<RiResetLeftLine className="h-[18px] w-[18px]" />
</ActionButton>
</Tooltip>
)}
{currentConversationId && inputsForms.length > 0 && (
<ViewFormDropdown />
)}
</div>
</div>
<div className="mx-auto mt-4 flex h-[0] w-[769px] grow flex-col">
{!isHideTryNotice && (
<Alert className="mb-4 shrink-0" message={t('tryApp.tryInfo', { ns: 'explore' })} onHide={hideTryNotice} />
)}
<ChatWrapper />
</div>
</div>
</EmbeddedChatbotContext.Provider>
)
}
export default React.memo(TryApp)

View File

@@ -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<Props> = ({
appId,
appDetail,
}) => {
const mode = appDetail?.mode
const isChat = ['chat', 'advanced-chat', 'agent-chat'].includes(mode!)
const isCompletion = !isChat
useDocumentTitle(appDetail?.site?.title || '')
return (
<div className="flex h-full w-full">
{isChat && (
<Chat appId={appId} appDetail={appDetail} className="h-full grow" />
)}
{isCompletion && (
<TextGeneration
appId={appId}
className="h-full grow"
isWorkflow={mode === 'workflow'}
appData={{
app_id: appId,
custom_config: {},
...appDetail,
} as AppData}
/>
)}
</div>
)
}
export default React.memo(TryApp)

View File

@@ -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<Props> = ({
appId,
className,
isWorkflow,
appData,
}) => {
const { t } = useTranslation()
const media = useBreakpoints()
const isPC = media === MediaType.pc
const [inputs, doSetInputs] = useState<Record<string, InputValueTypes>>({})
const inputsRef = useRef<Record<string, InputValueTypes>>(inputs)
const setInputs = useCallback((newInputs: Record<string, InputValueTypes>) => {
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<SiteInfo | null>(null)
const [promptConfig, setPromptConfig] = useState<PromptConfig | null>(null)
const [customConfig, setCustomConfig] = useState<Record<string, CustomConfigValueType> | null>(null)
const [moreLikeThisConfig, setMoreLikeThisConfig] = useState<MoreLikeThisConfig | null>(null)
const [textToSpeechConfig, setTextToSpeechConfig] = useState<TextToSpeechConfig | null>(null)
const [controlSend, setControlSend] = useState(0)
const [visionConfig, setVisionConfig] = useState<VisionSettings>({
enabled: false,
number_limits: 2,
detail: Resolution.low,
transfer_methods: [TransferMethod.local_file],
})
const [completionFiles, setCompletionFiles] = useState<VisionFile[]>([])
const [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) => (
<Res
key={task?.id}
isWorkflow={!!isWorkflow}
isCallBatchAPI={false}
isPC={isPC}
isMobile={!isPC}
appSourceType={AppSourceType.tryApp}
appId={appId}
isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}
inputs={inputs}
controlSend={controlSend}
onShowRes={showResultPanel}
handleSaveMessage={noop}
taskId={task?.id}
onCompleted={handleCompleted}
visionConfig={visionConfig}
completionFiles={completionFiles}
isShowTextToSpeech={!!textToSpeechConfig?.enabled}
siteInfo={siteInfo}
onRunStart={() => setResultExisted(true)}
/>
)
const renderResWrap = (
<div
className={cn(
'relative flex h-full flex-col',
'rounded-r-2xl bg-chatbot-bg',
)}
>
<div className={cn(
'flex h-0 grow flex-col overflow-y-auto p-6',
)}
>
{isCompleted && !isHideTryNotice && (
<Alert className="mb-3 shrink-0" message={t('tryApp.tryInfo', { ns: 'explore' })} onHide={hideTryNotice} />
)}
{renderRes()}
</div>
</div>
)
if (!siteInfo || !promptConfig) {
return (
<div className={cn('flex h-screen items-center', className)}>
<Loading type="app" />
</div>
)
}
return (
<div className={cn(
'rounded-2xl border border-components-panel-border bg-background-section-burn',
isPC && 'flex',
!isPC && 'flex-col',
'h-full rounded-2xl shadow-md',
className,
)}
>
{/* Left */}
<div className={cn(
'relative flex h-full shrink-0 flex-col',
isPC && 'w-[600px] max-w-[50%]',
'rounded-l-2xl bg-components-panel-bg',
)}
>
{/* Header */}
<div className={cn('shrink-0 space-y-4 pb-2', isPC ? ' p-8 pb-0' : 'p-4 pb-0')}>
<div className="flex items-center gap-3">
<AppIcon
size={isPC ? 'large' : 'small'}
iconType={siteInfo.icon_type}
icon={siteInfo.icon}
background={siteInfo.icon_background || appDefaultIconBackground}
imageUrl={siteInfo.icon_url}
/>
<div className="system-md-semibold grow truncate text-text-secondary">{siteInfo.title}</div>
</div>
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
</div>
{/* form */}
<div className={cn(
'h-0 grow overflow-y-auto',
isPC ? 'px-8' : 'px-4',
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
inputsRef={inputsRef}
onInputsChange={setInputs}
promptConfig={promptConfig}
onSend={handleSend}
visionConfig={visionConfig}
onVisionFilesChange={setCompletionFiles}
/>
</div>
</div>
{/* Result */}
<div className={cn('h-full w-0 grow')}>
{!isPC && (
<div
className={cn(
isShowResultPanel
? 'flex items-center justify-center p-2 pt-6'
: 'absolute left-0 top-0 z-10 flex w-full items-center justify-center px-2 pb-[57px] pt-[3px]',
)}
onClick={() => {
if (isShowResultPanel)
hideResultPanel()
else
showResultPanel()
}}
>
<div className="h-1 w-8 cursor-grab rounded bg-divider-solid" />
</div>
)}
{renderResWrap}
</div>
</div>
)
}
export default React.memo(TextGeneration)

View File

@@ -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<Props> = ({
appId,
category,
onClose,
onCreate,
}) => {
const [type, setType] = useState<TypeEnum>(TypeEnum.TRY)
const { data: appDetail, isLoading } = useGetTryAppInfo(appId)
return (
<Modal
isShow
onClose={onClose}
className="h-[calc(100vh-32px)] min-w-[1280px] max-w-[calc(100vw-32px)] overflow-x-auto p-2"
>
{isLoading ? (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
) : (
<div className="flex h-full flex-col">
<div className="flex shrink-0 justify-between pl-4">
<Tab
value={type}
onChange={setType}
/>
<Button
size="large"
variant="tertiary"
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<RiCloseLine className="size-5" onClick={onClose} />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{type === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail!} /> : <Preview appId={appId} appDetail={appDetail!} />}
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail!}
appId={appId}
category={category}
onCreate={onCreate}
/>
</div>
</div>
)}
</Modal>
)
}
export default React.memo(TryApp)

View File

@@ -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<Props> = ({
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<FormValue>({})
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 (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
)
}
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 (
<ConfigContext.Provider value={value as any}>
<FeaturesProvider features={featuresData}>
<div className="flex h-full w-full flex-col bg-components-panel-on-panel-item-bg">
<div className="relative flex h-[200px] grow">
<div className="flex h-full w-full shrink-0 flex-col sm:w-1/2">
<Config />
</div>
{!isMobile && (
<div className="relative flex h-full w-1/2 grow flex-col overflow-y-auto " style={{ borderColor: 'rgba(0, 0, 0, 0.02)' }}>
<div className="flex grow flex-col rounded-tl-2xl border-l-[0.5px] border-t-[0.5px] border-components-panel-border bg-chatbot-bg ">
<Debug
isAPIKeySet
onSetting={noop}
inputs={inputs}
modelParameterParams={{
setModel: noop,
onCompletionParamsChange: noop,
}}
debugWithMultipleModel={false}
multipleModelConfigs={[]}
onMultipleModelConfigsChange={noop}
/>
</div>
</div>
)}
</div>
</div>
</FeaturesProvider>
</ConfigContext.Provider>
)
}
export default React.memo(BasicAppPreview)

View File

@@ -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<Props> = ({
appId,
className,
}) => {
const { data, isLoading } = useGetTryAppFlowPreview(appId)
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<Loading type="area" />
</div>
)
}
if (!data)
return null
return (
<div className="h-full w-full">
<WorkflowPreview
{...data.graph}
className={cn(className)}
miniMapToRight
/>
</div>
)
}
export default React.memo(FlowAppPreview)

View File

@@ -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<Props> = ({
appId,
appDetail,
}) => {
const isBasicApp = ['agent-chat', 'chat', 'completion'].includes(appDetail.mode)
return (
<div className="h-full w-full">
{isBasicApp ? <BasicAppPreview appId={appId} /> : <FlowAppPreview appId={appId} className="h-full" />}
</div>
)
}
export default React.memo(Preview)

View File

@@ -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<Props> = ({
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 (
<TabHeader
items={tabs}
value={value}
onChange={onChange as (value: string) => 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)

View File

@@ -34,7 +34,7 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title' import useDocumentTitle from '@/hooks/use-document-title'
import { changeLanguage } from '@/i18n-config/client' import { changeLanguage } from '@/i18n-config/client'
import { AccessMode } from '@/models/access-control' 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 { Resolution, TransferMethod } from '@/types/app'
import { cn } from '@/utils/classnames' import { cn } from '@/utils/classnames'
import { userInputsFormToPromptVariables } from '@/utils/model-config' import { userInputsFormToPromptVariables } from '@/utils/model-config'
@@ -69,10 +69,10 @@ export type IMainProps = {
const TextGeneration: FC<IMainProps> = ({ const TextGeneration: FC<IMainProps> = ({
isInstalledApp = false, isInstalledApp = false,
installedAppInfo,
isWorkflow = false, isWorkflow = false,
}) => { }) => {
const { notify } = Toast const { notify } = Toast
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const { t } = useTranslation() const { t } = useTranslation()
const media = useBreakpoints() const media = useBreakpoints()
@@ -102,16 +102,18 @@ const TextGeneration: FC<IMainProps> = ({
// save message // save message
const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([]) const [savedMessages, setSavedMessages] = useState<SavedMessage[]>([])
const fetchSavedMessage = useCallback(async () => { const fetchSavedMessage = useCallback(async () => {
const res: any = await doFetchSavedMessage(isInstalledApp, appId) if (!appId)
return
const res: any = await doFetchSavedMessage(appSourceType, appId)
setSavedMessages(res.data) setSavedMessages(res.data)
}, [isInstalledApp, appId]) }, [appSourceType, appId])
const handleSaveMessage = async (messageId: string) => { const handleSaveMessage = async (messageId: string) => {
await saveMessage(messageId, isInstalledApp, appId) await saveMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.saved', { ns: 'common' }) }) notify({ type: 'success', message: t('api.saved', { ns: 'common' }) })
fetchSavedMessage() fetchSavedMessage()
} }
const handleRemoveSavedMessage = async (messageId: string) => { const handleRemoveSavedMessage = async (messageId: string) => {
await removeMessage(messageId, isInstalledApp, appId) await removeMessage(messageId, appSourceType, appId)
notify({ type: 'success', message: t('api.remove', { ns: 'common' }) }) notify({ type: 'success', message: t('api.remove', { ns: 'common' }) })
fetchSavedMessage() fetchSavedMessage()
} }
@@ -423,9 +425,8 @@ const TextGeneration: FC<IMainProps> = ({
isCallBatchAPI={isCallBatchAPI} isCallBatchAPI={isCallBatchAPI}
isPC={isPC} isPC={isPC}
isMobile={!isPC} isMobile={!isPC}
isInstalledApp={isInstalledApp} appSourceType={isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp}
appId={appId} appId={appId}
installedAppInfo={installedAppInfo}
isError={task?.status === TaskStatus.failed} isError={task?.status === TaskStatus.failed}
promptConfig={promptConfig} promptConfig={promptConfig}
moreLikeThisEnabled={!!moreLikeThisConfig?.enabled} moreLikeThisEnabled={!!moreLikeThisConfig?.enabled}

View File

@@ -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 { WorkflowProcess } from '@/app/components/base/chat/types'
import type { FileEntity } from '@/app/components/base/file-uploader/types' import type { FileEntity } from '@/app/components/base/file-uploader/types'
import type { PromptConfig } from '@/models/debug' import type { PromptConfig } from '@/models/debug'
import type { InstalledApp } from '@/models/explore'
import type { SiteInfo } from '@/models/share' import type { SiteInfo } from '@/models/share'
import type { AppSourceType } from '@/service/share'
import type { VisionFile, VisionSettings } from '@/types/app' import type { VisionFile, VisionSettings } from '@/types/app'
import { RiLoader2Line } from '@remixicon/react' import { RiLoader2Line } from '@remixicon/react'
import { useBoolean } from 'ahooks' import { useBoolean } from 'ahooks'
@@ -35,9 +35,8 @@ export type IResultProps = {
isCallBatchAPI: boolean isCallBatchAPI: boolean
isPC: boolean isPC: boolean
isMobile: boolean isMobile: boolean
isInstalledApp: boolean appSourceType: AppSourceType
appId: string appId?: string
installedAppInfo?: InstalledApp
isError: boolean isError: boolean
isShowTextToSpeech: boolean isShowTextToSpeech: boolean
promptConfig: PromptConfig | null promptConfig: PromptConfig | null
@@ -63,9 +62,8 @@ const Result: FC<IResultProps> = ({
isCallBatchAPI, isCallBatchAPI,
isPC, isPC,
isMobile, isMobile,
isInstalledApp, appSourceType,
appId, appId,
installedAppInfo,
isError, isError,
isShowTextToSpeech, isShowTextToSpeech,
promptConfig, promptConfig,
@@ -133,7 +131,7 @@ const Result: FC<IResultProps> = ({
}) })
const handleFeedback = async (feedback: FeedbackType) => { 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) setFeedback(feedback)
} }
@@ -147,9 +145,9 @@ const Result: FC<IResultProps> = ({
setIsStopping(true) setIsStopping(true)
try { try {
if (isWorkflow) if (isWorkflow)
await stopWorkflowMessage(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') await stopWorkflowMessage(appId!, currentTaskId, appSourceType, appId || '')
else else
await stopChatMessageResponding(appId, currentTaskId, isInstalledApp, installedAppInfo?.id || '') await stopChatMessageResponding(appId!, currentTaskId, appSourceType, appId || '')
abortControllerRef.current?.abort() abortControllerRef.current?.abort()
} }
catch (error) { catch (error) {
@@ -159,7 +157,7 @@ const Result: FC<IResultProps> = ({
finally { finally {
setIsStopping(false) setIsStopping(false)
} }
}, [appId, currentTaskId, installedAppInfo?.id, isInstalledApp, isStopping, isWorkflow, notify]) }, [appId, currentTaskId, appSourceType, appId, isStopping, isWorkflow, notify])
useEffect(() => { useEffect(() => {
if (!onRunControlChange) if (!onRunControlChange)
@@ -468,8 +466,8 @@ const Result: FC<IResultProps> = ({
})) }))
}, },
}, },
isInstalledApp, appSourceType,
installedAppInfo?.id, appId,
).catch((error) => { ).catch((error) => {
setRespondingFalse() setRespondingFalse()
resetRunState() resetRunState()
@@ -514,7 +512,7 @@ const Result: FC<IResultProps> = ({
getAbortController: (abortController) => { getAbortController: (abortController) => {
abortControllerRef.current = abortController abortControllerRef.current = abortController
}, },
}, isInstalledApp, installedAppInfo?.id) }, appSourceType, appId)
} }
} }
@@ -562,8 +560,8 @@ const Result: FC<IResultProps> = ({
feedback={feedback} feedback={feedback}
onSave={handleSaveMessage} onSave={handleSaveMessage}
isMobile={isMobile} isMobile={isMobile}
isInstalledApp={isInstalledApp} appSourceType={appSourceType}
installedAppId={installedAppInfo?.id} installedAppId={appId}
isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false} isLoading={isCallBatchAPI ? (!completionRes && isResponding) : false}
taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined} taskId={isCallBatchAPI ? ((taskId as number) < 10 ? `0${taskId}` : `${taskId}`) : undefined}
controlClearMoreLikeThis={controlClearMoreLikeThis} controlClearMoreLikeThis={controlClearMoreLikeThis}

View File

@@ -1,4 +1,5 @@
import type { ChangeEvent, FC, FormEvent } from 'react' import type { ChangeEvent, FC, FormEvent } from 'react'
import type { InputValueTypes } from '../types'
import type { PromptConfig } from '@/models/debug' import type { PromptConfig } from '@/models/debug'
import type { SiteInfo } from '@/models/share' import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app' import type { VisionFile, VisionSettings } from '@/types/app'
@@ -25,9 +26,9 @@ import { cn } from '@/utils/classnames'
export type IRunOnceProps = { export type IRunOnceProps = {
siteInfo: SiteInfo siteInfo: SiteInfo
promptConfig: PromptConfig promptConfig: PromptConfig
inputs: Record<string, any> inputs: Record<string, InputValueTypes>
inputsRef: React.RefObject<Record<string, any>> inputsRef: React.RefObject<Record<string, InputValueTypes>>
onInputsChange: (inputs: Record<string, any>) => void onInputsChange: (inputs: Record<string, InputValueTypes>) => void
onSend: () => void onSend: () => void
visionConfig: VisionSettings visionConfig: VisionSettings
onVisionFilesChange: (files: VisionFile[]) => void onVisionFilesChange: (files: VisionFile[]) => void
@@ -52,7 +53,7 @@ const RunOnce: FC<IRunOnceProps> = ({
const [isInitialized, setIsInitialized] = useState(false) const [isInitialized, setIsInitialized] = useState(false)
const onClear = () => { const onClear = () => {
const newInputs: Record<string, any> = {} const newInputs: Record<string, InputValueTypes> = {}
promptConfig.prompt_variables.forEach((item) => { promptConfig.prompt_variables.forEach((item) => {
if (item.type === 'string' || item.type === 'paragraph') if (item.type === 'string' || item.type === 'paragraph')
newInputs[item.key] = '' newInputs[item.key] = ''
@@ -127,7 +128,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'select' && ( {item.type === 'select' && (
<Select <Select
className="w-full" className="w-full"
defaultValue={inputs[item.key]} defaultValue={inputs[item.key] as (string | number | undefined)}
onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }} onSelect={(i) => { handleInputsChange({ ...inputsRef.current, [item.key]: i.value }) }}
items={(item.options || []).map(i => ({ name: i, value: i }))} items={(item.options || []).map(i => ({ name: i, value: i }))}
allowSearch={false} allowSearch={false}
@@ -137,7 +138,7 @@ const RunOnce: FC<IRunOnceProps> = ({
<Input <Input
type="text" type="text"
placeholder={item.name} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key] as string}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
maxLength={item.max_length} maxLength={item.max_length}
/> />
@@ -146,7 +147,7 @@ const RunOnce: FC<IRunOnceProps> = ({
<Textarea <Textarea
className="h-[104px] sm:text-xs" className="h-[104px] sm:text-xs"
placeholder={item.name} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key] as string}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/> />
)} )}
@@ -154,14 +155,14 @@ const RunOnce: FC<IRunOnceProps> = ({
<Input <Input
type="number" type="number"
placeholder={item.name} placeholder={item.name}
value={inputs[item.key]} value={inputs[item.key] as number}
onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }} onChange={(e: ChangeEvent<HTMLInputElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
/> />
)} )}
{item.type === 'checkbox' && ( {item.type === 'checkbox' && (
<BoolInput <BoolInput
name={item.name || item.key} name={item.name || item.key}
value={!!inputs[item.key]} value={!!inputs[item.key] as boolean}
required={item.required} required={item.required}
onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
/> />
@@ -182,6 +183,7 @@ const RunOnce: FC<IRunOnceProps> = ({
onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }} onChange={(files) => { handleInputsChange({ ...inputsRef.current, [item.key]: files }) }}
fileConfig={{ fileConfig={{
...item.config, ...item.config,
// eslint-disable-next-line ts/no-explicit-any
fileUploadConfig: (visionConfig as any).fileUploadConfig, fileUploadConfig: (visionConfig as any).fileUploadConfig,
}} }}
/> />
@@ -189,7 +191,7 @@ const RunOnce: FC<IRunOnceProps> = ({
{item.type === 'json_object' && ( {item.type === 'json_object' && (
<CodeEditor <CodeEditor
language={CodeLanguage.json} language={CodeLanguage.json}
value={inputs[item.key]} value={inputs[item.key] as string}
onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }} onChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
noWrapper noWrapper
className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1" className="bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1"

View File

@@ -0,0 +1,19 @@
type TaskParam = {
inputs: Record<string, string | boolean | undefined>
}
export type Task = {
id: number
status: TaskStatus
params: TaskParam
}
export enum TaskStatus {
pending = 'pending',
running = 'running',
completed = 'completed',
failed = 'failed',
}
// eslint-disable-next-line ts/no-explicit-any
export type InputValueTypes = string | boolean | number | string[] | object | undefined | any

View File

@@ -10,6 +10,7 @@ type Props = {
value: boolean value: boolean
required?: boolean required?: boolean
onChange: (value: boolean) => void onChange: (value: boolean) => void
readonly?: boolean
} }
const BoolInput: FC<Props> = ({ const BoolInput: FC<Props> = ({
@@ -17,6 +18,7 @@ const BoolInput: FC<Props> = ({
onChange, onChange,
name, name,
required, required,
readonly,
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
const handleChange = useCallback(() => { const handleChange = useCallback(() => {
@@ -28,6 +30,7 @@ const BoolInput: FC<Props> = ({
className="!h-4 !w-4" className="!h-4 !w-4"
checked={!!value} checked={!!value}
onCheck={handleChange} onCheck={handleChange}
disabled={readonly}
/> />
<div className="system-sm-medium flex items-center gap-1 text-text-secondary"> <div className="system-sm-medium flex items-center gap-1 text-text-secondary">
{name} {name}

View File

@@ -469,6 +469,7 @@ export enum SupportUploadFileTypes {
export type UploadFileSetting = { export type UploadFileSetting = {
allowed_file_upload_methods: TransferMethod[] allowed_file_upload_methods: TransferMethod[]
allowed_upload_methods?: TransferMethod[]
allowed_file_types: SupportUploadFileTypes[] allowed_file_types: SupportUploadFileTypes[]
allowed_file_extensions?: string[] allowed_file_extensions?: string[]
max_length: number max_length: number

View File

@@ -61,12 +61,14 @@ type WorkflowPreviewProps = {
edges: Edge[] edges: Edge[]
viewport: Viewport viewport: Viewport
className?: string className?: string
miniMapToRight?: boolean
} }
const WorkflowPreview = ({ const WorkflowPreview = ({
nodes, nodes,
edges, edges,
viewport, viewport,
className, className,
miniMapToRight,
}: WorkflowPreviewProps) => { }: WorkflowPreviewProps) => {
const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges)) const [nodesData, setNodesData] = useState(() => initialNodes(nodes, edges))
const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes)) const [edgesData, setEdgesData] = useState(() => initialEdges(edges, nodes))
@@ -97,8 +99,7 @@ const WorkflowPreview = ({
height: 72, height: 72,
}} }}
maskColor="var(--color-workflow-minimap-bg)" maskColor="var(--color-workflow-minimap-bg)"
className="!absolute !bottom-14 !left-4 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px] className={cn('!absolute !bottom-14 z-[9] !m-0 !h-[72px] !w-[102px] !rounded-lg !border-[0.5px] !border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5', miniMapToRight ? '!right-4' : '!left-4')}
!border-divider-subtle !bg-background-default-subtle !shadow-md !shadow-shadow-shadow-5"
/> />
<div className="absolute bottom-4 left-4 z-[9] mt-1 flex items-center gap-2"> <div className="absolute bottom-4 left-4 z-[9] mt-1 flex items-center gap-2">
<ZoomInOut /> <ZoomInOut />

View File

@@ -0,0 +1,19 @@
import type { CurrentTryAppParams } from './explore-context'
import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector'
type Props = {
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
controlHideCreateFromTemplatePanel: number
}
const AppListContext = createContext<Props>({
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
controlHideCreateFromTemplatePanel: 0,
})
export default AppListContext

View File

@@ -29,6 +29,7 @@ import { PromptMode } from '@/models/debug'
import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app' import { AppModeEnum, ModelModeType, Resolution, RETRIEVE_TYPE, TransferMethod } from '@/types/app'
type IDebugConfiguration = { type IDebugConfiguration = {
readonly?: boolean
appId: string appId: string
isAPIKeySet: boolean isAPIKeySet: boolean
isTrailFinished: boolean isTrailFinished: boolean
@@ -108,6 +109,7 @@ type IDebugConfiguration = {
} }
const DebugConfigurationContext = createContext<IDebugConfiguration>({ const DebugConfigurationContext = createContext<IDebugConfiguration>({
readonly: false,
appId: '', appId: '',
isAPIKeySet: false, isAPIKeySet: false,
isTrailFinished: false, isTrailFinished: false,

View File

@@ -1,8 +1,13 @@
import type { InstalledApp } from '@/models/explore' import type { App, InstalledApp } from '@/models/explore'
import { noop } from 'es-toolkit/function' import { noop } from 'es-toolkit/function'
import { createContext } from 'use-context-selector' import { createContext } from 'use-context-selector'
type IExplore = { export type CurrentTryAppParams = {
appId: string
app: App
}
export type IExplore = {
controlUpdateInstalledApps: number controlUpdateInstalledApps: number
setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void setControlUpdateInstalledApps: (controlUpdateInstalledApps: number) => void
hasEditPermission: boolean hasEditPermission: boolean
@@ -10,6 +15,9 @@ type IExplore = {
setInstalledApps: (installedApps: InstalledApp[]) => void setInstalledApps: (installedApps: InstalledApp[]) => void
isFetchingInstalledApps: boolean isFetchingInstalledApps: boolean
setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void setIsFetchingInstalledApps: (isFetchingInstalledApps: boolean) => void
currentApp?: CurrentTryAppParams
isShowTryAppPanel: boolean
setShowTryAppPanel: (showTryAppPanel: boolean, params?: CurrentTryAppParams) => void
} }
const ExploreContext = createContext<IExplore>({ const ExploreContext = createContext<IExplore>({
@@ -20,6 +28,9 @@ const ExploreContext = createContext<IExplore>({
setInstalledApps: noop, setInstalledApps: noop,
isFetchingInstalledApps: false, isFetchingInstalledApps: false,
setIsFetchingInstalledApps: noop, setIsFetchingInstalledApps: noop,
isShowTryAppPanel: false,
setShowTryAppPanel: noop,
currentApp: undefined,
}) })
export default ExploreContext export default ExploreContext

View File

@@ -0,0 +1,56 @@
import type { ChatConfig } from '@/app/components/base/chat/types'
import type { DataSetListResponse } from '@/models/datasets'
import type { TryAppFlowPreview, TryAppInfo } from '@/models/try-app'
import { type } from '@orpc/contract'
import { base } from '../base'
export const trialAppInfoContract = base
.route({
path: '/trial-apps/{appId}',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<TryAppInfo>())
export const trialAppDatasetsContract = base
.route({
path: '/trial-apps/{appId}/datasets',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
query: {
ids: string[]
}
}>())
.output(type<DataSetListResponse>())
export const trialAppWorkflowsContract = base
.route({
path: '/trial-apps/{appId}/workflows',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<TryAppFlowPreview>())
export const trialAppParametersContract = base
.route({
path: '/trial-apps/{appId}/parameters',
method: 'GET',
})
.input(type<{
params: {
appId: string
}
}>())
.output(type<ChatConfig>())

View File

@@ -1,6 +1,7 @@
import type { InferContractRouterInputs } from '@orpc/contract' import type { InferContractRouterInputs } from '@orpc/contract'
import { bindPartnerStackContract, invoicesContract } from './console/billing' import { bindPartnerStackContract, invoicesContract } from './console/billing'
import { systemFeaturesContract } from './console/system' import { systemFeaturesContract } from './console/system'
import { trialAppDatasetsContract, trialAppInfoContract, trialAppParametersContract, trialAppWorkflowsContract } from './console/try-app'
import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace' import { collectionPluginsContract, collectionsContract, searchAdvancedContract } from './marketplace'
export const marketplaceRouterContract = { export const marketplaceRouterContract = {
@@ -13,6 +14,12 @@ export type MarketPlaceInputs = InferContractRouterInputs<typeof marketplaceRout
export const consoleRouterContract = { export const consoleRouterContract = {
systemFeatures: systemFeaturesContract, systemFeatures: systemFeaturesContract,
trialApps: {
info: trialAppInfoContract,
datasets: trialAppDatasetsContract,
parameters: trialAppParametersContract,
workflows: trialAppWorkflowsContract,
},
billing: { billing: {
invoices: invoicesContract, invoices: invoicesContract,
bindPartnerStack: bindPartnerStackContract, bindPartnerStack: bindPartnerStackContract,

View File

@@ -855,10 +855,7 @@
}, },
"app/components/base/chat/embedded-chatbot/hooks.tsx": { "app/components/base/chat/embedded-chatbot/hooks.tsx": {
"react-hooks-extra/no-direct-set-state-in-use-effect": { "react-hooks-extra/no-direct-set-state-in-use-effect": {
"count": 6 "count": 3
},
"ts/no-explicit-any": {
"count": 16
} }
}, },
"app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": { "app/components/base/chat/embedded-chatbot/inputs-form/content.tsx": {
@@ -2645,7 +2642,7 @@
"count": 1 "count": 1
}, },
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 8 "count": 3
} }
}, },
"app/components/share/utils.ts": { "app/components/share/utils.ts": {
@@ -4247,11 +4244,6 @@
"count": 1 "count": 1
} }
}, },
"middleware.ts": {
"node/prefer-global/buffer": {
"count": 1
}
},
"models/common.ts": { "models/common.ts": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 3 "count": 3
@@ -4282,7 +4274,7 @@
"count": 1 "count": 1
}, },
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 2 "count": 1
} }
}, },
"scripts/analyze-component.js": { "scripts/analyze-component.js": {
@@ -4327,12 +4319,7 @@
}, },
"service/debug.ts": { "service/debug.ts": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 7 "count": 6
}
},
"service/explore.ts": {
"ts/no-explicit-any": {
"count": 1
} }
}, },
"service/fetch.ts": { "service/fetch.ts": {
@@ -4345,7 +4332,7 @@
}, },
"service/share.ts": { "service/share.ts": {
"ts/no-explicit-any": { "ts/no-explicit-any": {
"count": 4 "count": 3
} }
}, },
"service/tools.ts": { "service/tools.ts": {

View File

@@ -1,11 +1,9 @@
{ {
"appCard.addToWorkspace": "إضافة إلى مساحة العمل", "appCard.addToWorkspace": "إضافة إلى مساحة العمل",
"appCard.customize": "تخصيص",
"appCustomize.nameRequired": "اسم التطبيق مطلوب", "appCustomize.nameRequired": "اسم التطبيق مطلوب",
"appCustomize.subTitle": "أيقونة التطبيق واسمه", "appCustomize.subTitle": "أيقونة التطبيق واسمه",
"appCustomize.title": "إنشاء تطبيق من {{name}}", "appCustomize.title": "إنشاء تطبيق من {{name}}",
"apps.allCategories": "موصى به", "apps.allCategories": "موصى به",
"apps.description": "استخدم تطبيقات القوالب هذه فورًا أو خصص تطبيقاتك الخاصة بناءً على القوالب.",
"apps.title": "استكشاف التطبيقات", "apps.title": "استكشاف التطبيقات",
"category.Agent": "وكيل", "category.Agent": "وكيل",
"category.Assistant": "مساعد", "category.Assistant": "مساعد",
@@ -23,7 +21,5 @@
"sidebar.chat": "دردشة", "sidebar.chat": "دردشة",
"sidebar.delete.content": "هل أنت متأكد أنك تريد حذف هذا التطبيق؟", "sidebar.delete.content": "هل أنت متأكد أنك تريد حذف هذا التطبيق؟",
"sidebar.delete.title": "حذف التطبيق", "sidebar.delete.title": "حذف التطبيق",
"sidebar.discovery": "اكتشاف",
"sidebar.workspace": "مساحة العمل",
"title": "استكشاف" "title": "استكشاف"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "Zum Arbeitsbereich hinzufügen",
"appCard.customize": "Anpassen",
"appCustomize.nameRequired": "App-Name ist erforderlich", "appCustomize.nameRequired": "App-Name ist erforderlich",
"appCustomize.subTitle": "App-Symbol & Name", "appCustomize.subTitle": "App-Symbol & Name",
"appCustomize.title": "App aus {{name}} erstellen", "appCustomize.title": "App aus {{name}} erstellen",
"apps.allCategories": "Alle Kategorien",
"apps.description": "Nutzen Sie diese Vorlagen-Apps sofort oder passen Sie Ihre eigenen Apps basierend auf den Vorlagen an.",
"apps.title": "Apps von Dify erkunden",
"category.Agent": "Agent", "category.Agent": "Agent",
"category.Assistant": "Assistent", "category.Assistant": "Assistent",
"category.Entertainment": "Unterhaltung", "category.Entertainment": "Unterhaltung",
@@ -23,7 +18,5 @@
"sidebar.chat": "Chat", "sidebar.chat": "Chat",
"sidebar.delete.content": "Sind Sie sicher, dass Sie diese App löschen möchten?", "sidebar.delete.content": "Sind Sie sicher, dass Sie diese App löschen möchten?",
"sidebar.delete.title": "App löschen", "sidebar.delete.title": "App löschen",
"sidebar.discovery": "Entdeckung",
"sidebar.workspace": "Arbeitsbereich",
"title": "Entdecken" "title": "Entdecken"
} }

View File

@@ -110,6 +110,7 @@
"chat.conversationName": "Conversation name", "chat.conversationName": "Conversation name",
"chat.conversationNameCanNotEmpty": "Conversation name required", "chat.conversationNameCanNotEmpty": "Conversation name required",
"chat.conversationNamePlaceholder": "Please input conversation name", "chat.conversationNamePlaceholder": "Please input conversation name",
"chat.inputDisabledPlaceholder": "Preview Only",
"chat.inputPlaceholder": "Talk to {{botName}}", "chat.inputPlaceholder": "Talk to {{botName}}",
"chat.renameConversation": "Rename Conversation", "chat.renameConversation": "Rename Conversation",
"chat.resend": "Resend", "chat.resend": "Resend",

View File

@@ -1,12 +1,14 @@
{ {
"appCard.addToWorkspace": "Add to Workspace", "appCard.addToWorkspace": "Use template",
"appCard.customize": "Customize", "appCard.try": "Details",
"appCustomize.nameRequired": "App name is required", "appCustomize.nameRequired": "App name is required",
"appCustomize.subTitle": "App icon & name", "appCustomize.subTitle": "App icon & name",
"appCustomize.title": "Create app from {{name}}", "appCustomize.title": "Create app from {{name}}",
"apps.allCategories": "Recommended", "apps.allCategories": "All",
"apps.description": "Use these template apps instantly or customize your own apps based on the templates.", "apps.resetFilter": "Clear filter",
"apps.title": "Explore Apps", "apps.resultNum": "{{num}} results",
"apps.title": "Try Dify's curated apps to find AI solutions for your business",
"banner.viewMore": "VIEW MORE",
"category.Agent": "Agent", "category.Agent": "Agent",
"category.Assistant": "Assistant", "category.Assistant": "Assistant",
"category.Entertainment": "Entertainment", "category.Entertainment": "Entertainment",
@@ -23,7 +25,16 @@
"sidebar.chat": "Chat", "sidebar.chat": "Chat",
"sidebar.delete.content": "Are you sure you want to delete this app?", "sidebar.delete.content": "Are you sure you want to delete this app?",
"sidebar.delete.title": "Delete app", "sidebar.delete.title": "Delete app",
"sidebar.discovery": "Discovery", "sidebar.noApps.description": "Published web apps will appear here",
"sidebar.workspace": "Workspace", "sidebar.noApps.learnMore": "Learn more",
"title": "Explore" "sidebar.noApps.title": "No web apps",
"sidebar.title": "App gallery",
"sidebar.webApps": "Web apps",
"title": "Explore",
"tryApp.category": "Category",
"tryApp.createFromSampleApp": "Create from this sample app",
"tryApp.requirements": "Requirements",
"tryApp.tabHeader.detail": "Orchestration Details",
"tryApp.tabHeader.try": "Try it",
"tryApp.tryInfo": "This is a sample app. You can try up to 5 messages. To keep using it, click \"Create form this sample app\" and set it up!"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "Agregar al espacio de trabajo",
"appCard.customize": "Personalizar",
"appCustomize.nameRequired": "El nombre de la aplicación es obligatorio", "appCustomize.nameRequired": "El nombre de la aplicación es obligatorio",
"appCustomize.subTitle": "Icono y nombre de la aplicación", "appCustomize.subTitle": "Icono y nombre de la aplicación",
"appCustomize.title": "Crear aplicación a partir de {{name}}", "appCustomize.title": "Crear aplicación a partir de {{name}}",
"apps.allCategories": "Recomendado",
"apps.description": "Utiliza estas aplicaciones de plantilla al instante o personaliza tus propias aplicaciones basadas en las plantillas.",
"apps.title": "Explorar aplicaciones de Dify",
"category.Agent": "Agente", "category.Agent": "Agente",
"category.Assistant": "Asistente", "category.Assistant": "Asistente",
"category.Entertainment": "Entretenimiento", "category.Entertainment": "Entretenimiento",
@@ -23,7 +18,5 @@
"sidebar.chat": "Chat", "sidebar.chat": "Chat",
"sidebar.delete.content": "¿Estás seguro de que quieres eliminar esta aplicación?", "sidebar.delete.content": "¿Estás seguro de que quieres eliminar esta aplicación?",
"sidebar.delete.title": "Eliminar aplicación", "sidebar.delete.title": "Eliminar aplicación",
"sidebar.discovery": "Descubrimiento",
"sidebar.workspace": "Espacio de trabajo",
"title": "Explorar" "title": "Explorar"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "افزودن به فضای کاری",
"appCard.customize": "سفارشی کردن",
"appCustomize.nameRequired": "نام برنامه الزامی است", "appCustomize.nameRequired": "نام برنامه الزامی است",
"appCustomize.subTitle": "آیکون و نام برنامه", "appCustomize.subTitle": "آیکون و نام برنامه",
"appCustomize.title": "ایجاد برنامه از {{name}}", "appCustomize.title": "ایجاد برنامه از {{name}}",
"apps.allCategories": "پیشنهاد شده",
"apps.description": "از این برنامه‌های قالبی بلافاصله استفاده کنید یا برنامه‌های خود را بر اساس این قالب‌ها سفارشی کنید.",
"apps.title": "کاوش برنامه‌ها توسط دیفی",
"category.Agent": "عامل", "category.Agent": "عامل",
"category.Assistant": "دستیار", "category.Assistant": "دستیار",
"category.Entertainment": "سرگرمی", "category.Entertainment": "سرگرمی",
@@ -23,7 +18,5 @@
"sidebar.chat": "چت", "sidebar.chat": "چت",
"sidebar.delete.content": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟", "sidebar.delete.content": "آیا مطمئن هستید که می‌خواهید این برنامه را حذف کنید؟",
"sidebar.delete.title": "حذف برنامه", "sidebar.delete.title": "حذف برنامه",
"sidebar.discovery": "کشف",
"sidebar.workspace": "فضای کاری",
"title": "کاوش" "title": "کاوش"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "Ajouter à l'espace de travail",
"appCard.customize": "Personnaliser",
"appCustomize.nameRequired": "Le nom de l'application est requis", "appCustomize.nameRequired": "Le nom de l'application est requis",
"appCustomize.subTitle": "Icône de l'application & nom", "appCustomize.subTitle": "Icône de l'application & nom",
"appCustomize.title": "Créer une application à partir de {{name}}", "appCustomize.title": "Créer une application à partir de {{name}}",
"apps.allCategories": "Recommandé",
"apps.description": "Utilisez ces applications modèles instantanément ou personnalisez vos propres applications basées sur les modèles.",
"apps.title": "Explorez les applications par Dify",
"category.Agent": "Agent", "category.Agent": "Agent",
"category.Assistant": "Assistant", "category.Assistant": "Assistant",
"category.Entertainment": "Divertissement", "category.Entertainment": "Divertissement",
@@ -23,7 +18,5 @@
"sidebar.chat": "Discussion", "sidebar.chat": "Discussion",
"sidebar.delete.content": "Êtes-vous sûr de vouloir supprimer cette application ?", "sidebar.delete.content": "Êtes-vous sûr de vouloir supprimer cette application ?",
"sidebar.delete.title": "Supprimer l'application", "sidebar.delete.title": "Supprimer l'application",
"sidebar.discovery": "Découverte",
"sidebar.workspace": "Espace de travail",
"title": "Explorer" "title": "Explorer"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "कार्यक्षेत्र में जोड़ें",
"appCard.customize": "अनुकूलित करें",
"appCustomize.nameRequired": "ऐप का नाम आवश्यक है", "appCustomize.nameRequired": "ऐप का नाम आवश्यक है",
"appCustomize.subTitle": "ऐप आइकन और नाम", "appCustomize.subTitle": "ऐप आइकन और नाम",
"appCustomize.title": "{{name}} से ऐप बनाएँ", "appCustomize.title": "{{name}} से ऐप बनाएँ",
"apps.allCategories": "अनुशंसित",
"apps.description": "इन टेम्प्लेट ऐप्स का तुरंत उपयोग करें या टेम्प्लेट्स के आधार पर अपने स्वयं के ऐप्स को कस्टमाइज़ करें।",
"apps.title": "डिफ़ी द्वारा ऐप्स का अन्वेषण करें",
"category.Agent": "आढ़तिया", "category.Agent": "आढ़तिया",
"category.Assistant": "सहायक", "category.Assistant": "सहायक",
"category.Entertainment": "मनोरंजन", "category.Entertainment": "मनोरंजन",
@@ -23,7 +18,5 @@
"sidebar.chat": "चैट", "sidebar.chat": "चैट",
"sidebar.delete.content": "क्या आप वाकई इस ऐप को हटाना चाहते हैं?", "sidebar.delete.content": "क्या आप वाकई इस ऐप को हटाना चाहते हैं?",
"sidebar.delete.title": "ऐप हटाएं", "sidebar.delete.title": "ऐप हटाएं",
"sidebar.discovery": "खोज",
"sidebar.workspace": "कार्यक्षेत्र",
"title": "अन्वेषण करें" "title": "अन्वेषण करें"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "Tambahkan ke Ruang Kerja",
"appCard.customize": "Menyesuaikan",
"appCustomize.nameRequired": "Nama aplikasi diperlukan", "appCustomize.nameRequired": "Nama aplikasi diperlukan",
"appCustomize.subTitle": "Ikon & nama aplikasi", "appCustomize.subTitle": "Ikon & nama aplikasi",
"appCustomize.title": "Buat aplikasi dari {{name}}", "appCustomize.title": "Buat aplikasi dari {{name}}",
"apps.allCategories": "Direkomendasikan",
"apps.description": "Gunakan aplikasi templat ini secara instan atau sesuaikan aplikasi Anda sendiri berdasarkan templat.",
"apps.title": "Jelajahi Aplikasi",
"category.Agent": "Agen", "category.Agent": "Agen",
"category.Assistant": "Asisten", "category.Assistant": "Asisten",
"category.Entertainment": "Hiburan", "category.Entertainment": "Hiburan",
@@ -23,7 +18,5 @@
"sidebar.chat": "Mengobrol", "sidebar.chat": "Mengobrol",
"sidebar.delete.content": "Apakah Anda yakin ingin menghapus aplikasi ini?", "sidebar.delete.content": "Apakah Anda yakin ingin menghapus aplikasi ini?",
"sidebar.delete.title": "Hapus aplikasi", "sidebar.delete.title": "Hapus aplikasi",
"sidebar.discovery": "Penemuan",
"sidebar.workspace": "Workspace",
"title": "Menjelajahi" "title": "Menjelajahi"
} }

View File

@@ -1,12 +1,7 @@
{ {
"appCard.addToWorkspace": "Aggiungi a Workspace",
"appCard.customize": "Personalizza",
"appCustomize.nameRequired": "Il nome dell'app è obbligatorio", "appCustomize.nameRequired": "Il nome dell'app è obbligatorio",
"appCustomize.subTitle": "Icona & nome dell'app", "appCustomize.subTitle": "Icona & nome dell'app",
"appCustomize.title": "Crea app da {{name}}", "appCustomize.title": "Crea app da {{name}}",
"apps.allCategories": "Consigliato",
"apps.description": "Usa queste app modello istantaneamente o personalizza le tue app basate sui modelli.",
"apps.title": "Esplora App di Dify",
"category.Agent": "Agente", "category.Agent": "Agente",
"category.Assistant": "Assistente", "category.Assistant": "Assistente",
"category.Entertainment": "Intrattenimento", "category.Entertainment": "Intrattenimento",
@@ -23,7 +18,5 @@
"sidebar.chat": "Chat", "sidebar.chat": "Chat",
"sidebar.delete.content": "Sei sicuro di voler eliminare questa app?", "sidebar.delete.content": "Sei sicuro di voler eliminare questa app?",
"sidebar.delete.title": "Elimina app", "sidebar.delete.title": "Elimina app",
"sidebar.discovery": "Scoperta",
"sidebar.workspace": "Workspace",
"title": "Esplora" "title": "Esplora"
} }

View File

@@ -110,6 +110,7 @@
"chat.conversationName": "会話名", "chat.conversationName": "会話名",
"chat.conversationNameCanNotEmpty": "会話名は必須です", "chat.conversationNameCanNotEmpty": "会話名は必須です",
"chat.conversationNamePlaceholder": "会話名を入力してください", "chat.conversationNamePlaceholder": "会話名を入力してください",
"chat.inputDisabledPlaceholder": "プレビューのみ",
"chat.inputPlaceholder": "{{botName}} と話す", "chat.inputPlaceholder": "{{botName}} と話す",
"chat.renameConversation": "会話名を変更", "chat.renameConversation": "会話名を変更",
"chat.resend": "再送信してください", "chat.resend": "再送信してください",

View File

@@ -1,12 +1,14 @@
{ {
"appCard.addToWorkspace": "ワークスペースに追加", "appCard.addToWorkspace": "テンプレートを使用",
"appCard.customize": "カスタマイズ", "appCard.try": "詳細",
"appCustomize.nameRequired": "アプリ名は必須です", "appCustomize.nameRequired": "アプリ名は必須です",
"appCustomize.subTitle": "アプリアイコンと名前", "appCustomize.subTitle": "アプリアイコンと名前",
"appCustomize.title": "{{name}}からアプリを作成", "appCustomize.title": "{{name}}からアプリを作成",
"apps.allCategories": "推奨", "apps.allCategories": "全て",
"apps.description": "これらのテンプレートアプリを即座に使用するか、テンプレートに基づいて独自のアプリをカスタマイズしてください。", "apps.resetFilter": "クリア",
"apps.title": "アプリを探索", "apps.resultNum": "{{num}}件の結果",
"apps.title": "Difyの厳選アプリを試して、ビジネス向けのAIソリューションを見つけましょう",
"banner.viewMore": "もっと見る",
"category.Agent": "エージェント", "category.Agent": "エージェント",
"category.Assistant": "アシスタント", "category.Assistant": "アシスタント",
"category.Entertainment": "エンターテイメント", "category.Entertainment": "エンターテイメント",
@@ -23,7 +25,16 @@
"sidebar.chat": "チャット", "sidebar.chat": "チャット",
"sidebar.delete.content": "このアプリを削除してもよろしいですか?", "sidebar.delete.content": "このアプリを削除してもよろしいですか?",
"sidebar.delete.title": "アプリを削除", "sidebar.delete.title": "アプリを削除",
"sidebar.discovery": "探索", "sidebar.noApps.description": "公開されたWebアプリがここに表示されます",
"sidebar.workspace": "ワークスペース", "sidebar.noApps.learnMore": "詳細",
"title": "探索" "sidebar.noApps.title": "Webアプリなし",
"sidebar.title": "アプリギャラリー",
"sidebar.webApps": "Webアプリ",
"title": "探索",
"tryApp.category": "カテゴリー",
"tryApp.createFromSampleApp": "テンプレートから作成",
"tryApp.requirements": "必要項目",
"tryApp.tabHeader.detail": "オーケストレーション詳細",
"tryApp.tabHeader.try": "お試し",
"tryApp.tryInfo": "これはサンプルアプリです。最大5件のメッセージまでお試しいただけます。引き続き利用するには、「テンプレートから作成」 をクリックして設定を行ってください。"
} }

Some files were not shown because too many files have changed in this diff Show More