Merge branch 'main' into feat/summary-index

This commit is contained in:
zxhlyh
2026-01-22 14:27:23 +08:00
285 changed files with 2287 additions and 978 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

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

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

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

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

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

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

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

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

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

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

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

@ -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/connect-external-knowledge-base') || '/'}
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/connect-external-knowledge-base')
})
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/connect-external-knowledge-base')}
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"
>

View File

@ -146,7 +146,7 @@ describe('ExternalKnowledgeBaseCreate', () => {
renderComponent()
const docLink = screen.getByText('dataset.connectHelper.helper4')
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/guides/knowledge-base/connect-external-knowledge-base')
expect(docLink).toHaveAttribute('href', 'https://docs.dify.ai/en/use-dify/knowledge/connect-external-knowledge-base')
expect(docLink).toHaveAttribute('target', '_blank')
expect(docLink).toHaveAttribute('rel', 'noopener noreferrer')
})

View File

@ -61,7 +61,7 @@ const ExternalKnowledgeBaseCreate: React.FC<ExternalKnowledgeBaseCreateProps> =
<span>{t('connectHelper.helper1', { ns: 'dataset' })}</span>
<span className="system-sm-medium text-text-secondary">{t('connectHelper.helper2', { ns: 'dataset' })}</span>
<span>{t('connectHelper.helper3', { ns: 'dataset' })}</span>
<a className="system-sm-regular self-stretch text-text-accent" href={docLink('/guides/knowledge-base/connect-external-knowledge-base')} target="_blank" rel="noopener noreferrer">
<a className="system-sm-regular self-stretch text-text-accent" href={docLink('/use-dify/knowledge/connect-external-knowledge-base')} target="_blank" rel="noopener noreferrer">
{t('connectHelper.helper4', { ns: 'dataset' })}
</a>
<span>

View File

@ -96,10 +96,7 @@ const ModifyRetrievalModal: FC<Props> = ({
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/guides/knowledge-base/retrieval-test-and-citation#modify-text-retrieval-setting', {
'zh-Hans': '/guides/knowledge-base/retrieval-test-and-citation#修改文本检索方式',
'ja-JP': '/guides/knowledge-base/retrieval-test-and-citation',
})}
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
className="text-text-accent"
>
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}

View File

@ -15,7 +15,7 @@ const NoLinkedAppsPanel = () => {
<div className="my-2 text-xs text-text-tertiary">{t('datasetMenus.emptyTip', { ns: 'common' })}</div>
<a
className="mt-2 inline-flex cursor-pointer items-center text-xs text-text-accent"
href={docLink('/guides/knowledge-base/integrate-knowledge-within-application')}
href={docLink('/use-dify/knowledge/integrate-knowledge-within-application')}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -289,7 +289,7 @@ const Form = () => {
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/chunking-and-cleaning-text')}
href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')}
className="text-text-accent"
>
{t('form.chunkStructure.learnMore', { ns: 'datasetSettings' })}
@ -446,10 +446,7 @@ const Form = () => {
<a
target="_blank"
rel="noopener noreferrer"
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting', {
'zh-Hans': '/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#指定检索方式',
'ja-JP': '/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#検索方法の指定',
})}
href={docLink('/use-dify/knowledge/create-knowledge/setting-indexing-methods')}
className="text-text-accent"
>
{t('form.retrievalSetting.learnMore', { ns: 'datasetSettings' })}

View File

