Merge branch 'main' into feat/hitl-frontend

This commit is contained in:
twwu
2026-01-23 15:51:58 +08:00
553 changed files with 13688 additions and 4912 deletions

View File

@ -138,7 +138,7 @@ This will help you determine the testing strategy. See [web/testing/testing.md](
## Documentation
Visit <https://docs.dify.ai/getting-started/readme> to view the full documentation.
Visit <https://docs.dify.ai> to view the full documentation.
## Community

View File

@ -5,7 +5,6 @@ import type { BlockEnum } from '@/app/components/workflow/types'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import type { App } from '@/types/app'
import type { I18nKeysByPrefix } from '@/types/i18n'
import * as React from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
@ -17,7 +16,6 @@ import { ToastContext } from '@/app/components/base/toast'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import { isTriggerNode } from '@/app/components/workflow/types'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useDocLink } from '@/context/i18n'
import {
fetchAppDetail,
updateAppSiteAccessToken,
@ -36,7 +34,6 @@ export type ICardViewProps = {
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
@ -59,25 +56,13 @@ const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const triggerDocUrl = docLink('/guides/workflow/node/start')
const buildTriggerModeMessage = useCallback((featureName: string) => (
<div className="flex flex-col gap-1">
<div className="text-xs text-text-secondary">
{t('overview.disableTooltip.triggerMode', { ns: 'appOverview', feature: featureName })}
</div>
<a
href={triggerDocUrl}
target="_blank"
rel="noopener noreferrer"
className="block cursor-pointer text-xs font-medium text-text-accent hover:underline"
onClick={(event) => {
event.stopPropagation()
}}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</a>
</div>
), [t, triggerDocUrl])
), [t])
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t('overview.appInfo.title', { ns: 'appOverview' }))

View File

@ -48,7 +48,7 @@ const CSVUploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return

View File