@ -137,7 +137,7 @@ export default function AppSelector() {
<MenuItem>
<Link
className={cn(itemClassName, 'group justify-between', 'data-[active]:bg-state-base-hover')}
href={docLink('/introduction')}
href={docLink('/use-dify/getting-started/introduction')}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -17,7 +17,7 @@ const Empty = () => {
<div className="system-sm-medium mb-1 text-text-secondary">{t('apiBasedExtension.title', { ns: 'common' })}</div>
<a
className="system-xs-regular flex items-center text-text-accent"
href={docLink('/guides/extension/api-based-extension/README')}
href={docLink('/use-dify/workspace/api-extension/api-extension')}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -102,7 +102,7 @@ const ApiBasedExtensionModal: FC<ApiBasedExtensionModalProps> = ({
<div className="flex h-9 items-center justify-between text-sm font-medium text-text-primary">
{t('apiBasedExtension.modal.apiEndpoint.title', { ns: 'common' })}
<a
href={docLink('/user-guide/extension/api-based-extension/README#api-based-extension')}
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-accent"

View File

@ -77,7 +77,7 @@ const EndpointList = ({ detail }: Props) => {
</div>
<div className="system-xs-regular text-text-tertiary">{t('detailPanel.endpointsTip', { ns: 'plugin' })}</div>
<a
href={docLink('/plugins/schema-definition/endpoint')}
href={docLink('/develop-plugin/getting-started/getting-started-dify-plugin')}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -8,8 +8,7 @@ import * as React from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Tooltip from '@/app/components/base/tooltip'
import { getDocsUrl } from '@/app/components/plugins/utils'
import { useLocale } from '@/context/i18n'
import { useDocLink } from '@/context/i18n'
import { useDebugKey } from '@/service/use-plugins'
import KeyValueItem from '../base/key-value-item'
@ -17,7 +16,7 @@ const i18nPrefix = 'debugInfo'
const DebugInfo: FC = () => {
const { t } = useTranslation()
const locale = useLocale()
const docLink = useDocLink()
const { data: info, isLoading } = useDebugKey()
// info.key likes 4580bdb7-b878-471c-a8a4-bfd760263a53 mask the middle part using *.
@ -34,7 +33,7 @@ const DebugInfo: FC = () => {
<>
<div className="flex items-center gap-1 self-stretch">
<span className="system-sm-semibold flex shrink-0 grow basis-0 flex-col items-start justify-center text-text-secondary">{t(`${i18nPrefix}.title`, { ns: 'plugin' })}</span>
<a href={getDocsUrl(locale, '/plugins/quick-start/debug-plugin')} target="_blank" className="flex cursor-pointer items-center gap-0.5 text-text-accent-light-mode-only">
<a href={docLink('/develop-plugin/features-and-specs/plugin-types/remote-debug-a-plugin')} target="_blank" className="flex cursor-pointer items-center gap-0.5 text-text-accent-light-mode-only">
<span className="system-xs-medium">{t(`${i18nPrefix}.viewDocs`, { ns: 'plugin' })}</span>
<RiArrowRightUpLine className="h-3 w-3" />
</a>

View File

@ -24,6 +24,7 @@ vi.mock('@/hooks/use-document-title', () => ({
vi.mock('@/context/i18n', () => ({
useLocale: () => 'en-US',
useDocLink: () => (path: string) => `https://docs.example.com${path}`,
}))
vi.mock('@/context/global-public-context', () => ({

View File

@ -15,10 +15,9 @@ import Button from '@/app/components/base/button'
import TabSlider from '@/app/components/base/tab-slider'
import Tooltip from '@/app/components/base/tooltip'
import ReferenceSettingModal from '@/app/components/plugins/reference-setting-modal'
import { getDocsUrl } from '@/app/components/plugins/utils'
import { MARKETPLACE_API_PREFIX, SUPPORT_INSTALL_LOCAL_FILE_EXTENSIONS } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useLocale } from '@/context/i18n'
import { useDocLink } from '@/context/i18n'
import useDocumentTitle from '@/hooks/use-document-title'
import { usePluginInstallation } from '@/hooks/use-query-params'
import { fetchBundleInfoFromMarketPlace, fetchManifestFromMarketPlace } from '@/service/plugins'
@ -47,7 +46,7 @@ const PluginPage = ({
marketplace,
}: PluginPageProps) => {
const { t } = useTranslation()
const locale = useLocale()
const docLink = useDocLink()
useDocumentTitle(t('metadata.title', { ns: 'plugin' }))
// Use nuqs hook for installation state
@ -175,7 +174,7 @@ const PluginPage = ({
</Button>
</Link>
<Link
href={getDocsUrl(locale, '/plugins/publish-plugins/publish-to-dify-marketplace/README')}
href={docLink('/develop-plugin/publishing/marketplace-listing/release-to-dify-marketplace')}
target="_blank"
>
<Button

View File

@ -2,7 +2,6 @@ import type {
TagKey,
} from './constants'
import { LanguagesSupported } from '@/i18n-config/language'
import {
categoryKeys,
tagKeys,
@ -15,15 +14,3 @@ export const getValidTagKeys = (tags: TagKey[]) => {
export const getValidCategoryKeys = (category?: string) => {
return categoryKeys.find(key => key === category)
}
export const getDocsUrl = (locale: string, path: string) => {
let localePath = 'en'
if (locale === LanguagesSupported[1])
localePath = 'zh-hans'
else if (locale === LanguagesSupported[7])
localePath = 'ja-jp'
return `https://docs.dify.ai/${localePath}${path}`
}

View File

@ -34,6 +34,7 @@ import {
} from '@/app/components/workflow/store'
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '@/app/components/workflow/utils'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useDocLink } from '@/context/i18n'
import { useModalContextSelector } from '@/context/modal-context'
import { useProviderContext } from '@/context/provider-context'
import { useDatasetApiAccessUrl } from '@/hooks/use-api-access-url'
@ -55,6 +56,7 @@ const Popup = () => {
const { t } = useTranslation()
const { datasetId } = useParams()
const { push } = useRouter()
const docLink = useDocLink()
const publishedAt = useStore(s => s.publishedAt)
const draftUpdatedAt = useStore(s => s.draftUpdatedAt)
const pipelineId = useStore(s => s.pipelineId)
@ -186,7 +188,7 @@ const Popup = () => {
{t('publishTemplate.success.tip', { ns: 'datasetPipeline' })}
</span>
<Link
href="https://docs.dify.ai"
href={docLink()}
target="_blank"
className="system-xs-medium-uppercase inline-block text-text-accent"
>

View File

@ -6,11 +6,11 @@ import dataSourceEmptyDefault from '@/app/components/workflow/nodes/data-source-
import dataSourceDefault from '@/app/components/workflow/nodes/data-source/default'
import knowledgeBaseDefault from '@/app/components/workflow/nodes/knowledge-base/default'
import { BlockEnum } from '@/app/components/workflow/types'
import { useGetLanguage } from '@/context/i18n'
import { useDocLink } from '@/context/i18n'
export const useAvailableNodesMetaData = () => {
const { t } = useTranslation()
const language = useGetLanguage()
const docLink = useDocLink()
const mergedNodesMetaData = useMemo(() => [
...WORKFLOW_COMMON_NODES,
@ -25,14 +25,9 @@ export const useAvailableNodesMetaData = () => {
dataSourceEmptyDefault,
], [])
const helpLinkUri = useMemo(() => {
if (language === 'zh_Hans')
return 'https://docs.dify.ai/zh-hans/guides/knowledge-base/knowledge-pipeline/knowledge-pipeline-orchestration#%E6%AD%A5%E9%AA%A4%E4%B8%80%EF%BC%9A%E6%95%B0%E6%8D%AE%E6%BA%90%E9%85%8D%E7%BD%AE'
if (language === 'ja_JP')
return 'https://docs.dify.ai/ja-jp/guides/knowledge-base/knowledge-pipeline/knowledge-pipeline-orchestration#%E3%82%B9%E3%83%86%E3%83%83%E3%83%971%EF%BC%9A%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BD%E3%83%BC%E3%82%B9%E3%81%AE%E8%A8%AD%E5%AE%9A'
return 'https://docs.dify.ai/en/guides/knowledge-base/knowledge-pipeline/knowledge-pipeline-orchestration#step-1%3A-data-source'
}, [language])
const helpLinkUri = useMemo(() => docLink(
'/use-dify/knowledge/knowledge-pipeline/knowledge-pipeline-orchestration',
), [docLink])
const availableNodesMetaData = useMemo(() => mergedNodesMetaData.map((node) => {
const { metaData } = node

View File

@ -8,8 +8,7 @@ import {
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import { useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { useDocLink } from '@/context/i18n'
import { useCreateMCP } from '@/service/use-tools'
import MCPModal from './modal'
@ -19,8 +18,7 @@ type Props = {
const NewMCPCard = ({ handleCreate }: Props) => {
const { t } = useTranslation()
const locale = useLocale()
const language = getLanguage(locale)
const docLink = useDocLink()
const { isCurrentWorkspaceManager } = useAppContext()
const { mutateAsync: createMCP } = useCreateMCP()
@ -30,13 +28,7 @@ const NewMCPCard = ({ handleCreate }: Props) => {
handleCreate(provider)
}
const linkUrl = useMemo(() => {
if (language.startsWith('zh_'))
return 'https://docs.dify.ai/zh-hans/guides/tools/mcp'
if (language.startsWith('ja_jp'))
return 'https://docs.dify.ai/ja_jp/guides/tools/mcp'
return 'https://docs.dify.ai/en/guides/tools/mcp'
}, [language])
const linkUrl = useMemo(() => docLink('/use-dify/build/mcp'), [docLink])
const [showModal, setShowModal] = useState(false)

View File

@ -200,7 +200,7 @@ function MCPServiceCard({
</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

@ -2,16 +2,12 @@
import type { CustomCollectionBackend } from '../types'
import {
RiAddCircleFill,
RiArrowRightUpLine,
RiBookOpenLine,
} from '@remixicon/react'
import { useMemo, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Toast from '@/app/components/base/toast'
import EditCustomToolModal from '@/app/components/tools/edit-custom-collection-modal'
import { useAppContext } from '@/context/app-context'
import { useDocLink, useLocale } from '@/context/i18n'
import { getLanguage } from '@/i18n-config/language'
import { createCustomCollection } from '@/service/tools'
type Props = {
@ -20,17 +16,8 @@ type Props = {
const Contribute = ({ onRefreshData }: Props) => {
const { t } = useTranslation()
const locale = useLocale()
const language = getLanguage(locale)
const { isCurrentWorkspaceManager } = useAppContext()
const docLink = useDocLink()
const linkUrl = useMemo(() => {
return docLink('/guides/tools#how-to-create-custom-tools', {
'zh-Hans': '/guides/tools#ru-he-chuang-jian-zi-ding-yi-gong-ju',
})
}, [language])
const [isShowEditCollectionToolModal, setIsShowEditCustomCollectionModal] = useState(false)
const doCreateCustomToolCollection = async (data: CustomCollectionBackend) => {
await createCustomCollection(data)
@ -54,13 +41,6 @@ const Contribute = ({ onRefreshData }: Props) => {
<div className="system-md-semibold ml-3 text-text-secondary group-hover:text-text-accent">{t('createCustomTool', { ns: 'tools' })}</div>
</div>
</div>
<div className="rounded-b-xl border-t-[0.5px] border-divider-subtle px-4 py-3 text-text-tertiary hover:text-text-accent">
<a href={linkUrl} target="_blank" rel="noopener noreferrer" className="flex items-center space-x-1">
<RiBookOpenLine className="h-3 w-3 shrink-0" />
<div className="system-xs-regular grow truncate" title={t('customToolTip', { ns: 'tools' }) || ''}>{t('customToolTip', { ns: 'tools' })}</div>
<RiArrowRightUpLine className="h-3 w-3 shrink-0" />
</a>
</div>
</div>
)}
{isShowEditCollectionToolModal && (

View File

@ -126,18 +126,6 @@ describe('WorkflowOnboardingModal', () => {
expect(descriptionDiv).toHaveTextContent('workflow.onboarding.aboutStartNode')
})
it('should render learn more link', () => {
// Arrange & Act
renderComponent()
// Assert
const learnMoreLink = screen.getByText('workflow.onboarding.learnMore')
expect(learnMoreLink).toBeInTheDocument()
expect(learnMoreLink.closest('a')).toHaveAttribute('href', 'https://docs.example.com/guides/workflow/node/start')
expect(learnMoreLink.closest('a')).toHaveAttribute('target', '_blank')
expect(learnMoreLink.closest('a')).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should render StartNodeSelectionPanel', () => {
// Arrange & Act
renderComponent()
@ -547,16 +535,6 @@ describe('WorkflowOnboardingModal', () => {
expect(heading).toHaveTextContent('workflow.onboarding.title')
})
it('should have external link with proper attributes', () => {
// Arrange & Act
renderComponent()
// Assert
const link = screen.getByText('workflow.onboarding.learnMore').closest('a')
expect(link).toHaveAttribute('target', '_blank')
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
})
it('should have keyboard navigation support via ESC key', () => {
// Arrange
renderComponent({ isShow: true })
@ -595,16 +573,6 @@ describe('WorkflowOnboardingModal', () => {
const title = screen.getByText('workflow.onboarding.title')
expect(title).toHaveClass('text-text-primary')
})
it('should have underlined learn more link', () => {
// Arrange & Act
renderComponent()
// Assert
const link = screen.getByText('workflow.onboarding.learnMore').closest('a')
expect(link).toHaveClass('underline')
expect(link).toHaveClass('cursor-pointer')
})
})
// Integration Tests
@ -654,9 +622,6 @@ describe('WorkflowOnboardingModal', () => {
const heading = container.querySelector('h3')
expect(heading).toBeInTheDocument()
// Assert - Description with link
expect(screen.getByText('workflow.onboarding.learnMore').closest('a')).toBeInTheDocument()
// Assert - Selection panel
expect(screen.getByTestId('start-node-selection-panel')).toBeInTheDocument()

View File

@ -8,7 +8,6 @@ import {
import { useTranslation } from 'react-i18next'
import Modal from '@/app/components/base/modal'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import StartNodeSelectionPanel from './start-node-selection-panel'
type WorkflowOnboardingModalProps = {
@ -23,7 +22,6 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
onSelectStartNode,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const handleSelectUserInput = useCallback(() => {
onSelectStartNode(BlockEnum.Start)
@ -63,15 +61,6 @@ const WorkflowOnboardingModal: FC<WorkflowOnboardingModalProps> = ({
<div className="body-xs-regular leading-4 text-text-tertiary">
{t('onboarding.description', { ns: 'workflow' })}
{' '}
<a
href={docLink('/guides/workflow/node/start')}
target="_blank"
rel="noopener noreferrer"
className="hover:text-text-accent-hover cursor-pointer text-text-accent underline"
>
{t('onboarding.learnMore', { ns: 'workflow' })}
</a>
{' '}
{t('onboarding.aboutStartNode', { ns: 'workflow' })}
</div>
</div>

View File

@ -1,4 +1,5 @@
import type { AvailableNodesMetaData } from '@/app/components/workflow/hooks-store/store'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { WORKFLOW_COMMON_NODES } from '@/app/components/workflow/constants/node'
@ -44,7 +45,7 @@ export const useAvailableNodesMetaData = () => {
const { metaData } = node
const title = t(`blocks.${metaData.type}`, { ns: 'workflow' })
const description = t(`blocksAbout.${metaData.type}`, { ns: 'workflow' })
const helpLinkPath = `guides/workflow/node/${metaData.helpLinkUri}`
const helpLinkPath = `/use-dify/nodes/${metaData.helpLinkUri}` as DocPathWithoutLang
return {
...node,
metaData: {

View File

@ -95,6 +95,7 @@ import {
import SyncingDataModal from './syncing-data-modal'
import {
ControlMode,
WorkflowRunningStatus,
} from './types'
import { setupScrollToNodeListener } from './utils/node-navigation'
import { WorkflowHistoryProvider } from './workflow-history-store'
@ -231,11 +232,20 @@ export const Workflow: FC<WorkflowProps> = memo(({
const { handleRefreshWorkflowDraft } = useWorkflowRefreshDraft()
const handleSyncWorkflowDraftWhenPageClose = useCallback(() => {
if (document.visibilityState === 'hidden')
if (document.visibilityState === 'hidden') {
syncWorkflowDraftWhenPageClose()
return
}
if (document.visibilityState === 'visible') {
const { isListening, workflowRunningData } = workflowStore.getState()
const status = workflowRunningData?.result?.status
// Avoid resetting UI state when user comes back while a run is active or listening for triggers
if (isListening || status === WorkflowRunningStatus.Running)
return
else if (document.visibilityState === 'visible')
setTimeout(() => handleRefreshWorkflowDraft(), 500)
}
}, [syncWorkflowDraftWhenPageClose, handleRefreshWorkflowDraft, workflowStore])
// Also add beforeunload handler as additional safety net for tab close

View File

@ -251,10 +251,7 @@ export const AgentStrategy = memo((props: AgentStrategyProps) => {
{' '}
<br />
<Link
href={docLink('/guides/workflow/node/agent#select-an-agent-strategy', {
'zh-Hans': '/guides/workflow/node/agent#选择-agent-策略',
'ja-JP': '/guides/workflow/node/agent#エージェント戦略の選択',
})}
href={docLink('/use-dify/nodes/agent')}
className="text-text-accent-secondary"
target="_blank"
>

View File

@ -5,7 +5,6 @@ import Input from '@/app/components/base/input'
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { VarType } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
type DefaultValueProps = {
forms: DefaultValueForm[]
@ -16,7 +15,6 @@ const DefaultValue = ({
onFormChange,
}: DefaultValueProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const getFormChangeHandler = useCallback(({ key, type }: DefaultValueForm) => {
return (payload: any) => {
let value
@ -35,15 +33,6 @@ const DefaultValue = ({
<div className="body-xs-regular mb-2 text-text-tertiary">
{t('nodes.common.errorHandle.defaultValue.desc', { ns: 'workflow' })}
&nbsp;
<a
href={docLink('/guides/workflow/error-handling/README', {
'zh-Hans': '/guides/workflow/error-handling/readme',
})}
target="_blank"
className="text-text-accent"
>
{t('common.learnMore', { ns: 'workflow' })}
</a>
</div>
<div className="space-y-1">
{

View File

@ -19,7 +19,7 @@ const FailBranchCard = () => {
{t('nodes.common.errorHandle.failBranch.customizeTip', { ns: 'workflow' })}
&nbsp;
<a
href={docLink('/guides/workflow/error-handling/error-type')}
href={docLink('/use-dify/debug/error-type')}
target="_blank"
className="text-text-accent"
>

View File

@ -6,7 +6,6 @@ import { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import ListEmpty from '@/app/components/base/list-empty'
import { useStore } from '@/app/components/workflow/store'
import { useDocLink } from '@/context/i18n'
import VarReferenceVars from './var-reference-vars'
type Props = {
@ -31,7 +30,7 @@ const VarReferencePopup: FC<Props> = ({
const pipelineId = useStore(s => s.pipelineId)
const showManageRagInputFields = useMemo(() => !!pipelineId, [pipelineId])
const setShowInputFieldPanel = useStore(s => s.setShowInputFieldPanel)
const docLink = useDocLink()
// max-h-[300px] overflow-y-auto todo: use portal to handle long list
return (
<div
@ -58,17 +57,6 @@ const VarReferencePopup: FC<Props> = ({
description={(
<div className="system-xs-regular text-text-tertiary">
{t('variableReference.assignedVarsDescription', { ns: 'workflow' })}
<a
target="_blank"
rel="noopener noreferrer"
className="text-text-accent-secondary"
href={docLink('/guides/workflow/variables#conversation-variables', {
'zh-Hans': '/guides/workflow/variables#会话变量',
'ja-JP': '/guides/workflow/variables#会話変数',
})}
>
{t('variableReference.conversationVars', { ns: 'workflow' })}
</a>
</div>
)}
/>

View File

@ -31,7 +31,7 @@ const Instruction = ({
<div className="system-xs-regular">
<p className="text-text-tertiary">{t('nodes.knowledgeBase.chunkStructureTip.message', { ns: 'workflow' })}</p>
<a
href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/chunking-and-cleaning-text')}
href={docLink('/use-dify/knowledge/create-knowledge/chunking-and-cleaning-text')}
target="_blank"
rel="noopener noreferrer"
className="text-text-accent"

View File

@ -11,6 +11,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import { Field } from '@/app/components/workflow/nodes/_base/components/layout'
import { useDocLink } from '@/context/i18n'
import { useRetrievalSetting } from './hooks'
import SearchMethodOption from './search-method-option'
@ -50,6 +51,7 @@ const RetrievalSetting = ({
showMultiModalTip,
}: RetrievalSettingProps) => {
const { t } = useTranslation()
const docLink = useDocLink()
const {
options,
hybridSearchModeOptions,
@ -61,7 +63,7 @@ const RetrievalSetting = ({
title: t('form.retrievalSetting.title', { ns: 'datasetSettings' }),
subTitle: (
<div className="body-xs-regular flex items-center text-text-tertiary">
<a target="_blank" rel="noopener noreferrer" href="https://docs.dify.ai/guides/knowledge-base/create-knowledge-and-upload-documents#id-4-retrieval-settings" 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>
&nbsp;
{t('nodes.knowledgeBase.aboutRetrieval', { ns: 'workflow' })}
</div>

View File

@ -1,14 +1,12 @@
import type { FC } from 'react'
import type { SchemaRoot } from '../../types'
import { RiBracesLine, RiCloseLine, RiExternalLinkLine, RiTimelineView } from '@remixicon/react'
import * as React from 'react'
import { RiBracesLine, RiCloseLine, RiTimelineView } from '@remixicon/react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import Divider from '@/app/components/base/divider'
import Toast from '@/app/components/base/toast'
import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
import { useDocLink } from '@/context/i18n'
import { SegmentedControl } from '../../../../../base/segmented-control'
import { Type } from '../../types'
import {
@ -55,7 +53,6 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
onClose,
}) => {
const { t } = useTranslation()
const docLink = useDocLink()
const [currentTab, setCurrentTab] = useState(SchemaView.VisualEditor)
const [jsonSchema, setJsonSchema] = useState(defaultSchema || DEFAULT_SCHEMA)
const [json, setJson] = useState(() => JSON.stringify(jsonSchema, null, 2))
@ -253,15 +250,6 @@ const JsonSchemaConfig: FC<JsonSchemaConfigProps> = ({
</div>
{/* Footer */}
<div className="flex items-center gap-x-2 p-6 pt-5">
<a
className="flex grow items-center gap-x-1 text-text-accent"
href={docLink('/guides/workflow/structured-outputs')}
target="_blank"
rel="noopener noreferrer"
>
<span className="system-xs-regular">{t('nodes.llm.jsonSchema.doc', { ns: 'workflow' })}</span>
<RiExternalLinkLine className="h-3 w-3" />
</a>
<div className="flex items-center gap-x-3">
<div className="flex items-center gap-x-2">
<Button variant="secondary" onClick={handleResetDefaults}>

View File

@ -21,13 +21,11 @@ import VariableItem from '@/app/components/workflow/panel/chat-variable-panel/co
import VariableModalTrigger from '@/app/components/workflow/panel/chat-variable-panel/components/variable-modal-trigger'
import { useStore } from '@/app/components/workflow/store'
import { BlockEnum } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
import { cn } from '@/utils/classnames'
import useInspectVarsCrud from '../../hooks/use-inspect-vars-crud'
const ChatVariablePanel = () => {
const { t } = useTranslation()
const docLink = useDocLink()
const store = useStoreApi()
const setShowChatVariablePanel = useStore(s => s.setShowChatVariablePanel)
const varList = useStore(s => s.conversationVariables) as ConversationVariable[]
@ -148,17 +146,6 @@ const ChatVariablePanel = () => {
<div className="system-2xs-medium-uppercase inline-block rounded-[5px] border border-divider-deep px-[5px] py-[3px] text-text-tertiary">TIPS</div>
<div className="system-sm-regular mb-4 mt-1 text-text-secondary">
{t('chatVariable.panelDescription', { ns: 'workflow' })}
<a
target="_blank"
rel="noopener noreferrer"
className="text-text-accent"
href={docLink('/guides/workflow/variables#conversation-variables', {
'zh-Hans': '/guides/workflow/variables#会话变量',
'ja-JP': '/guides/workflow/variables#会話変数',
})}
>
{t('chatVariable.docLink', { ns: 'workflow' })}
</a>
</div>
<div className="flex items-center gap-2">
<div className="radius-lg flex flex-col border border-workflow-block-border bg-workflow-block-bg p-3 pb-4 shadow-md">

View File

@ -211,7 +211,7 @@ const NodePanel: FC<Props> = ({
<StatusContainer status="stopped">
{nodeInfo.error}
<a
href={docLink('/guides/workflow/error-handling/error-type')}
href={docLink('/use-dify/debug/error-type')}
target="_blank"
className="text-text-accent"
>

View File

@ -139,7 +139,7 @@ const StatusPanel: FC<ResultProps> = ({
<div className="system-xs-medium text-text-warning">
{error}
<a
href={docLink('/guides/workflow/error-handling/error-type')}
href={docLink('/use-dify/debug/error-type')}
target="_blank"
className="text-text-accent"
>

View File

@ -1,9 +1,11 @@
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { Variable02 } from '@/app/components/base/icons/src/vender/solid/development'
import { useDocLink } from '@/context/i18n'
const Empty: FC = () => {
const { t } = useTranslation()
const docLink = useDocLink()
return (
<div className="flex h-full flex-col gap-3 rounded-xl bg-background-section p-8">
@ -15,7 +17,7 @@ const Empty: FC = () => {
<div className="system-xs-regular text-text-tertiary">{t('debug.variableInspect.emptyTip', { ns: 'workflow' })}</div>
<a
className="system-xs-regular cursor-pointer text-text-accent"
href="https://docs.dify.ai/en/guides/workflow/debug-and-preview/variable-inspect"
href={docLink('/use-dify/debug/variable-inspect')}
target="_blank"
rel="noopener noreferrer"
>

View File

@ -163,7 +163,7 @@ const EducationApplyAge = () => {
<div className="mb-4 mt-5 h-px bg-gradient-to-r from-[rgba(16,24,40,0.08)]"></div>
<a
className="system-xs-regular flex items-center text-text-accent"
href={docLink('/getting-started/dify-for-education')}
href={docLink('/use-dify/workspace/subscription-management#dify-for-education')}
target="_blank"
>
{t('learn', { ns: 'education' })}

View File

@ -25,7 +25,7 @@ const i18nPrefix = 'notice'
const ExpireNoticeModal: React.FC<Props> = ({ expireAt, expired, onClose }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const eduDocLink = docLink('/getting-started/dify-for-education')
const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education')
const { formatTime } = useTimestamp()
const setShowPricingModal = useModalContextSelector(s => s.setShowPricingModal)
const { mutateAsync } = useEducationVerify()

View File

@ -34,7 +34,7 @@ function Confirm({
const docLink = useDocLink()
const dialogRef = useRef<HTMLDivElement>(null)
const [isVisible, setIsVisible] = useState(isShow)
const eduDocLink = docLink('/getting-started/dify-for-education')
const eduDocLink = docLink('/use-dify/workspace/subscription-management#dify-for-education')
const handleClick = () => {
window.open(eduDocLink, '_blank', 'noopener,noreferrer')

View File

@ -14,7 +14,7 @@ import { zodSubmitValidator } from '@/app/components/base/form/utils/zod-submit-
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
import { useDocLink } from '@/context/i18n'
import { LICENSE_LINK } from '@/constants/link'
import useDocumentTitle from '@/hooks/use-document-title'
import { fetchInitValidateStatus, fetchSetupStatus, login, setup } from '@/service/common'
import { cn } from '@/utils/classnames'
@ -35,7 +35,6 @@ const accountFormSchema = z.object({
const InstallForm = () => {
useDocumentTitle('')
const { t, i18n } = useTranslation()
const docLink = useDocLink()
const router = useRouter()
const [showPassword, setShowPassword] = React.useState(false)
const [loading, setLoading] = React.useState(true)
@ -219,7 +218,7 @@ const InstallForm = () => {
className="text-text-accent"
target="_blank"
rel="noopener noreferrer"
href={docLink('/policies/open-source')}
href={LICENSE_LINK}
>
{t('license.link', { ns: 'login' })}
</Link>

View File

@ -11,8 +11,8 @@ import Input from '@/app/components/base/input'
import Loading from '@/app/components/base/loading'
import { SimpleSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast'
import { LICENSE_LINK } from '@/constants/link'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { useDocLink } from '@/context/i18n'
import { setLocaleOnClient } from '@/i18n-config'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import { activateMember } from '@/service/common'
@ -23,7 +23,6 @@ import { resolvePostLoginRedirect } from '../utils/post-login-redirect'
export default function InviteSettingsPage() {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const docLink = useDocLink()
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('invite_token') as string)
@ -161,7 +160,7 @@ export default function InviteSettingsPage() {
className="system-xs-medium text-text-accent-secondary"
target="_blank"
rel="noopener noreferrer"
href={docLink('/policies/open-source')}
href={LICENSE_LINK}
>
{t('license.link', { ns: 'login' })}
</Link>

View File

@ -2,14 +2,13 @@
import type { Reducer } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import * as React from 'react'
import { useReducer } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { SimpleSelect } from '@/app/components/base/select'
import Toast from '@/app/components/base/toast'
import Tooltip from '@/app/components/base/tooltip'
import { useDocLink } from '@/context/i18n'
import { LICENSE_LINK } from '@/constants/link'
import { languages, LanguagesSupported } from '@/i18n-config/language'
import { useOneMoreStep } from '@/service/use-common'
import { timezones } from '@/utils/timezone'
@ -48,7 +47,6 @@ const reducer: Reducer<IState, IAction> = (state: IState, action: IAction) => {
const OneMoreStep = () => {
const { t } = useTranslation()
const docLink = useDocLink()
const router = useRouter()
const searchParams = useSearchParams()
@ -159,7 +157,7 @@ const OneMoreStep = () => {
className="system-xs-medium text-text-accent-secondary"
target="_blank"
rel="noopener noreferrer"
href={docLink('/policies/agreement/README')}
href={LICENSE_LINK}
>
{t('license.link', { ns: 'login' })}
</Link>

1
web/constants/link.ts Normal file
View File

@ -0,0 +1 @@
export const LICENSE_LINK = 'https://github.com/langgenius/dify?tab=License-1-ov-file#readme'

View File

@ -1,6 +1,8 @@
import type { Locale } from '@/i18n-config/language'
import type { DocPathWithoutLang } from '@/types/doc-paths'
import { useTranslation } from '#i18n'
import { getDocLanguage, getLanguage, getPricingPageLanguage } from '@/i18n-config/language'
import { apiReferencePathTranslations } from '@/types/doc-paths'
export const useLocale = () => {
const { i18n } = useTranslation()
@ -19,15 +21,24 @@ export const useGetPricingPageLanguage = () => {
}
export const defaultDocBaseUrl = 'https://docs.dify.ai'
export const useDocLink = (baseUrl?: string): ((path?: string, pathMap?: { [index: string]: string }) => string) => {
export type DocPathMap = Partial<Record<Locale, DocPathWithoutLang>>
export const useDocLink = (baseUrl?: string): ((path?: DocPathWithoutLang, pathMap?: DocPathMap) => string) => {
let baseDocUrl = baseUrl || defaultDocBaseUrl
baseDocUrl = (baseDocUrl.endsWith('/')) ? baseDocUrl.slice(0, -1) : baseDocUrl
const locale = useLocale()
const docLanguage = getDocLanguage(locale)
return (path?: string, pathMap?: { [index: string]: string }): string => {
return (path?: DocPathWithoutLang, pathMap?: DocPathMap): string => {
const pathUrl = path || ''
let targetPath = (pathMap) ? pathMap[locale] || pathUrl : pathUrl
targetPath = (targetPath.startsWith('/')) ? targetPath.slice(1) : targetPath
return `${baseDocUrl}/${docLanguage}/${targetPath}`
// Translate API reference paths for non-English locales
if (targetPath.startsWith('/api-reference/') && docLanguage !== 'en') {
const translatedPath = apiReferencePathTranslations[targetPath]?.[docLanguage as 'zh' | 'ja']
if (translatedPath)
targetPath = translatedPath
}
return `${baseDocUrl}/${docLanguage}${targetPath}`
}
}

View File

@ -1131,11 +1131,6 @@
"count": 1
}
},
"app/components/base/input-with-copy/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/base/input/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 1
@ -1559,11 +1554,6 @@
"count": 3
}
},
"app/components/billing/usage-info/index.tsx": {
"ts/no-explicit-any": {
"count": 1
}
},
"app/components/custom/custom-web-app-brand/index.spec.tsx": {
"ts/no-explicit-any": {
"count": 7

View File

@ -23,7 +23,7 @@ export default antfu(
},
},
nextjs: true,
ignores: ['public'],
ignores: ['public', 'types/doc-paths.ts'],
typescript: {
overrides: {
'ts/consistent-type-definitions': ['error', 'type'],

View File

@ -1,16 +1,7 @@
import { useMemo } from 'react'
import { useGetLanguage } from '@/context/i18n'
import { useDocLink } from '@/context/i18n'
export const useDatasetApiAccessUrl = () => {
const locale = useGetLanguage()
const docLink = useDocLink()
const apiReferenceUrl = useMemo(() => {
if (locale === 'zh_Hans')
return 'https://docs.dify.ai/api-reference/%E6%95%B0%E6%8D%AE%E9%9B%86'
if (locale === 'ja_JP')
return 'https://docs.dify.ai/api-reference/%E3%83%87%E3%83%BC%E3%82%BF%E3%82%BB%E3%83%83%E3%83%88'
return 'https://docs.dify.ai/api-reference/datasets'
}, [locale])
return apiReferenceUrl
return docLink('/api-reference/datasets/get-knowledge-base-list')
}

View File

@ -1,3 +1,4 @@
import type { DocLanguage } from '@/types/doc-paths'
import data from './languages'
export type Item = {
@ -22,9 +23,9 @@ export const getLanguage = (locale: Locale): Locale => {
return LanguagesSupported[0].replace('-', '_') as Locale
}
const DOC_LANGUAGE: Record<string, string> = {
'zh-Hans': 'zh-hans',
'ja-JP': 'ja-jp',
const DOC_LANGUAGE: Record<string, DocLanguage | undefined> = {
'zh-Hans': 'zh',
'ja-JP': 'ja',
'en-US': 'en',
}
@ -56,7 +57,7 @@ export const localeMap: Record<Locale, string> = {
'ar-TN': 'ar',
}
export const getDocLanguage = (locale: string) => {
export const getDocLanguage = (locale: string): DocLanguage => {
return DOC_LANGUAGE[locale] || 'en'
}

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "حصة رفع المستندات",
"usagePage.perMonth": "شهريًا",
"usagePage.resetsIn": "يتم إعادة التعيين في {{count,number}} أيام",
"usagePage.storageThresholdTooltip": "يتم عرض الاستخدام التفصيلي بمجرد أن تتجاوز مساحة التخزين 50 ميجابايت.",
"usagePage.teamMembers": "أعضاء الفريق",
"usagePage.triggerEvents": "أحداث المشغل",
"usagePage.vectorSpace": "تخزين بيانات المعرفة",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "حصة",
"modelProvider.card.quotaExhausted": "نفدت الحصة",
"modelProvider.card.removeKey": "إزالة مفتاح API",
"modelProvider.card.tip": "ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة التجريبية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tip": "تدعم أرصدة الرسائل نماذج من OpenAI. ستعطى الأولوية للحصة المدفوعة. سيتم استخدام الحصة المجانية بعد نفاد الحصة المدفوعة.",
"modelProvider.card.tokens": "رموز",
"modelProvider.collapse": "طي",
"modelProvider.config": "تكوين",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "Dokumenten-Upload-Quota",
"usagePage.perMonth": "pro Monat",
"usagePage.resetsIn": "Setzt in {{count,number}} Tagen zurück",
"usagePage.storageThresholdTooltip": "Die detaillierte Nutzung wird angezeigt, sobald der Speicher 50 MB überschreitet.",
"usagePage.teamMembers": "Teammitglieder",
"usagePage.triggerEvents": "Auslöser-Ereignisse",
"usagePage.vectorSpace": "Wissensdatenbank",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "KONTINGENT",
"modelProvider.card.quotaExhausted": "Kontingent erschöpft",
"modelProvider.card.removeKey": "API-Schlüssel entfernen",
"modelProvider.card.tip": "Der bezahlten Kontingent wird Vorrang gegeben. Das Testkontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tip": "Nachrichtenguthaben unterstützen Modelle von OpenAI. Der bezahlten Kontingent wird Vorrang gegeben. Das kostenlose Kontingent wird nach dem Verbrauch des bezahlten Kontingents verwendet.",
"modelProvider.card.tokens": "Token",
"modelProvider.collapse": "Einklappen",
"modelProvider.config": "Konfigurieren",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "Documents Upload Quota",
"usagePage.perMonth": "per month",
"usagePage.resetsIn": "Resets in {{count,number}} days",
"usagePage.storageThresholdTooltip": "Detailed usage is shown once storage exceeds 50 MB.",
"usagePage.teamMembers": "Team Members",
"usagePage.triggerEvents": "Trigger Events",
"usagePage.vectorSpace": "Knowledge Data Storage",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota exhausted",
"modelProvider.card.removeKey": "Remove API Key",
"modelProvider.card.tip": "Message Credits supports models from OpenAI, Anthropic, Gemini, xAI, DeepSeek and Tongyi. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tip": "Message Credits supports models from OpenAI. Priority will be given to the paid quota. The free quota will be used after the paid quota is exhausted.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Collapse",
"modelProvider.config": "Config",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "Cuota de carga de documentos",
"usagePage.perMonth": "por mes",
"usagePage.resetsIn": "Se reinicia en {{count,number}} días",
"usagePage.storageThresholdTooltip": "El uso detallado se muestra una vez que el almacenamiento supera los 50 MB.",
"usagePage.teamMembers": "Miembros del equipo",
"usagePage.triggerEvents": "Eventos desencadenantes",
"usagePage.vectorSpace": "Almacenamiento de Datos de Conocimiento",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "CUOTA",
"modelProvider.card.quotaExhausted": "Cuota agotada",
"modelProvider.card.removeKey": "Eliminar CLAVE API",
"modelProvider.card.tip": "Se dará prioridad al uso de la cuota pagada. La cuota de prueba se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tip": "Créditos de mensajes admite modelos de OpenAI. Se dará prioridad a la cuota pagada. La cuota gratuita se utilizará después de que se agote la cuota pagada.",
"modelProvider.card.tokens": "Tokens",
"modelProvider.collapse": "Colapsar",
"modelProvider.config": "Configurar",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "حجم بارگذاری اسناد",
"usagePage.perMonth": "در ماه",
"usagePage.resetsIn": "در {{count,number}} روز بازنشانی می‌شود",
"usagePage.storageThresholdTooltip": "جزئیات استفاده زمانی نمایش داده می‌شود که فضای ذخیره‌سازی از 50 مگابایت بیشتر شود.",
"usagePage.teamMembers": "اعضای تیم",
"usagePage.triggerEvents": "رویدادهای محرک",
"usagePage.vectorSpace": "ذخیره‌سازی داده‌های دانش",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "سهمیه",
"modelProvider.card.quotaExhausted": "سهمیه تمام شده",
"modelProvider.card.removeKey": "حذف کلید API",
"modelProvider.card.tip": "اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه آزمایشی پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tip": "اعتبار پیام از مدل‌های OpenAI پشتیبانی می‌کند. اولویت به سهمیه پرداخت شده داده می‌شود. سهمیه رایگان پس از اتمام سهمیه پرداخت شده استفاده خواهد شد.",
"modelProvider.card.tokens": "توکن‌ها",
"modelProvider.collapse": "جمع کردن",
"modelProvider.config": "پیکربندی",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "Quota de téléchargement de documents",
"usagePage.perMonth": "par mois",
"usagePage.resetsIn": "Réinitialisations dans {{count,number}} jours",
"usagePage.storageThresholdTooltip": "L'utilisation détaillée est affichée lorsque le stockage dépasse 50 Mo.",
"usagePage.teamMembers": "Membres de l'équipe",
"usagePage.triggerEvents": "Événements déclencheurs",
"usagePage.vectorSpace": "Stockage de données de connaissance",

View File

@ -350,7 +350,7 @@
"modelProvider.card.quota": "QUOTA",
"modelProvider.card.quotaExhausted": "Quota épuisé",
"modelProvider.card.removeKey": "Supprimer la clé API",
"modelProvider.card.tip": "La priorité sera donnée au quota payant. Le quota d'essai sera utilisé après épuisement du quota payant.",
"modelProvider.card.tip": "Les crédits de messages prennent en charge les modèles d'OpenAI. La priorité sera donnée au quota payant. Le quota gratuit sera utilisé après épuisement du quota payant.",
"modelProvider.card.tokens": "Jetons",
"modelProvider.collapse": "Effondrer",
"modelProvider.config": "Configuration",

View File

@ -172,6 +172,7 @@
"usagePage.documentsUploadQuota": "दस्तावेज़ अपलोड कोटा",
"usagePage.perMonth": "प्रति माह",
"usagePage.resetsIn": "{{count,number}} दिनों में रीसेट होता है",
"usagePage.storageThresholdTooltip": "स्टोरेज 50 MB से अधिक होने पर विस्तृत उपयोग दिखाया जाता है।",
"usagePage.teamMembers": "टीम के सदस्य",
"usagePage.triggerEvents": "उत्तेजक घटनाएँ",
"usagePage.vectorSpace": "ज्ञान डेटा भंडारण",

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