@ -1,12 +1,6 @@
import { render, screen } from '@testing-library/react'
import * as React from 'react'
import HistoryPanel from './history-panel'
const mockDocLink = vi.fn(() => 'doc-link')
vi.mock('@/context/i18n', () => ({
useDocLink: () => mockDocLink,
}))
vi.mock('@/app/components/app/configuration/base/operation-btn', () => ({
default: ({ onClick }: { onClick: () => void }) => (
<button type="button" data-testid="edit-button" onClick={onClick}>
@ -24,12 +18,10 @@ describe('HistoryPanel', () => {
vi.clearAllMocks()
})
it('should render warning content and link when showWarning is true', () => {
it('should render warning content when showWarning is true', () => {
render(<HistoryPanel showWarning onShowEditModal={vi.fn()} />)
expect(screen.getByText('appDebug.feature.conversationHistory.tip')).toBeInTheDocument()
const link = screen.getByText('appDebug.feature.conversationHistory.learnMore')
expect(link).toHaveAttribute('href', 'doc-link')
})
it('should hide warning when showWarning is false', () => {

View File

@ -5,7 +5,6 @@ import { useTranslation } from 'react-i18next'
import Panel from '@/app/components/app/configuration/base/feature-panel'
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
import { useDocLink } from '@/context/i18n'
type Props = {
showWarning: boolean
@ -17,8 +16,6 @@ const HistoryPanel: FC<Props> = ({
onShowEditModal,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
return (
<Panel
className="mt-2"
@ -45,14 +42,6 @@ const HistoryPanel: FC<Props> = ({
<div className="flex justify-between rounded-b-xl bg-background-section-burn px-3 py-2 text-xs text-text-secondary">
<div>
{t('feature.conversationHistory.tip', { ns: 'appDebug' })}
<a
href={docLink('/learn-more/extended-reading/what-is-llmops', { 'zh-Hans': '/learn-more/extended-reading/prompt-engineering/README' })}
target="_blank"
rel="noopener noreferrer"
className="text-[#155EEF]"
>
{t('feature.conversationHistory.learnMore', { ns: 'appDebug' })}
</a>
</div>
</div>
)}

View File

@ -271,9 +271,9 @@ const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVar
</div>
)}
{hasVar && (
<div className="mt-1 px-3 pb-3">
<div className={cn('mt-1 grid px-3 pb-3')}>
<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}
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
handle=".handle"

View File

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

View File

@ -40,7 +40,7 @@ type AgentToolWithMoreInfo = AgentTool & { icon: any, collection?: Collection }
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const { modelConfig, setModelConfig } = useContext(ConfigContext)
const { readonly, modelConfig, setModelConfig } = useContext(ConfigContext)
const { data: buildInTools } = useAllBuiltInTools()
const { data: customTools } = useAllCustomTools()
const { data: workflowTools } = useAllWorkflowTools()
@ -168,10 +168,10 @@ const AgentTools: FC = () => {
{tools.filter(item => !!item.enabled).length}
/
{tools.length}
&nbsp;
&nbsp;
{t('agent.tools.enabled', { ns: 'appDebug' })}
</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>
<ToolPicker
@ -189,7 +189,7 @@ const AgentTools: FC = () => {
</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) => (
<div
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="text-text-tertiary">{item.tool_label}</span>
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<Tooltip
popupContent={(
<div className="w-[180px]">
@ -259,7 +259,7 @@ const AgentTools: FC = () => {
</div>
</div>
)}
{!item.isDeleted && (
{!item.isDeleted && !readonly && (
<div className="mr-2 hidden items-center gap-1 group-hover:flex">
{!item.notAuthor && (
<Tooltip
@ -298,7 +298,7 @@ const AgentTools: FC = () => {
{!item.notAuthor && (
<Switch
defaultValue={item.isDeleted ? false : item.enabled}
disabled={item.isDeleted}
disabled={item.isDeleted || readonly}
size="md"
onChange={(enabled) => {
const newModelConfig = produce(modelConfig, (draft) => {
@ -312,6 +312,7 @@ const AgentTools: FC = () => {
{item.notAuthor && (
<Button
variant="secondary"
disabled={readonly}
size="small"
onClick={() => {
setCurrentTool(item)

View File

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

View File

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

View File

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

View File

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

View File

@ -30,6 +30,7 @@ const Item: FC<ItemProps> = ({
config,
onSave,
onRemove,
readonly = false,
editable = true,
}) => {
const media = useBreakpoints()
@ -56,6 +57,7 @@ const Item: FC<ItemProps> = ({
<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',
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">
@ -70,7 +72,7 @@ const Item: FC<ItemProps> = ({
</div>
<div className="ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex">
{
editable && (
editable && !readonly && (
<ActionButton
onClick={(e) => {
e.stopPropagation()
@ -81,14 +83,18 @@ const Item: FC<ItemProps> = ({
</ActionButton>
)
}
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
{
!readonly && (
<ActionButton
onClick={() => onRemove(config.id)}
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
onMouseEnter={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
</ActionButton>
)
}
</div>
{
!!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">
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
{showSettingsModal && (
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
)}
</Drawer>
</div>
)

View File

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

View File

@ -1,5 +1,6 @@
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { IndexingType } from '@/app/components/datasets/create/step-two'
@ -237,15 +238,15 @@ describe('RetrievalSection', () => {
retrievalConfig={retrievalConfig}
showMultiModalTip
onRetrievalConfigChange={vi.fn()}
docLink={docLink}
docLink={docLink as unknown as (path?: DocPathWithoutLang) => string}
/>,
)
// Assert
expect(screen.getByText('dataset.retrieval.semantic_search.title')).toBeInTheDocument()
const learnMoreLink = screen.getByRole('link', { name: 'datasetSettings.form.retrievalSetting.learnMore' })
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
expect(docLink).toHaveBeenCalledWith('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')
expect(learnMoreLink).toHaveAttribute('href', 'https://docs.example/use-dify/knowledge/create-knowledge/setting-indexing-methods')
expect(docLink).toHaveBeenCalledWith('/use-dify/knowledge/create-knowledge/setting-indexing-methods')
})
it('propagates retrieval config changes for economical indexing', async () => {
@ -263,7 +264,7 @@ describe('RetrievalSection', () => {
retrievalConfig={createRetrievalConfig()}
showMultiModalTip={false}
onRetrievalConfigChange={handleRetrievalChange}
docLink={path => path}
docLink={path => path || ''}
/>,
)
const [topKIncrement] = screen.getAllByLabelText('increment')

View File

@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { DataSet } from '@/models/datasets'
import type { RetrievalConfig } from '@/types/app'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { RiCloseLine } from '@remixicon/react'
import Divider from '@/app/components/base/divider'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
@ -84,7 +85,7 @@ type InternalRetrievalSectionProps = CommonSectionProps & {
retrievalConfig: RetrievalConfig
showMultiModalTip: boolean
onRetrievalConfigChange: (value: RetrievalConfig) => void
docLink: (path: string) => string
docLink: (path?: DocPathWithoutLang) => string
}
const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
@ -102,7 +103,7 @@ const InternalRetrievalSection: FC<InternalRetrievalSectionProps> = ({
<div>
<div className="system-sm-semibold text-text-secondary">{t('form.retrievalSetting.title', { ns: 'datasetSettings' })}</div>
<div className="text-xs font-normal leading-[18px] text-text-tertiary">
<a target="_blank" rel="noopener noreferrer" href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className="text-text-accent">{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}</a>
<a target="_blank" rel="noopener noreferrer" href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')} className="text-text-accent">{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}</a>
{t('form.retrievalSetting.description', { ns: 'datasetSettings' })}
</div>
</div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -240,7 +240,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.selector.title', { ns: 'common' })}
<a
href={docLink('/guides/extension/api-based-extension/README')}
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs font-normal text-text-tertiary hover:text-text-accent"

View File

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

View File

@ -1,9 +1,14 @@
'use client'
import type { App } from '@/models/explore'
import { PlusIcon } from '@heroicons/react/20/solid'
import { RiInformation2Line } from '@remixicon/react'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import AppIcon from '@/app/components/base/app-icon'
import Button from '@/app/components/base/button'
import AppListContext from '@/context/app-list-context'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { cn } from '@/utils/classnames'
import { AppTypeIcon, AppTypeLabel } from '../../type-selector'
@ -20,6 +25,14 @@ const AppCard = ({
}: AppCardProps) => {
const { t } = useTranslation()
const { app: appBasicInfo } = app
const { systemFeatures } = useGlobalPublicStore()
const isTrialApp = app.can_trial && systemFeatures.enable_trial_app
const setShowTryAppPanel = useContextSelector(AppListContext, ctx => ctx.setShowTryAppPanel)
const showTryAPPPanel = useCallback((appId: string) => {
return () => {
setShowTryAppPanel?.(true, { appId, app })
}
}, [setShowTryAppPanel, app.category])
return (
<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">
@ -51,11 +64,17 @@ const AppCard = ({
</div>
{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('flex h-8 w-full items-center space-x-2')}>
<Button variant="primary" className="grow" onClick={() => onCreate()}>
<div className={cn('grid h-8 w-full grid-cols-1 items-center space-x-2', isTrialApp && 'grid-cols-2')}>
<Button variant="primary" onClick={() => onCreate()}>
<PlusIcon className="mr-1 h-4 w-4" />
<span className="text-xs">{t('newApp.useTemplate', { ns: 'app' })}</span>
</Button>
{isTrialApp && (
<Button onClick={showTryAPPPanel(app.app_id)}>
<RiInformation2Line className="mr-1 size-4" />
<span>{t('appCard.try', { ns: 'explore' })}</span>
</Button>
)}
</div>
</div>
)}

View File

@ -5,7 +5,6 @@ import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLin
import { useDebounceFn, useKeyPress } from 'ahooks'
import Image from 'next/image'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
@ -22,7 +21,6 @@ import { ToastContext } from '@/app/components/base/toast'
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { useAppContext } from '@/context/app-context'
import { useDocLink } from '@/context/i18n'
import { useProviderContext } from '@/context/provider-context'
import useTheme from '@/hooks/use-theme'
import { createApp } from '@/service/apps'
@ -346,41 +344,26 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
function AppPreview({ mode }: { mode: AppModeEnum }) {
const { t } = useTranslation()
const docLink = useDocLink()
const modeToPreviewInfoMap = {
[AppModeEnum.CHAT]: {
title: t('types.chatbot', { ns: 'app' }),
description: t('newApp.chatbotUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/chatbot-application'),
},
[AppModeEnum.ADVANCED_CHAT]: {
title: t('types.advanced', { ns: 'app' }),
description: t('newApp.advancedUserDescription', { ns: 'app' }),
link: docLink('/guides/workflow/README', {
'zh-Hans': '/guides/workflow/readme',
'ja-JP': '/guides/workflow/concepts',
}),
},
[AppModeEnum.AGENT_CHAT]: {
title: t('types.agent', { ns: 'app' }),
description: t('newApp.agentUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/agent'),
},
[AppModeEnum.COMPLETION]: {
title: t('newApp.completeApp', { ns: 'app' }),
description: t('newApp.completionUserDescription', { ns: 'app' }),
link: docLink('/guides/application-orchestrate/text-generator', {
'zh-Hans': '/guides/application-orchestrate/readme',
'ja-JP': '/guides/application-orchestrate/README',
}),
},
[AppModeEnum.WORKFLOW]: {
title: t('types.workflow', { ns: 'app' }),
description: t('newApp.workflowUserDescription', { ns: 'app' }),
link: docLink('/guides/workflow/README', {
'zh-Hans': '/guides/workflow/readme',
'ja-JP': '/guides/workflow/concepts',
}),
},
}
const previewInfo = modeToPreviewInfoMap[mode]
@ -389,7 +372,6 @@ function AppPreview({ mode }: { mode: AppModeEnum }) {
<h4 className="system-sm-semibold-uppercase text-text-secondary">{previewInfo.title}</h4>
<div className="system-xs-regular mt-1 min-h-8 max-w-96 text-text-tertiary">
<span>{previewInfo.description}</span>
{previewInfo.link && <Link target="_blank" href={previewInfo.link} className="ml-1 text-text-accent">{t('newApp.learnMore', { ns: 'app' })}</Link>}
</div>
</div>
)

View File

@ -58,7 +58,7 @@ const Uploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return

View File

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

View File

@ -245,7 +245,7 @@ function AppCard({
</div>
<div
className="cursor-pointer text-xs font-normal text-text-accent hover:underline"
onClick={() => window.open(docLink('/guides/workflow/node/user-input'), '_blank')}
onClick={() => window.open(docLink('/use-dify/nodes/user-input'), '_blank')}
>
{t('overview.appInfo.enableTooltip.learnMore', { ns: 'appOverview' })}
</div>

View File

@ -305,7 +305,7 @@ describe('CustomizeModal', () => {
// Assert
expect(mockWindowOpen).toHaveBeenCalledTimes(1)
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining('/guides/application-publishing/developing-with-apis'),
expect.stringContaining('/use-dify/publish/developing-with-apis'),
'_blank',
)
})

View File

@ -118,7 +118,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
className="mt-2"
onClick={() =>
window.open(
docLink('/guides/application-publishing/developing-with-apis'),
docLink('/use-dify/publish/developing-with-apis'),
'_blank',
)}
>

View File

@ -23,7 +23,6 @@ import Textarea from '@/app/components/base/textarea'
import { useToastContext } from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
import { useDocLink } from '@/context/i18n'
import { useModalContext } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { languages } from '@/i18n-config/language'
@ -100,7 +99,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
const [language, setLanguage] = useState(default_language)
const [saveLoading, setSaveLoading] = useState(false)
const { t } = useTranslation()
const docLink = useDocLink()
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
const [appIcon, setAppIcon] = useState<AppIconSelection>(
@ -240,16 +238,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
</div>
<div className="system-xs-regular mt-0.5 text-text-tertiary">
<span>{t(`${prefixSettings}.modalTip`, { ns: 'appOverview' })}</span>
<Link
href={docLink('/guides/application-publishing/launch-your-webapp-quickly/README', {
'zh-Hans': '/guides/application-publishing/launch-your-webapp-quickly/readme',
})}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent"
>
{t('operation.learnMore', { ns: 'common' })}
</Link>
</div>
</div>
{/* form body */}

View File

@ -208,7 +208,7 @@ function TriggerCard({ appInfo, onToggleResult }: ITriggerCardProps) {
{t('overview.triggerInfo.triggerStatusDescription', { ns: 'appOverview' })}
{' '}
<Link
href={docLink('/guides/workflow/node/trigger')}
href={docLink('/use-dify/nodes/trigger/overview')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent hover:underline"

View File

@ -31,7 +31,7 @@ import { Markdown } from '@/app/components/base/markdown'
import NewAudioButton from '@/app/components/base/new-audio-button'
import Toast from '@/app/components/base/toast'
import { fetchTextGenerationMessage } from '@/service/debug'
import { fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
import { AppSourceType, fetchMoreLikeThis, submitHumanInputForm, updateFeedback } from '@/service/share'
import { submitHumanInputForm as submitHumanInputFormService } from '@/service/workflow'
import { cn } from '@/utils/classnames'
import ResultTab from './result-tab'
@ -56,7 +56,7 @@ export type IGenerationItemProps = {
onFeedback?: (feedback: FeedbackType) => void
onSave?: (messageId: string) => void
isMobile?: boolean
isInstalledApp: boolean
appSourceType: AppSourceType
installedAppId?: string
taskId?: string
controlClearMoreLikeThis?: number
@ -90,7 +90,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave,
depth = 1,
isMobile,
isInstalledApp,
appSourceType,
installedAppId,
taskId,
controlClearMoreLikeThis,
@ -103,6 +103,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const { t } = useTranslation()
const params = useParams()
const isTop = depth === 1
const isTryApp = appSourceType === AppSourceType.tryApp
const [completionRes, setCompletionRes] = useState('')
const [childMessageId, setChildMessageId] = useState<string | null>(null)
const [childFeedback, setChildFeedback] = useState<FeedbackType>({
@ -116,7 +117,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
const setShowPromptLogModal = useAppStore(s => s.setShowPromptLogModal)
const handleFeedback = async (childFeedback: FeedbackType) => {
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, isInstalledApp, installedAppId)
await updateFeedback({ url: `/messages/${childMessageId}/feedbacks`, body: { rating: childFeedback.rating } }, appSourceType, installedAppId)
setChildFeedback(childFeedback)
}
@ -134,7 +135,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
onSave,
isShowTextToSpeech,
isMobile,
isInstalledApp,
appSourceType,
installedAppId,
controlClearMoreLikeThis,
isWorkflow,
@ -148,7 +149,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
return
}
startQuerying()
const res: any = await fetchMoreLikeThis(messageId as string, isInstalledApp, installedAppId)
const res: any = await fetchMoreLikeThis(messageId as string, appSourceType, installedAppId)
setCompletionRes(res.answer)
setChildFeedback({
rating: null,
@ -336,7 +337,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
)}
{/* action buttons */}
<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">
<ActionButton disabled={isError || !messageId} onClick={handleOpenLogModal}>
<RiFileList3Line className="h-4 w-4" />
@ -345,12 +346,12 @@ const GenerationItem: FC<IGenerationItemProps> = ({
</div>
)}
<div className="ml-1 flex items-center gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm">
{moreLikeThis && (
{moreLikeThis && !isTryApp && (
<ActionButton state={depth === MAX_DEPTH ? ActionButtonState.Disabled : ActionButtonState.Default} disabled={depth === MAX_DEPTH} onClick={handleMoreLikeThis}>
<RiSparklingLine className="h-4 w-4" />
</ActionButton>
)}
{isShowTextToSpeech && (
{isShowTextToSpeech && !isTryApp && (
<NewAudioButton
id={messageId!}
voice={config?.text_to_speech?.voice}
@ -376,13 +377,13 @@ const GenerationItem: FC<IGenerationItemProps> = ({
<RiResetLeftLine className="h-4 w-4" />
</ActionButton>
)}
{isInWebApp && !isWorkflow && (
{isInWebApp && !isWorkflow && !isTryApp && (
<ActionButton disabled={isError || !messageId} onClick={() => { onSave?.(messageId as string) }}>
<RiBookmark3Line className="h-4 w-4" />
</ActionButton>
)}
</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">
{!feedback?.rating && (
<>

View File

@ -36,7 +36,7 @@ export const useDSLDragDrop = ({ onDSLFileDropped, containerRef, enabled = true
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length === 0)
return

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

View File

@ -1,7 +1,17 @@
'use client'
import type { CreateAppModalProps } from '../explore/create-app-modal'
import type { CurrentTryAppParams } from '@/context/explore-context'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useEducationInit } from '@/app/education-apply/hooks'
import AppListContext from '@/context/app-list-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { useImportDSL } from '@/hooks/use-import-dsl'
import { DSLImportMode } from '@/models/app'
import { fetchAppDetail } from '@/service/explore'
import DSLConfirmModal from '../app/create-from-dsl-modal/dsl-confirm-modal'
import CreateAppModal from '../explore/create-app-modal'
import TryApp from '../explore/try-app'
import List from './list'
const Apps = () => {
@ -10,10 +20,124 @@ const Apps = () => {
useDocumentTitle(t('menus.apps', { ns: 'common' }))
useEducationInit()
const [currentTryAppParams, setCurrentTryAppParams] = useState<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 (
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body">
<List />
</div>
<AppListContext.Provider value={{
currentApp: currentTryAppParams,
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'
import type { FC } from 'react'
import {
RiApps2Line,
RiDragDropLine,
@ -53,7 +54,12 @@ const CreateFromDSLModal = dynamic(() => import('@/app/components/app/create-fro
ssr: false,
})
const List = () => {
type Props = {
controlRefreshList?: number
}
const List: FC<Props> = ({
controlRefreshList = 0,
}) => {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const router = useRouter()
@ -110,6 +116,13 @@ const List = () => {
refetch,
} = useInfiniteAppList(appListQueryParams, { enabled: !isCurrentWorkspaceDatasetOperator })
useEffect(() => {
if (controlRefreshList > 0) {
refetch()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlRefreshList])
const anchorRef = useRef<HTMLDivElement>(null)
const options = [
{ 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,
} from 'next/navigation'
import * as React from 'react'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContextSelector } from 'use-context-selector'
import { CreateFromDSLModalTab } from '@/app/components/app/create-from-dsl-modal'
import { FileArrow01, FilePlus01, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
import AppListContext from '@/context/app-list-context'
import { useProviderContext } from '@/context/provider-context'
import { cn } from '@/utils/classnames'
@ -55,6 +57,13 @@ const CreateAppCard = ({
return undefined
}, [dslUrl])
const controlHideCreateFromTemplatePanel = useContextSelector(AppListContext, ctx => ctx.controlHideCreateFromTemplatePanel)
useEffect(() => {
if (controlHideCreateFromTemplatePanel > 0)
// eslint-disable-next-line react-hooks-extra/no-direct-set-state-in-use-effect
setShowNewAppTemplateDialog(false)
}, [controlHideCreateFromTemplatePanel])
return (
<div
ref={ref}

View File

@ -52,11 +52,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 (
<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}
style={styleCss}
{...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 { textToAudioStream } from '@/service/share'
import { AppSourceType, textToAudioStream } from '@/service/share'
declare global {
// eslint-disable-next-line ts/consistent-type-definitions
@ -100,7 +100,7 @@ export default class AudioPlayer {
private async loadAudio() {
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,
streaming: true,
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

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

View File

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

View File

@ -29,6 +29,7 @@ import { useWebAppStore } from '@/context/web-app-context'
import { useAppFavicon } from '@/hooks/use-app-favicon'
import { changeLanguage } from '@/i18n-config/client'
import {
AppSourceType,
delConversation,
pinConversation,
renameConversation,
@ -95,6 +96,7 @@ function getFormattedChatList(messages: any[]) {
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
const appSourceType = isInstalledApp ? AppSourceType.installedApp : AppSourceType.webApp
const appInfo = useWebAppStore(s => s.appInfo)
const appParams = useWebAppStore(s => s.appParams)
const appMeta = useWebAppStore(s => s.appMeta)
@ -200,7 +202,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [currentConversationId, newConversationId])
const { data: appPinnedConversationData } = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: true,
limit: 100,
@ -213,7 +215,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
data: appConversationData,
isLoading: appConversationDataLoading,
} = useShareConversations({
isInstalledApp,
appSourceType,
appId,
pinned: false,
limit: 100,
@ -227,7 +229,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
isLoading: appChatListDataLoading,
} = useShareChatList({
conversationId: chatShouldReloadKey,
isInstalledApp,
appSourceType,
appId,
}, {
enabled: !!chatShouldReloadKey,
@ -357,10 +359,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
const { data: newConversation } = useShareConversationName({
conversationId: newConversationId,
isInstalledApp,
appSourceType,
appId,
}, {
refetchOnWindowFocus: false,
enabled: !!newConversationId,
})
const [originConversationList, setOriginConversationList] = useState<ConversationItem[]>([])
useEffect(() => {
@ -485,16 +488,16 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [invalidateShareConversations])
const handlePinConversation = useCallback(async (conversationId: string) => {
await pinConversation(isInstalledApp, appId, conversationId)
await pinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
const handleUnpinConversation = useCallback(async (conversationId: string) => {
await unpinConversation(isInstalledApp, appId, conversationId)
await unpinConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
handleUpdateConversationList()
}, [isInstalledApp, appId, notify, t, handleUpdateConversationList])
}, [appSourceType, appId, notify, t, handleUpdateConversationList])
const [conversationDeleting, setConversationDeleting] = useState(false)
const handleDeleteConversation = useCallback(async (
@ -508,7 +511,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
try {
setConversationDeleting(true)
await delConversation(isInstalledApp, appId, conversationId)
await delConversation(appSourceType, appId, conversationId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
onSuccess()
}
@ -543,7 +546,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
setConversationRenaming(true)
try {
await renameConversation(isInstalledApp, appId, conversationId, newName)
await renameConversation(appSourceType, appId, conversationId, newName)
notify({
type: 'success',
@ -573,9 +576,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
}, [handleConversationIdInfoChange, invalidateShareConversations])
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, appSourceType, appId)
notify({ type: 'success', message: t('api.success', { ns: 'common' }) })
}, [isInstalledApp, appId, t, notify])
}, [appSourceType, appId, t, notify])
return {
isInstalledApp,

View File

@ -345,7 +345,7 @@ const Answer: FC<AnswerProps> = ({
data={workflowProcess}
item={item}
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 { ChatItem } from '../../types'
import { memo } from 'react'
import { cn } from '@/utils/classnames'
import { useChatContext } from '../context'
type SuggestedQuestionsProps = {
@ -9,7 +10,7 @@ type SuggestedQuestionsProps = {
const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
item,
}) => {
const { onSend } = useChatContext()
const { onSend, readonly } = useChatContext()
const {
isOpeningStatement,
@ -24,8 +25,11 @@ const SuggestedQuestions: FC<SuggestedQuestionsProps> = ({
{suggestedQuestions.filter(q => !!q && q.trim()).map((question, index) => (
<div
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"
onClick={() => onSend?.(question)}
className={cn(
'system-sm-medium mr-1 mt-1 inline-flex max-w-full shrink-0 cursor-pointer flex-wrap rounded-lg border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-3.5 py-2 text-components-button-secondary-accent-text shadow-xs last:mr-0 hover:border-components-button-secondary-border-hover hover:bg-components-button-secondary-bg-hover',
readonly && 'pointer-events-none opacity-50',
)}
onClick={() => !readonly && onSend?.(question)}
>
{question}
</div>

View File

@ -5,6 +5,7 @@ import type {
} from '../../types'
import type { InputForm } from '../type'
import type { FileUpload } from '@/app/components/base/features/types'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import {
@ -30,6 +31,7 @@ import { useTextAreaHeight } from './hooks'
import Operation from './operation'
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
showFeatureBar?: boolean
showFileUpload?: boolean
@ -45,6 +47,7 @@ type ChatInputAreaProps = {
disabled?: boolean
}
const ChatInputArea = ({
readonly,
botName,
showFeatureBar,
showFileUpload,
@ -170,6 +173,7 @@ const ChatInputArea = ({
const operation = (
<Operation
ref={holdSpaceRef}
readonly={readonly}
fileConfig={visionConfig}
speechToTextConfig={speechToTextConfig}
onShowVoiceInput={handleShowVoiceInput}
@ -205,7 +209,7 @@ const ChatInputArea = ({
className={cn(
'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
minRows={1}
value={query}
@ -218,6 +222,7 @@ const ChatInputArea = ({
onDragLeave={handleDragFileLeave}
onDragOver={handleDragFileOver}
onDrop={handleDropFile}
readOnly={readonly}
/>
</div>
{
@ -239,7 +244,14 @@ const ChatInputArea = ({
)
}
</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,
RiSendPlane2Fill,
} from '@remixicon/react'
import { noop } from 'es-toolkit/function'
import { memo } from 'react'
import ActionButton from '@/app/components/base/action-button'
import Button from '@/app/components/base/button'
@ -15,6 +16,7 @@ import { FileUploaderInChatInput } from '@/app/components/base/file-uploader'
import { cn } from '@/utils/classnames'
type OperationProps = {
readonly?: boolean
fileConfig?: FileUpload
speechToTextConfig?: EnableType
onShowVoiceInput?: () => void
@ -23,6 +25,7 @@ type OperationProps = {
ref?: Ref<HTMLDivElement>
}
const Operation: FC<OperationProps> = ({
readonly,
ref,
fileConfig,
speechToTextConfig,
@ -41,11 +44,12 @@ const Operation: FC<OperationProps> = ({
ref={ref}
>
<div className="flex items-center space-x-1">
{fileConfig?.enabled && <FileUploaderInChatInput fileConfig={fileConfig} />}
{fileConfig?.enabled && <FileUploaderInChatInput readonly={readonly} fileConfig={fileConfig} />}
{
speechToTextConfig?.enabled && (
<ActionButton
size="l"
disabled={readonly}
onClick={onShowVoiceInput}
>
<RiMicLine className="h-5 w-5" />
@ -56,7 +60,7 @@ const Operation: FC<OperationProps> = ({
<Button
className="ml-3 w-8 px-0"
variant="primary"
onClick={onSend}
onClick={readonly ? noop : onSend}
style={
theme
? {

View File

@ -15,11 +15,15 @@ export type ChatContextValue = Pick<ChatProps, 'config'
| 'onAnnotationEdited'
| 'onAnnotationAdded'
| 'onAnnotationRemoved'
| 'disableFeedback'
| 'onFeedback'
| 'getHumanInputNodeData'>
| 'getHumanInputNodeData'> & {
readonly?: boolean
}
const ChatContext = createContext<ChatContextValue>({
chatList: [],
readonly: false,
})
type ChatContextProviderProps = {
@ -28,6 +32,7 @@ type ChatContextProviderProps = {
export const ChatContextProvider = ({
children,
readonly = false,
config,
isResponding,
chatList,
@ -39,12 +44,14 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}: ChatContextProviderProps) => {
return (
<ChatContext.Provider value={{
config,
readonly,
isResponding,
chatList: chatList || [],
showPromptLog,
@ -55,6 +62,7 @@ export const ChatContextProvider = ({
onAnnotationEdited,
onAnnotationAdded,
onAnnotationRemoved,
disableFeedback,
onFeedback,
getHumanInputNodeData,
}}

View File

@ -36,6 +36,8 @@ import Question from './question'
import TryToAsk from './try-to-ask'
export type ChatProps = {
isTryApp?: boolean
readonly?: boolean
appData?: AppData
chatList: ChatItem[]
config?: ChatConfig
@ -60,6 +62,7 @@ export type ChatProps = {
onAnnotationAdded?: (annotationId: string, authorName: string, question: string, answer: string, index: number) => void
onAnnotationRemoved?: (index: number) => void
chatNode?: ReactNode
disableFeedback?: boolean
onFeedback?: (messageId: string, feedback: Feedback) => void
chatAnswerContainerInner?: string
hideProcessDetail?: boolean
@ -78,6 +81,8 @@ export type ChatProps = {
}
const Chat: FC<ChatProps> = ({
isTryApp,
readonly = false,
appData,
config,
onSend,
@ -101,6 +106,7 @@ const Chat: FC<ChatProps> = ({
onAnnotationEdited,
onAnnotationRemoved,
chatNode,
disableFeedback,
onFeedback,
chatAnswerContainerInner,
hideProcessDetail,
@ -251,6 +257,7 @@ const Chat: FC<ChatProps> = ({
return (
<ChatContextProvider
readonly={readonly}
config={config}
chatList={chatList}
isResponding={isResponding}
@ -262,18 +269,19 @@ const Chat: FC<ChatProps> = ({
onAnnotationAdded={onAnnotationAdded}
onAnnotationEdited={onAnnotationEdited}
onAnnotationRemoved={onAnnotationRemoved}
disableFeedback={disableFeedback}
onFeedback={onFeedback}
getHumanInputNodeData={getHumanInputNodeData}
>
<div className="relative h-full">
<div className={cn('relative h-full', isTryApp && 'flex flex-col')}>
<div
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}
<div
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) => {
@ -320,7 +328,7 @@ const Chat: FC<ChatProps> = ({
>
<div
ref={chatFooterInnerRef}
className={cn('relative', chatFooterInnerClassName)}
className={cn('relative', chatFooterInnerClassName, isTryApp && 'px-0')}
>
{
!noStopResponding && isResponding && (
@ -343,7 +351,7 @@ const Chat: FC<ChatProps> = ({
{
!noChatInput && (
<ChatInputArea
botName={appData?.site.title || 'Bot'}
botName={appData?.site?.title || 'Bot'}
disabled={inputDisabled}
showFeatureBar={showFeatureBar}
showFileUpload={showFileUpload}
@ -356,6 +364,7 @@ const Chat: FC<ChatProps> = ({
inputsForm={inputsForm}
theme={themeBuilder?.theme}
isResponding={isResponding}
readonly={readonly}
/>
)
}

View File

@ -14,6 +14,7 @@ import LogoAvatar from '@/app/components/base/logo/logo-embedded-chat-avatar'
import { Markdown } from '@/app/components/base/markdown'
import { InputVarType } from '@/app/components/workflow/types'
import {
AppSourceType,
fetchSuggestedQuestions,
getUrl,
stopChatMessageResponding,
@ -45,6 +46,7 @@ const ChatWrapper = () => {
isInstalledApp,
appId,
appMeta,
disableFeedback,
handleFeedback,
currentChatInstanceRef,
themeBuilder,
@ -53,7 +55,9 @@ const ChatWrapper = () => {
setIsResponding,
allInputsHidden,
initUserVariables,
appSourceType,
} = useEmbeddedChatbotContext()
const appConfig = useMemo(() => {
const config = appParams || {}
@ -81,7 +85,7 @@ const ChatWrapper = () => {
inputsForm: inputsForms,
},
appPrevChatList,
taskId => stopChatMessageResponding('', taskId, isInstalledApp, appId),
taskId => stopChatMessageResponding('', taskId, appSourceType, appId),
clearChatList,
setClearChatList,
)
@ -155,9 +159,9 @@ const ChatWrapper = () => {
handleSwitchSibling(
lastPausedNode.id,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}
@ -171,17 +175,16 @@ const ChatWrapper = () => {
conversation_id: currentConversationId,
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
}
handleSend(
getUrl('chat-messages', isInstalledApp, appId || ''),
getUrl('chat-messages', appSourceType, appId || ''),
data,
{
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
isPublicAPI: appSourceType === AppSourceType.webApp,
},
)
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, isInstalledApp, appId, handleNewConversationCompleted])
}, [currentConversationId, currentConversationInputs, newConversationInputs, chatList, handleSend, appSourceType, appId, handleNewConversationCompleted])
const doRegenerate = useCallback((chatItem: ChatItem, editedQuestion?: { message: string, files?: FileEntity[] }) => {
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
@ -191,11 +194,11 @@ const ChatWrapper = () => {
const doSwitchSibling = useCallback((siblingMessageId: string) => {
handleSwitchSibling(siblingMessageId, {
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, appSourceType, appId),
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
isPublicAPI: !isInstalledApp,
isPublicAPI: appSourceType === AppSourceType.webApp,
})
}, [handleSwitchSibling, isInstalledApp, appId, currentConversationId, handleNewConversationCompleted])
}, [handleSwitchSibling, appSourceType, appId, currentConversationId, handleNewConversationCompleted])
const messageList = useMemo(() => {
if (currentConversationId || chatList.length > 1)
@ -204,7 +207,8 @@ const ChatWrapper = () => {
return chatList.filter(item => !item.isOpeningStatement)
}, [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(() => {
if (allInputsHidden || !inputsForms.length)
@ -236,6 +240,8 @@ const ChatWrapper = () => {
return null
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
return null
if (!appData?.site)
return null
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
return (
<div className={cn('flex items-center justify-center px-4 py-12', isMobile ? 'min-h-[30vh] py-0' : 'h-[50vh]')}>
@ -269,7 +275,7 @@ const ChatWrapper = () => {
</div>
</div>
)
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, isMobile, appData?.site.icon_type, appData?.site.icon, appData?.site.icon_background, appData?.site.icon_url])
}, [chatList, respondingState, currentConversationId, collapsed, inputsForms.length, allInputsHidden, appData?.site, isMobile])
const answerIcon = isDify()
? <LogoAvatar className="relative shrink-0" />
@ -286,6 +292,7 @@ const ChatWrapper = () => {
return (
<Chat
isTryApp={isTryApp}
appData={appData || undefined}
config={appConfig}
chatList={messageList}
@ -306,6 +313,7 @@ const ChatWrapper = () => {
</>
)}
allToolIcons={appMeta?.tool_icons || {}}
disableFeedback={disableFeedback}
onFeedback={handleFeedback}
suggestedQuestions={suggestedQuestions}
answerIcon={answerIcon}

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
'use client'
import type { AppData } from '@/models/share'
import {
useEffect,
} 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 useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import useDocumentTitle from '@/hooks/use-document-title'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import {
EmbeddedChatbotContext,
@ -132,11 +134,12 @@ const EmbeddedChatbotWrapper = () => {
setCurrentConversationInputs,
allInputsHidden,
initUserVariables,
} = useEmbeddedChatbot()
} = useEmbeddedChatbot(AppSourceType.webApp)
return (
<EmbeddedChatbotContext.Provider value={{
appData,
appSourceType: AppSourceType.webApp,
appData: (appData as AppData) || null,
appParams,
appMeta,
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 Divider from '@/app/components/base/divider'
import { Message3Fill } from '@/app/components/base/icons/src/public/other'
import { AppSourceType } from '@/service/share'
import { cn } from '@/utils/classnames'
import { useEmbeddedChatbotContext } from '../context'
@ -18,6 +19,7 @@ const InputsFormNode = ({
}: Props) => {
const { t } = useTranslation()
const {
appSourceType,
isMobile,
currentConversationId,
themeBuilder,
@ -25,15 +27,17 @@ const InputsFormNode = ({
allInputsHidden,
inputsForms,
} = useEmbeddedChatbotContext()
const isTryApp = appSourceType === AppSourceType.tryApp
if (allInputsHidden || inputsForms.length === 0)
return null
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(
'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',
isTryApp && 'max-w-[auto]',
)}
>
<div className={cn(

View File

@ -33,7 +33,7 @@ const ViewFormDropdown = ({ iconColor }: Props) => {
<RiChatSettingsLine className={cn('h-[18px] w-[18px]', iconColor)} />
</ActionButton>
</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="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" />

View File

@ -3,10 +3,8 @@ import {
RiClipboardFill,
RiClipboardLine,
} from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import ActionButton from '@/app/components/base/action-button'
import Tooltip from '@/app/components/base/tooltip'
@ -21,32 +19,27 @@ const prefixEmbedded = 'overview.appInfo.embedded'
const CopyFeedback = ({ content }: Props) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<ActionButton>
<div
onClick={onClickCopy}
onMouseLeave={onMouseLeave}
onClick={handleCopy}
onMouseLeave={reset}
>
{isCopied && <RiClipboardFill className="h-4 w-4" />}
{!isCopied && <RiClipboardLine className="h-4 w-4" />}
{copied && <RiClipboardFill className="h-4 w-4" />}
{!copied && <RiClipboardLine className="h-4 w-4" />}
</div>
</ActionButton>
</Tooltip>
@ -57,21 +50,16 @@ export default CopyFeedback
export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className' | 'content'>) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
@ -81,9 +69,9 @@ export const CopyFeedbackNew = ({ content, className }: Pick<Props, 'className'
}`}
>
<div
onClick={onClickCopy}
onMouseLeave={onMouseLeave}
className={`h-full w-full ${copyStyle.copyIcon} ${isCopied ? copyStyle.copied : ''
onClick={handleCopy}
onMouseLeave={reset}
className={`h-full w-full ${copyStyle.copyIcon} ${copied ? copyStyle.copied : ''
}`}
>
</div>

View File

@ -1,8 +1,6 @@
'use client'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import * as React from 'react'
import { useState } from 'react'
import { useClipboard } from 'foxact/use-clipboard'
import { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import {
Copy,
@ -18,29 +16,24 @@ const prefixEmbedded = 'overview.appInfo.embedded'
const CopyIcon = ({ content }: Props) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
const { copied, copy, reset } = useClipboard()
const onClickCopy = debounce(() => {
const handleCopy = useCallback(() => {
copy(content)
setIsCopied(true)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
}, [copy, content])
return (
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<div onMouseLeave={onMouseLeave}>
{!isCopied
<div onMouseLeave={reset}>
{!copied
? (
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={onClickCopy} />
<Copy className="mx-1 h-3.5 w-3.5 cursor-pointer text-text-tertiary" onClick={handleCopy} />
)
: (
<CopyCheck className="mx-1 h-3.5 w-3.5 text-text-tertiary" />

View File

@ -14,6 +14,7 @@ type Props = {
showFileUpload?: boolean
disabled?: boolean
onFeatureBarClick?: (state: boolean) => void
hideEditEntrance?: boolean
}
const FeatureBar = ({
@ -21,6 +22,7 @@ const FeatureBar = ({
showFileUpload = true,
disabled,
onFeatureBarClick,
hideEditEntrance = false,
}: Props) => {
const { t } = useTranslation()
const features = useFeatures(s => s.features)
@ -133,10 +135,14 @@ const FeatureBar = ({
)}
</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>
<RiArrowRightLine className="h-3.5 w-3.5 text-text-accent" />
</Button>
{
!hideEditEntrance && (
<Button className="shrink-0" variant="ghost-accent" size="small" onClick={() => onFeatureBarClick?.(true)}>
<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>

View File

@ -2,7 +2,6 @@ import type { OnFeaturesChange } from '@/app/components/base/features/types'
import type { InputVar } from '@/app/components/workflow/types'
import type { PromptVariable } from '@/models/debug'
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
@ -18,7 +17,6 @@ import SpeechToText from '@/app/components/base/features/new-feature-panel/speec
import TextToSpeech from '@/app/components/base/features/new-feature-panel/text-to-speech'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useDocLink } from '@/context/i18n'
type Props = {
show: boolean
@ -46,7 +44,6 @@ const NewFeaturePanel = ({
onAutoAddPromptVariable,
}: Props) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
@ -76,14 +73,6 @@ const NewFeaturePanel = ({
</div>
<div className="system-xs-medium p-1 text-text-primary">
<span>{isChatMode ? t('common.fileUploadTip', { ns: 'workflow' }) : t('common.ImageUploadLegacyTip', { ns: 'workflow' })}</span>
<a
className="text-text-accent"
href={docLink('/guides/workflow/bulletin')}
target="_blank"
rel="noopener noreferrer"
>
{t('common.featuresDocLink', { ns: 'workflow' })}
</a>
</div>
</div>
</div>

View File

@ -319,7 +319,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
<div className="flex h-9 items-center justify-between">
<div className="text-sm font-medium text-text-primary">{t('apiBasedExtension.selector.title', { ns: 'common' })}</div>
<a
href={docLink('/guides/extension/api-based-extension/README')}
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
className="group flex items-center text-xs text-text-tertiary hover:text-primary-600"

View File

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

View File

@ -1,4 +1,3 @@
import type { UnsafeUnwrappedHeaders } from 'next/headers'
import type { FC } from 'react'
import { headers } from 'next/headers'
import Script from 'next/script'
@ -26,14 +25,14 @@ const extractNonceFromCSP = (cspHeader: string | null): string | undefined => {
return nonceMatch ? nonceMatch[1] : undefined
}
const GA: FC<IGAProps> = ({
const GA: FC<IGAProps> = async ({
gaType,
}) => {
if (IS_CE_EDITION)
return null
const cspHeader = IS_PROD
? (headers() as unknown as UnsafeUnwrappedHeaders).get('content-security-policy')
? (await headers()).get('content-security-policy')
: null
const nonce = extractNonceFromCSP(cspHeader)

View File

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

View File

@ -3,13 +3,8 @@ import * as React from 'react'
import { createReactI18nextMock } from '@/test/i18n-mock'
import InputWithCopy from './index'
// Create a mock function that we can track using vi.hoisted
const mockCopyToClipboard = vi.hoisted(() => vi.fn(() => true))
// Mock the copy-to-clipboard library
vi.mock('copy-to-clipboard', () => ({
default: mockCopyToClipboard,
}))
// Mock navigator.clipboard for foxact/use-clipboard
const mockWriteText = vi.fn(() => Promise.resolve())
// Mock the i18n hook with custom translations for test assertions
vi.mock('react-i18next', () => createReactI18nextMock({
@ -19,15 +14,16 @@ vi.mock('react-i18next', () => createReactI18nextMock({
'overview.appInfo.embedded.copied': 'Copied',
}))
// Mock es-toolkit/compat debounce
vi.mock('es-toolkit/compat', () => ({
debounce: (fn: any) => fn,
}))
describe('InputWithCopy component', () => {
beforeEach(() => {
vi.clearAllMocks()
mockCopyToClipboard.mockClear()
mockWriteText.mockClear()
// Setup navigator.clipboard mock
Object.assign(navigator, {
clipboard: {
writeText: mockWriteText,
},
})
})
it('renders correctly with default props', () => {
@ -55,7 +51,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('test value')
})
})
it('copies custom value when copyValue prop is provided', async () => {
@ -65,7 +63,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('custom copy value')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('custom copy value')
})
})
it('calls onCopy callback when copy button is clicked', async () => {
@ -76,7 +76,9 @@ describe('InputWithCopy component', () => {
const copyButton = screen.getByRole('button')
fireEvent.click(copyButton)
expect(onCopyMock).toHaveBeenCalledWith('test value')
await waitFor(() => {
expect(onCopyMock).toHaveBeenCalledWith('test value')
})
})
it('shows copied state after successful copy', async () => {
@ -115,17 +117,19 @@ describe('InputWithCopy component', () => {
expect(input).toHaveClass('custom-class')
})
it('handles empty value correctly', () => {
it('handles empty value correctly', async () => {
const mockOnChange = vi.fn()
render(<InputWithCopy value="" onChange={mockOnChange} />)
const input = screen.getByRole('textbox')
const input = screen.getByDisplayValue('')
const copyButton = screen.getByRole('button')
expect(input).toBeInTheDocument()
expect(copyButton).toBeInTheDocument()
fireEvent.click(copyButton)
expect(mockCopyToClipboard).toHaveBeenCalledWith('')
await waitFor(() => {
expect(mockWriteText).toHaveBeenCalledWith('')
})
})
it('maintains focus on input after copy', async () => {

View File

@ -1,10 +1,8 @@
'use client'
import type { InputProps } from '../input'
import { RiClipboardFill, RiClipboardLine } from '@remixicon/react'
import copy from 'copy-to-clipboard'
import { debounce } from 'es-toolkit/compat'
import { useClipboard } from 'foxact/use-clipboard'
import * as React from 'react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/utils/classnames'
import ActionButton from '../action-button'
@ -30,31 +28,16 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
ref,
) => {
const { t } = useTranslation()
const [isCopied, setIsCopied] = useState<boolean>(false)
// Determine what value to copy
const valueToString = typeof value === 'string' ? value : String(value || '')
const finalCopyValue = copyValue || valueToString
const onClickCopy = debounce(() => {
const { copied, copy, reset } = useClipboard()
const handleCopy = () => {
copy(finalCopyValue)
setIsCopied(true)
onCopy?.(finalCopyValue)
}, 100)
const onMouseLeave = debounce(() => {
setIsCopied(false)
}, 100)
useEffect(() => {
if (isCopied) {
const timeout = setTimeout(() => {
setIsCopied(false)
}, 2000)
return () => {
clearTimeout(timeout)
}
}
}, [isCopied])
}
return (
<div className={cn('relative w-full', wrapperClassName)}>
@ -73,21 +56,21 @@ const InputWithCopy = React.forwardRef<HTMLInputElement, InputWithCopyProps>((
{showCopyButton && (
<div
className="absolute right-2 top-1/2 -translate-y-1/2"
onMouseLeave={onMouseLeave}
onMouseLeave={reset}
>
<Tooltip
popupContent={
(isCopied
(copied
? t(`${prefixEmbedded}.copied`, { ns: 'appOverview' })
: t(`${prefixEmbedded}.copy`, { ns: 'appOverview' })) || ''
}
>
<ActionButton
size="xs"
onClick={onClickCopy}
onClick={handleCopy}
className="hover:bg-components-button-ghost-bg-hover"
>
{isCopied
{copied
? (
<RiClipboardFill className="h-3.5 w-3.5 text-text-tertiary" />
)

View File

@ -0,0 +1,109 @@
import type { PropsWithChildren, ReactNode } from 'react'
import { render, screen } from '@testing-library/react'
import { ReactMarkdownWrapper } from './react-markdown-wrapper'
vi.mock('@/app/components/base/markdown-blocks', () => ({
AudioBlock: ({ children }: PropsWithChildren) => <div data-testid="audio-block">{children}</div>,
Img: ({ alt }: { alt?: string }) => <span data-testid="img">{alt}</span>,
Link: ({ children, href }: { children?: ReactNode, href?: string }) => <a href={href}>{children}</a>,
MarkdownButton: ({ children }: PropsWithChildren) => <button>{children}</button>,
MarkdownForm: ({ children }: PropsWithChildren) => <form>{children}</form>,
Paragraph: ({ children }: PropsWithChildren) => <p>{children}</p>,
PluginImg: ({ alt }: { alt?: string }) => <span data-testid="plugin-img">{alt}</span>,
PluginParagraph: ({ children }: PropsWithChildren) => <p>{children}</p>,
ScriptBlock: () => null,
ThinkBlock: ({ children }: PropsWithChildren) => <details>{children}</details>,
VideoBlock: ({ children }: PropsWithChildren) => <div data-testid="video-block">{children}</div>,
}))
vi.mock('@/app/components/base/markdown-blocks/code-block', () => ({
default: ({ children }: PropsWithChildren) => <code>{children}</code>,
}))
describe('ReactMarkdownWrapper', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('Strikethrough rendering', () => {
it('should NOT render single tilde as strikethrough', () => {
// Arrange - single tilde should be rendered as literal text
const content = 'Range: 0.3~8mm'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert - check that ~ is rendered as text, not as strikethrough (del element)
// The content should contain the tilde as literal text
expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument()
expect(document.querySelector('del')).toBeNull()
})
it('should render double tildes as strikethrough', () => {
// Arrange - double tildes should create strikethrough
const content = 'This is ~~strikethrough~~ text'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert - del element should be present for double tildes
const delElement = document.querySelector('del')
expect(delElement).not.toBeNull()
expect(delElement?.textContent).toBe('strikethrough')
})
it('should handle mixed content with single and double tildes correctly', () => {
// Arrange - real-world example from issue #31391
const content = 'PCB thickness: 0.3~8mm and ~~removed feature~~ text'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert
// Only double tildes should create strikethrough
const delElements = document.querySelectorAll('del')
expect(delElements).toHaveLength(1)
expect(delElements[0].textContent).toBe('removed feature')
// Single tilde should remain as literal text
expect(screen.getByText(/0\.3~8mm/)).toBeInTheDocument()
})
})
describe('Basic rendering', () => {
it('should render plain text content', () => {
// Arrange
const content = 'Hello World'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert
expect(screen.getByText('Hello World')).toBeInTheDocument()
})
it('should render bold text', () => {
// Arrange
const content = '**bold text**'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert
expect(screen.getByText('bold text')).toBeInTheDocument()
expect(document.querySelector('strong')).not.toBeNull()
})
it('should render italic text', () => {
// Arrange
const content = '*italic text*'
// Act
render(<ReactMarkdownWrapper latexContent={content} />)
// Assert
expect(screen.getByText('italic text')).toBeInTheDocument()
expect(document.querySelector('em')).not.toBeNull()
})
})
})

View File

@ -31,7 +31,7 @@ export const ReactMarkdownWrapper: FC<ReactMarkdownWrapperProps> = (props) => {
return (
<ReactMarkdown
remarkPlugins={[
RemarkGfm,
[RemarkGfm, { singleTilde: false }],
[RemarkMath, { singleDollarTextMath: ENABLE_SINGLE_DOLLAR_LATEX }],
RemarkBreaks,
]}

View File

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

View File

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

View File

@ -2,24 +2,61 @@ import { render, screen } from '@testing-library/react'
import ProgressBar from './index'
describe('ProgressBar', () => {
it('renders with provided percent and color', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
describe('Normal Mode (determinate)', () => {
it('renders with provided percent and color', () => {
render(<ProgressBar percent={42} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-test-color')
expect(bar.getAttribute('style')).toContain('width: 42%')
})
it('caps width at 100% when percent exceeds max', () => {
render(<ProgressBar percent={150} color="bg-test-color" />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 100%')
})
it('uses the default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar).toHaveClass('bg-components-progress-bar-progress-solid')
expect(bar.getAttribute('style')).toContain('width: 20%')
})
})
it('caps width at 100% when percent exceeds max', () => {
render(<ProgressBar percent={150} color="bg-test-color" />)
describe('Indeterminate Mode', () => {
it('should render indeterminate progress bar when indeterminate is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate />)
const bar = screen.getByTestId('billing-progress-bar')
expect(bar.getAttribute('style')).toContain('width: 100%')
})
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toBeInTheDocument()
expect(bar).toHaveClass('bg-progress-bar-indeterminate-stripe')
})
it('uses the default color when no color prop is provided', () => {
render(<ProgressBar percent={20} color={undefined as unknown as string} />)
it('should not render normal progress bar when indeterminate is true', () => {
render(<ProgressBar percent={50} color="bg-test-color" indeterminate />)
expect(screen.getByTestId('billing-progress-bar')).toHaveClass('#2970FF')
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render with default width (w-[30px]) when indeterminateFull is false', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull={false} />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should render with full width (w-full) when indeterminateFull is true', () => {
render(<ProgressBar percent={0} color="bg-test-color" indeterminate indeterminateFull />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
expect(bar).not.toHaveClass('w-[30px]')
})
})
})

View File

@ -3,12 +3,27 @@ import { cn } from '@/utils/classnames'
type ProgressBarProps = {
percent: number
color: string
indeterminate?: boolean
indeterminateFull?: boolean // For Sandbox users: full width stripe
}
const ProgressBar = ({
percent = 0,
color = '#2970FF',
color = 'bg-components-progress-bar-progress-solid',
indeterminate = false,
indeterminateFull = false,
}: ProgressBarProps) => {
if (indeterminate) {
return (
<div className="overflow-hidden rounded-[6px] bg-components-progress-bar-bg">
<div
data-testid="billing-progress-bar-indeterminate"
className={cn('h-1 rounded-[6px] bg-progress-bar-indeterminate-stripe', indeterminateFull ? 'w-full' : 'w-[30px]')}
/>
</div>
)
}
return (
<div className="overflow-hidden rounded-[6px] bg-components-progress-bar-bg">
<div

View File

@ -5,110 +5,310 @@ import UsageInfo from './index'
const TestIcon = () => <span data-testid="usage-icon" />
describe('UsageInfo', () => {
it('renders the metric with a suffix unit and tooltip text', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Apps"
usage={30}
total={100}
unit="GB"
tooltip="tooltip text"
/>,
)
describe('Default Mode (non-storage)', () => {
it('renders the metric with a suffix unit and tooltip text', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Apps"
usage={30}
total={100}
unit="GB"
tooltip="tooltip text"
/>,
)
expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
expect(screen.getByText('Apps')).toBeInTheDocument()
expect(screen.getByText('30')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('GB')).toBeInTheDocument()
expect(screen.getByTestId('usage-icon')).toBeInTheDocument()
expect(screen.getByText('Apps')).toBeInTheDocument()
expect(screen.getByText('30')).toBeInTheDocument()
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('GB')).toBeInTheDocument()
})
it('renders inline unit when unitPosition is inline', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
unitPosition="inline"
/>,
)
expect(screen.getByText('100GB')).toBeInTheDocument()
})
it('shows reset hint text instead of the unit when resetHint is provided', () => {
const resetHint = 'Resets in 3 days'
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
resetHint={resetHint}
/>,
)
expect(screen.getByText(resetHint)).toBeInTheDocument()
expect(screen.queryByText('GB')).not.toBeInTheDocument()
})
it('displays unlimited text when total is infinite', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={10}
total={NUM_INFINITE}
unit="GB"
/>,
)
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
it('applies warning color when usage is close to the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={85}
total={100}
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
it('applies error color when usage exceeds the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={120}
total={100}
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('does not render the icon when hideIcon is true', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={5}
total={100}
hideIcon
/>,
)
expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
})
})
it('renders inline unit when unitPosition is inline', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
unitPosition="inline"
/>,
)
describe('Storage Mode', () => {
describe('Below Threshold', () => {
it('should render indeterminate progress bar when usage is below threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={5120}
unit="MB"
storageMode
storageThreshold={50}
/>,
)
expect(screen.getByText('100GB')).toBeInTheDocument()
})
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar')).not.toBeInTheDocument()
})
it('shows reset hint text instead of the unit when resetHint is provided', () => {
const resetHint = 'Resets in 3 days'
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={20}
total={100}
unit="GB"
resetHint={resetHint}
/>,
)
it('should display "< threshold" format when usage is below threshold (non-sandbox)', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={5120}
unit="MB"
unitPosition="inline"
storageMode
storageThreshold={50}
isSandboxPlan={false}
/>,
)
expect(screen.getByText(resetHint)).toBeInTheDocument()
expect(screen.queryByText('GB')).not.toBeInTheDocument()
})
// Text "< 50" is rendered inside a single span
expect(screen.getByText(/< 50/)).toBeInTheDocument()
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
it('displays unlimited text when total is infinite', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={10}
total={NUM_INFINITE}
unit="GB"
/>,
)
it('should display "< threshold unit" format when usage is below threshold (sandbox)', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={50}
unit="MB"
storageMode
storageThreshold={50}
isSandboxPlan
/>,
)
expect(screen.getByText('billing.plansCommon.unlimited')).toBeInTheDocument()
})
// Text "< 50" is rendered inside a single span
expect(screen.getByText(/< 50/)).toBeInTheDocument()
// Unit "MB" appears in the display
expect(screen.getAllByText('MB').length).toBeGreaterThanOrEqual(1)
})
it('applies warning color when usage is close to the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={85}
total={100}
/>,
)
it('should render full-width indeterminate bar for sandbox users below threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={50}
unit="MB"
storageMode
storageThreshold={50}
isSandboxPlan
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
})
it('applies error color when usage exceeds the limit', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={120}
total={100}
/>,
)
it('should render narrow indeterminate bar for non-sandbox users below threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={5120}
unit="MB"
storageMode
storageThreshold={50}
isSandboxPlan={false}
/>,
)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
})
})
it('does not render the icon when hideIcon is true', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={5}
total={100}
hideIcon
/>,
)
describe('Sandbox Full Capacity', () => {
it('should render error color progress bar when sandbox usage >= threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={50}
total={50}
unit="MB"
storageMode
storageThreshold={50}
isSandboxPlan
/>,
)
expect(screen.queryByTestId('usage-icon')).not.toBeInTheDocument()
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('should display "threshold / threshold unit" format when sandbox is at full capacity', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={50}
total={50}
unit="MB"
storageMode
storageThreshold={50}
isSandboxPlan
/>,
)
// First span: "50", Third span: "50 MB"
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText(/50 MB/)).toBeInTheDocument()
expect(screen.getByText('/')).toBeInTheDocument()
})
})
describe('Pro/Team Users Above Threshold', () => {
it('should render normal progress bar when usage >= threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={100}
total={5120}
unit="MB"
unitPosition="inline"
storageMode
storageThreshold={50}
isSandboxPlan={false}
/>,
)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display actual usage when usage >= threshold', () => {
render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={100}
total={5120}
unit="MB"
unitPosition="inline"
storageMode
storageThreshold={50}
isSandboxPlan={false}
/>,
)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
})
describe('Storage Tooltip', () => {
it('should render tooltip wrapper when storageTooltip is provided', () => {
const { container } = render(
<UsageInfo
Icon={TestIcon}
name="Storage"
usage={30}
total={5120}
unit="MB"
storageMode
storageThreshold={50}
storageTooltip="This is a storage tooltip"
/>,
)
// Tooltip wrapper should contain cursor-default class
const tooltipWrapper = container.querySelector('.cursor-default')
expect(tooltipWrapper).toBeInTheDocument()
})
})
})
})

View File

@ -1,5 +1,5 @@
'use client'
import type { FC } from 'react'
import type { ComponentType, FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Tooltip from '@/app/components/base/tooltip'
@ -9,7 +9,7 @@ import ProgressBar from '../progress-bar'
type Props = {
className?: string
Icon: any
Icon: ComponentType<{ className?: string }>
name: string
tooltip?: string
usage: number
@ -19,6 +19,11 @@ type Props = {
resetHint?: string
resetInDays?: number
hideIcon?: boolean
// Props for the 50MB threshold display logic
storageMode?: boolean
storageThreshold?: number
storageTooltip?: string
isSandboxPlan?: boolean
}
const WARNING_THRESHOLD = 80
@ -35,30 +40,141 @@ const UsageInfo: FC<Props> = ({
resetHint,
resetInDays,
hideIcon = false,
storageMode = false,
storageThreshold = 50,
storageTooltip,
isSandboxPlan = false,
}) => {
const { t } = useTranslation()
// Special display logic for usage below threshold (only in storage mode)
const isBelowThreshold = storageMode && usage < storageThreshold
// Sandbox at full capacity (usage >= threshold and it's sandbox plan)
const isSandboxFull = storageMode && isSandboxPlan && usage >= storageThreshold
const percent = usage / total * 100
const color = percent >= 100
? 'bg-components-progress-error-progress'
: (percent >= WARNING_THRESHOLD ? 'bg-components-progress-warning-progress' : 'bg-components-progress-bar-progress-solid')
const getProgressColor = () => {
if (percent >= 100)
return 'bg-components-progress-error-progress'
if (percent >= WARNING_THRESHOLD)
return 'bg-components-progress-warning-progress'
return 'bg-components-progress-bar-progress-solid'
}
const color = getProgressColor()
const isUnlimited = total === NUM_INFINITE
let totalDisplay: string | number = isUnlimited ? t('plansCommon.unlimited', { ns: 'billing' }) : total
if (!isUnlimited && unit && unitPosition === 'inline')
totalDisplay = `${total}${unit}`
const showUnit = !!unit && !isUnlimited && unitPosition === 'suffix'
const resetText = resetHint ?? (typeof resetInDays === 'number' ? t('usagePage.resetsIn', { ns: 'billing', count: resetInDays }) : undefined)
const rightInfo = resetText
? (
const renderRightInfo = () => {
if (resetText) {
return (
<div className="system-xs-regular ml-auto flex-1 text-right text-text-tertiary">
{resetText}
</div>
)
: (showUnit && (
}
if (showUnit) {
return (
<div className="system-xs-medium ml-auto text-text-tertiary">
{unit}
</div>
))
)
}
return null
}
// Render usage display
const renderUsageDisplay = () => {
// Storage mode: special display logic
if (storageMode) {
// Sandbox user at full capacity
if (isSandboxFull) {
return (
<div className="flex items-center gap-1">
<span>
{storageThreshold}
</span>
<span className="system-md-regular text-text-quaternary">/</span>
<span>
{storageThreshold}
{' '}
{unit}
</span>
</div>
)
}
// Usage below threshold - show "< 50 MB" or "< 50 / 5GB"
if (isBelowThreshold) {
return (
<div className="flex items-center gap-1">
<span>
&lt;
{' '}
{storageThreshold}
</span>
{!isSandboxPlan && (
<>
<span className="system-md-regular text-text-quaternary">/</span>
<span>{totalDisplay}</span>
</>
)}
{isSandboxPlan && <span>{unit}</span>}
</div>
)
}
// Pro/Team users with usage >= threshold - show actual usage
return (
<div className="flex items-center gap-1">
<span>{usage}</span>
<span className="system-md-regular text-text-quaternary">/</span>
<span>{totalDisplay}</span>
</div>
)
}
// Default display (storageMode = false)
return (
<div className="flex items-center gap-1">
<span>{usage}</span>
<span className="system-md-regular text-text-quaternary">/</span>
<span>{totalDisplay}</span>
</div>
)
}
const renderWithTooltip = (children: React.ReactNode) => {
if (storageMode && storageTooltip) {
return (
<Tooltip
popupContent={<div className="w-[200px]">{storageTooltip}</div>}
asChild={false}
>
<div className="cursor-default">{children}</div>
</Tooltip>
)
}
return children
}
// Render progress bar with optional tooltip wrapper
const renderProgressBar = () => {
const progressBar = (
<ProgressBar
percent={isBelowThreshold ? 0 : percent}
color={isSandboxFull ? 'bg-components-progress-error-progress' : color}
indeterminate={isBelowThreshold}
indeterminateFull={isBelowThreshold && isSandboxPlan}
/>
)
return renderWithTooltip(progressBar)
}
const renderUsageWithTooltip = () => {
return renderWithTooltip(renderUsageDisplay())
}
return (
<div className={cn('flex flex-col gap-2 rounded-xl bg-components-panel-bg p-4', className)}>
@ -78,17 +194,10 @@ const UsageInfo: FC<Props> = ({
)}
</div>
<div className="system-md-semibold flex items-center gap-1 text-text-primary">
<div className="flex items-center gap-1">
{usage}
<div className="system-md-regular text-text-quaternary">/</div>
<div>{totalDisplay}</div>
</div>
{rightInfo}
{renderUsageWithTooltip()}
{renderRightInfo()}
</div>
<ProgressBar
percent={percent}
color={color}
/>
{renderProgressBar()}
</div>
)
}

View File

@ -0,0 +1,305 @@
import { render, screen } from '@testing-library/react'
import { defaultPlan } from '../config'
import { Plan } from '../type'
import VectorSpaceInfo from './vector-space-info'
// Mock provider context with configurable plan
let mockPlanType = Plan.sandbox
let mockVectorSpaceUsage = 30
let mockVectorSpaceTotal = 5120
vi.mock('@/context/provider-context', () => ({
useProviderContext: () => ({
plan: {
...defaultPlan,
type: mockPlanType,
usage: {
...defaultPlan.usage,
vectorSpace: mockVectorSpaceUsage,
},
total: {
...defaultPlan.total,
vectorSpace: mockVectorSpaceTotal,
},
},
}),
}))
describe('VectorSpaceInfo', () => {
beforeEach(() => {
vi.clearAllMocks()
// Reset to default values
mockPlanType = Plan.sandbox
mockVectorSpaceUsage = 30
mockVectorSpaceTotal = 5120
})
describe('Rendering', () => {
it('should render vector space info component', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText('billing.usagePage.vectorSpace')).toBeInTheDocument()
})
it('should apply custom className', () => {
render(<VectorSpaceInfo className="custom-class" />)
const container = screen.getByText('billing.usagePage.vectorSpace').closest('.custom-class')
expect(container).toBeInTheDocument()
})
})
describe('Sandbox Plan', () => {
beforeEach(() => {
mockPlanType = Plan.sandbox
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render full-width indeterminate bar for sandbox users', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-full')
})
it('should display "< 50" format for sandbox below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
})
})
describe('Sandbox Plan at Full Capacity', () => {
beforeEach(() => {
mockPlanType = Plan.sandbox
mockVectorSpaceUsage = 50
})
it('should render error color progress bar when at full capacity', () => {
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('should display "50 / 50 MB" format when at full capacity', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText('50')).toBeInTheDocument()
expect(screen.getByText(/50 MB/)).toBeInTheDocument()
})
})
describe('Professional Plan', () => {
beforeEach(() => {
mockPlanType = Plan.professional
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
// 5 GB = 5120 MB
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
})
describe('Professional Plan Above Threshold', () => {
beforeEach(() => {
mockPlanType = Plan.professional
mockVectorSpaceUsage = 100
})
it('should render normal progress bar when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display actual usage when above threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('5120MB')).toBeInTheDocument()
})
})
describe('Team Plan', () => {
beforeEach(() => {
mockPlanType = Plan.team
mockVectorSpaceUsage = 30
})
it('should render indeterminate progress bar when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width)', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
// 20 GB = 20480 MB
expect(screen.getByText('20480MB')).toBeInTheDocument()
})
})
describe('Team Plan Above Threshold', () => {
beforeEach(() => {
mockPlanType = Plan.team
mockVectorSpaceUsage = 100
})
it('should render normal progress bar when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display actual usage when above threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('20480MB')).toBeInTheDocument()
})
})
describe('Pro/Team Plan Warning State', () => {
it('should show warning color when Professional plan usage approaches limit (80%+)', () => {
mockPlanType = Plan.professional
// 5120 MB * 80% = 4096 MB
mockVectorSpaceUsage = 4100
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
it('should show warning color when Team plan usage approaches limit (80%+)', () => {
mockPlanType = Plan.team
// 20480 MB * 80% = 16384 MB
mockVectorSpaceUsage = 16500
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-warning-progress')
})
})
describe('Pro/Team Plan Error State', () => {
it('should show error color when Professional plan usage exceeds limit', () => {
mockPlanType = Plan.professional
// Exceeds 5120 MB
mockVectorSpaceUsage = 5200
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
it('should show error color when Team plan usage exceeds limit', () => {
mockPlanType = Plan.team
// Exceeds 20480 MB
mockVectorSpaceUsage = 21000
render(<VectorSpaceInfo />)
const progressBar = screen.getByTestId('billing-progress-bar')
expect(progressBar).toHaveClass('bg-components-progress-error-progress')
})
})
describe('Enterprise Plan (default case)', () => {
beforeEach(() => {
mockPlanType = Plan.enterprise
mockVectorSpaceUsage = 30
// Enterprise plan uses total.vectorSpace from context
mockVectorSpaceTotal = 102400 // 100 GB = 102400 MB
})
it('should use total.vectorSpace from context for enterprise plan', () => {
render(<VectorSpaceInfo />)
// Enterprise plan should use the mockVectorSpaceTotal value (102400MB)
expect(screen.getByText('102400MB')).toBeInTheDocument()
})
it('should render indeterminate progress bar when usage is below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar-indeterminate')).toBeInTheDocument()
})
it('should render narrow indeterminate bar (not full width) for enterprise', () => {
render(<VectorSpaceInfo />)
const bar = screen.getByTestId('billing-progress-bar-indeterminate')
expect(bar).toHaveClass('w-[30px]')
expect(bar).not.toHaveClass('w-full')
})
it('should display "< 50 / total" format when below threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText(/< 50/)).toBeInTheDocument()
expect(screen.getByText('102400MB')).toBeInTheDocument()
})
})
describe('Enterprise Plan Above Threshold', () => {
beforeEach(() => {
mockPlanType = Plan.enterprise
mockVectorSpaceUsage = 100
mockVectorSpaceTotal = 102400 // 100 GB
})
it('should render normal progress bar when usage >= threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByTestId('billing-progress-bar')).toBeInTheDocument()
expect(screen.queryByTestId('billing-progress-bar-indeterminate')).not.toBeInTheDocument()
})
it('should display actual usage when above threshold', () => {
render(<VectorSpaceInfo />)
expect(screen.getByText('100')).toBeInTheDocument()
expect(screen.getByText('102400MB')).toBeInTheDocument()
})
})
})

View File

@ -1,26 +1,44 @@
'use client'
import type { FC } from 'react'
import type { BasicPlan } from '../type'
import {
RiHardDrive3Line,
} from '@remixicon/react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { useProviderContext } from '@/context/provider-context'
import { Plan } from '../type'
import UsageInfo from '../usage-info'
import { getPlanVectorSpaceLimitMB } from '../utils'
type Props = {
className?: string
}
// Storage threshold in MB - usage below this shows as "< 50 MB"
const STORAGE_THRESHOLD_MB = getPlanVectorSpaceLimitMB(Plan.sandbox)
const VectorSpaceInfo: FC<Props> = ({
className,
}) => {
const { t } = useTranslation()
const { plan } = useProviderContext()
const {
type,
usage,
total,
} = plan
// Determine total based on plan type (in MB), derived from ALL_PLANS config
const getTotalInMB = () => {
const planLimit = getPlanVectorSpaceLimitMB(type as BasicPlan)
// For known plans, use the config value; otherwise fall back to API response
return planLimit > 0 ? planLimit : total.vectorSpace
}
const totalInMB = getTotalInMB()
const isSandbox = type === Plan.sandbox
return (
<UsageInfo
className={className}
@ -28,9 +46,13 @@ const VectorSpaceInfo: FC<Props> = ({
name={t('usagePage.vectorSpace', { ns: 'billing' })}
tooltip={t('usagePage.vectorSpaceTooltip', { ns: 'billing' }) as string}
usage={usage.vectorSpace}
total={total.vectorSpace}
total={totalInMB}
unit="MB"
unitPosition="inline"
storageMode
storageThreshold={STORAGE_THRESHOLD_MB}
storageTooltip={t('usagePage.storageThresholdTooltip', { ns: 'billing' }) as string}
isSandboxPlan={isSandbox}
/>
)
}

View File

@ -1,7 +1,33 @@
import type { BillingQuota, CurrentPlanInfoBackend } from '../type'
import type { BasicPlan, BillingQuota, CurrentPlanInfoBackend } from '../type'
import dayjs from 'dayjs'
import { ALL_PLANS, NUM_INFINITE } from '@/app/components/billing/config'
/**
* Parse vectorSpace string from ALL_PLANS config and convert to MB
* @example "50MB" -> 50, "5GB" -> 5120, "20GB" -> 20480
*/
export const parseVectorSpaceToMB = (vectorSpace: string): number => {
const match = vectorSpace.match(/^(\d+)(MB|GB)$/i)
if (!match)
return 0
const value = Number.parseInt(match[1], 10)
const unit = match[2].toUpperCase()
return unit === 'GB' ? value * 1024 : value
}
/**
* Get the vector space limit in MB for a given plan type from ALL_PLANS config
*/
export const getPlanVectorSpaceLimitMB = (planType: BasicPlan): number => {
const planInfo = ALL_PLANS[planType]
if (!planInfo)
return 0
return parseVectorSpaceToMB(planInfo.vectorSpace)
}
const parseLimit = (limit: number) => {
if (limit === 0)
return NUM_INFINITE

View File

@ -21,6 +21,18 @@ vi.mock('../upgrade-btn', () => ({
default: () => <button data-testid="vector-upgrade-btn" type="button">Upgrade</button>,
}))
// Mock utils to control threshold and plan limits
vi.mock('../utils', () => ({
getPlanVectorSpaceLimitMB: (planType: string) => {
// Return 5 for sandbox (threshold) and 100 for team
if (planType === 'sandbox')
return 5
if (planType === 'team')
return 100
return 0
},
}))
describe('VectorSpaceFull', () => {
const planMock = {
type: 'team',
@ -52,6 +64,6 @@ describe('VectorSpaceFull', () => {
render(<VectorSpaceFull />)
expect(screen.getByText('8')).toBeInTheDocument()
expect(screen.getByText('10MB')).toBeInTheDocument()
expect(screen.getByText('100MB')).toBeInTheDocument()
})
})

View File

@ -54,7 +54,7 @@ const Uploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return

View File

@ -278,7 +278,7 @@ const FileUploader = ({
onFileListUpdate?.([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let files = [...(e.target.files ?? [])] as File[]
let files = Array.from(e.target.files ?? []) as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload, fileUploadConfig])

View File

@ -190,7 +190,7 @@ describe('StepThree', () => {
// Assert
const link = screen.getByText('datasetPipeline.addDocuments.stepThree.learnMore')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/integrate-knowledge-within-application')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})

View File

@ -87,7 +87,7 @@ const StepThree = ({ datasetId, datasetName, indexingType, creationCache, retrie
<div className="text-base font-semibold text-text-secondary">{t('stepThree.sideTipTitle', { ns: 'datasetCreation' })}</div>
<div className="text-text-tertiary">{t('stepThree.sideTipContent', { ns: 'datasetCreation' })}</div>
<a
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')}
target="_blank"
rel="noreferrer noopener"
className="system-sm-regular text-text-accent"

View File

@ -214,7 +214,7 @@ export const IndexingModeSection: FC<IndexingModeSectionProps> = ({
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents')}
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
className="text-text-accent"
>
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}

View File

@ -24,6 +24,11 @@ vi.mock('@/context/modal-context', () => ({
}),
}))
// Mock i18n context
vi.mock('@/context/i18n', () => ({
useDocLink: () => (path?: string) => path ? `https://docs.dify.ai/en${path}` : 'https://docs.dify.ai/en/',
}))
// ============================================================================
// Test Data Factories
// ============================================================================

View File

@ -121,7 +121,7 @@ const DocumentsHeader: FC<DocumentsHeaderProps> = ({
className="flex items-center text-text-accent"
target="_blank"
rel="noopener noreferrer"
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')}
>
<span>{t('list.learnMore', { ns: 'datasetDocuments' })}</span>
<RiExternalLinkLine className="h-3 w-3" />

View File

@ -230,7 +230,7 @@ const LocalFile = ({
if (!e.dataTransfer)
return
let files = [...e.dataTransfer.files] as File[]
let files = Array.from(e.dataTransfer.files) as File[]
if (!supportBatchUpload)
files = files.slice(0, 1)
@ -251,7 +251,7 @@ const LocalFile = ({
updateFileList([...fileListRef.current])
}
const fileChangeHandle = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
let files = [...(e.target.files ?? [])] as File[]
let files = Array.from(e.target.files ?? []) as File[]
files = files.slice(0, fileUploadConfig.batch_count_limit)
initialUpload(files.filter(isValid))
}, [isValid, initialUpload, fileUploadConfig.batch_count_limit])

View File

@ -138,7 +138,7 @@ const OnlineDocuments = ({
<div className="flex flex-col gap-y-2">
<Header
docTitle="Docs"
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
docLink={docLink('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}

View File

@ -327,7 +327,7 @@ describe('OnlineDrive', () => {
render(<OnlineDrive {...props} />)
// Assert
expect(mockDocLink).toHaveBeenCalledWith('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')
expect(mockDocLink).toHaveBeenCalledWith('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')
})
})

View File

@ -196,7 +196,7 @@ const OnlineDrive = ({
<div className="flex flex-col gap-y-2">
<Header
docTitle="Docs"
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
docLink={docLink('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}

View File

@ -158,7 +158,7 @@ const WebsiteCrawl = ({
<div className="flex flex-col">
<Header
docTitle="Docs"
docLink={docLink('/guides/knowledge-base/knowledge-pipeline/authorize-data-source')}
docLink={docLink('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')}
onClickConfiguration={handleSetting}
pluginName={nodeData.datasource_label}
currentCredentialId={currentCredentialId}

View File

@ -159,7 +159,7 @@ describe('Processing', () => {
// Assert
const link = screen.getByRole('link', { name: 'datasetPipeline.addDocuments.stepThree.learnMore' })
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/guides/knowledge-base/integrate-knowledge-within-application')
expect(link).toHaveAttribute('href', 'https://docs.dify.ai/en-US/use-dify/knowledge/knowledge-pipeline/authorize-data-source')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noreferrer noopener')
})

View File

@ -44,7 +44,7 @@ const Processing = ({
<div className="system-xl-semibold text-text-secondary">{t('stepThree.sideTipTitle', { ns: 'datasetCreation' })}</div>
<div className="system-sm-regular text-text-tertiary">{t('stepThree.sideTipContent', { ns: 'datasetCreation' })}</div>
<a
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
href={docLink('/use-dify/knowledge/knowledge-pipeline/authorize-data-source')}
target="_blank"
rel="noreferrer noopener"
className="system-sm-regular text-text-accent"

View File

@ -126,7 +126,7 @@ const CSVUploader: FC<Props> = ({
setDragging(false)
if (!e.dataTransfer)
return
const files = [...e.dataTransfer.files]
const files = Array.from(e.dataTransfer.files)
if (files.length > 1) {
notify({ type: 'error', message: t('stepOne.uploader.validation.count', { ns: 'datasetCreation' }) })
return

View File

@ -57,7 +57,7 @@ const Form: FC<FormProps> = React.memo(({
</label>
{variable === 'endpoint' && (
<a
href={docLink('/guides/knowledge-base/connect-external-knowledge-base') || '/'}
href={docLink('/use-dify/knowledge/external-knowledge-api') || '/'}
target="_blank"
rel="noopener noreferrer"
className="body-xs-regular flex items-center text-text-accent"

View File

@ -63,7 +63,7 @@ describe('ExternalAPIPanel', () => {
render(<ExternalAPIPanel {...defaultProps} />)
const docLink = screen.getByText('dataset.externalAPIPanelDocumentation')
expect(docLink).toBeInTheDocument()
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/knowledge-base/connect-external-knowledge-base')
expect(docLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/use-dify/knowledge/external-knowledge-api')
})
it('should render create button', () => {

View File

@ -54,7 +54,7 @@ const ExternalAPIPanel: React.FC<ExternalAPIPanelProps> = ({ onClose }) => {
<div className="body-xs-regular self-stretch text-text-tertiary">{t('externalAPIPanelDescription', { ns: 'dataset' })}</div>
<a
className="flex cursor-pointer items-center justify-center gap-1 self-stretch"
href={docLink('/guides/knowledge-base/connect-external-knowledge-base')}
href={docLink('/use-dify/knowledge/external-knowledge-api')}
target="_blank"
>
<RiBookOpenLine className="h-3 w-3 text-text-accent" />

View File

@ -18,14 +18,14 @@ const InfoPanel = () => {
</span>
<span className="system-sm-regular text-text-tertiary">
{t('connectDatasetIntro.content.front', { ns: 'dataset' })}
<a className="system-sm-regular ml-1 text-text-accent" href={docLink('/guides/knowledge-base/external-knowledge-api')} target="_blank" rel="noopener noreferrer">
<a className="system-sm-regular ml-1 text-text-accent" href={docLink('/use-dify/knowledge/external-knowledge-api')} target="_blank" rel="noopener noreferrer">
{t('connectDatasetIntro.content.link', { ns: 'dataset' })}
</a>
{t('connectDatasetIntro.content.end', { ns: 'dataset' })}
</span>
<a
className="system-sm-regular self-stretch text-text-accent"
href={docLink('/guides/knowledge-base/connect-external-knowledge-base')}
href={docLink('/use-dify/knowledge/connect-external-knowledge-base')}
target="_blank"
rel="noopener noreferrer"
>

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