Merge branch 'main' into feat/grouping-branching

# Conflicts:
#	web/package.json
This commit is contained in:
zhsama
2026-01-06 22:00:01 +08:00
156 changed files with 5890 additions and 1553 deletions

View File

@ -7,8 +7,12 @@ logs
# node
node_modules
dist
build
coverage
.husky
.next
.pnpm-store
# vscode
.vscode
@ -22,3 +26,7 @@ node_modules
# Jetbrains
.idea
# git
.git
.gitignore

View File

@ -47,6 +47,8 @@ NEXT_PUBLIC_TOP_K_MAX_VALUE=10
# The maximum number of tokens for segmentation
NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Used by web/docker/entrypoint.sh to overwrite/export NEXT_PUBLIC_INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH at container startup (Docker only)
INDEXING_MAX_SEGMENTATION_TOKENS_LENGTH=4000
# Maximum loop count in the workflow
NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=100

View File

@ -12,7 +12,8 @@ RUN apk add --no-cache tzdata
RUN corepack enable
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
ENV NEXT_PUBLIC_BASE_PATH=""
ARG NEXT_PUBLIC_BASE_PATH=""
ENV NEXT_PUBLIC_BASE_PATH="$NEXT_PUBLIC_BASE_PATH"
# install packages

View File

@ -1,11 +1,8 @@
/* eslint-disable dify-i18n/require-ns-option */
import * as React from 'react'
import { useTranslation } from '#i18n'
import Form from '@/app/components/datasets/settings/form'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
const Settings = async () => {
const locale = await getLocaleOnServer()
const { t } = await getTranslation(locale, 'dataset-settings')
const Settings = () => {
const { t } = useTranslation('datasetSettings')
return (
<div className="h-full overflow-y-auto">

View File

@ -0,0 +1,97 @@
'use client'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import s from '../index.module.css'
type DataSourceTypeSelectorProps = {
currentType: DataSourceType
disabled: boolean
onChange: (type: DataSourceType) => void
onClearPreviews: (type: DataSourceType) => void
}
type DataSourceLabelKey
= | 'stepOne.dataSourceType.file'
| 'stepOne.dataSourceType.notion'
| 'stepOne.dataSourceType.web'
type DataSourceOption = {
type: DataSourceType
iconClass?: string
labelKey: DataSourceLabelKey
}
const DATA_SOURCE_OPTIONS: DataSourceOption[] = [
{
type: DataSourceType.FILE,
labelKey: 'stepOne.dataSourceType.file',
},
{
type: DataSourceType.NOTION,
iconClass: s.notion,
labelKey: 'stepOne.dataSourceType.notion',
},
{
type: DataSourceType.WEB,
iconClass: s.web,
labelKey: 'stepOne.dataSourceType.web',
},
]
/**
* Data source type selector component for choosing between file, notion, and web sources.
*/
function DataSourceTypeSelector({
currentType,
disabled,
onChange,
onClearPreviews,
}: DataSourceTypeSelectorProps) {
const { t } = useTranslation()
const isWebEnabled = ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL
const handleTypeChange = useCallback((type: DataSourceType) => {
if (disabled)
return
onChange(type)
onClearPreviews(type)
}, [disabled, onChange, onClearPreviews])
const visibleOptions = useMemo(() => DATA_SOURCE_OPTIONS.filter((option) => {
if (option.type === DataSourceType.WEB)
return isWebEnabled
return true
}), [isWebEnabled])
return (
<div className="mb-8 grid grid-cols-3 gap-4">
{visibleOptions.map(option => (
<div
key={option.type}
className={cn(
s.dataSourceItem,
'system-sm-medium',
currentType === option.type && s.active,
disabled && currentType !== option.type && s.disabled,
)}
onClick={() => handleTypeChange(option.type)}
>
<span className={cn(s.datasetIcon, option.iconClass)} />
<span
title={t(option.labelKey, { ns: 'datasetCreation' }) || undefined}
className="truncate"
>
{t(option.labelKey, { ns: 'datasetCreation' })}
</span>
</div>
))}
</div>
)
}
export default DataSourceTypeSelector

View File

@ -0,0 +1,3 @@
export { default as DataSourceTypeSelector } from './data-source-type-selector'
export { default as NextStepButton } from './next-step-button'
export { default as PreviewPanel } from './preview-panel'

View File

@ -0,0 +1,30 @@
'use client'
import { RiArrowRightLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
type NextStepButtonProps = {
disabled: boolean
onClick: () => void
}
/**
* Reusable next step button component for dataset creation flow.
*/
function NextStepButton({ disabled, onClick }: NextStepButtonProps) {
const { t } = useTranslation()
return (
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={disabled} variant="primary" onClick={onClick}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
)
}
export default NextStepButton

View File

@ -0,0 +1,62 @@
'use client'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useTranslation } from 'react-i18next'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import FilePreview from '../../file-preview'
import NotionPagePreview from '../../notion-page-preview'
import WebsitePreview from '../../website/preview'
type PreviewPanelProps = {
currentFile: File | undefined
currentNotionPage: NotionPage | undefined
currentWebsite: CrawlResultItem | undefined
notionCredentialId: string
isShowPlanUpgradeModal: boolean
hideFilePreview: () => void
hideNotionPagePreview: () => void
hideWebsitePreview: () => void
hidePlanUpgradeModal: () => void
}
/**
* Right panel component for displaying file, notion page, or website previews.
*/
function PreviewPanel({
currentFile,
currentNotionPage,
currentWebsite,
notionCredentialId,
isShowPlanUpgradeModal,
hideFilePreview,
hideNotionPagePreview,
hideWebsitePreview,
hidePlanUpgradeModal,
}: PreviewPanelProps) {
const { t } = useTranslation()
return (
<div className="h-full w-1/2 overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (
<NotionPagePreview
currentPage={currentNotionPage}
hidePreview={hideNotionPagePreview}
notionCredentialId={notionCredentialId}
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('upgrade.uploadMultiplePages.title', { ns: 'billing' })!}
description={t('upgrade.uploadMultiplePages.description', { ns: 'billing' })!}
/>
)}
</div>
)
}
export default PreviewPanel

View File

@ -0,0 +1,2 @@
export { default as usePreviewState } from './use-preview-state'
export type { PreviewActions, PreviewState, UsePreviewStateReturn } from './use-preview-state'

View File

@ -0,0 +1,70 @@
'use client'
import type { NotionPage } from '@/models/common'
import type { CrawlResultItem } from '@/models/datasets'
import { useCallback, useState } from 'react'
export type PreviewState = {
currentFile: File | undefined
currentNotionPage: NotionPage | undefined
currentWebsite: CrawlResultItem | undefined
}
export type PreviewActions = {
showFilePreview: (file: File) => void
hideFilePreview: () => void
showNotionPagePreview: (page: NotionPage) => void
hideNotionPagePreview: () => void
showWebsitePreview: (website: CrawlResultItem) => void
hideWebsitePreview: () => void
}
export type UsePreviewStateReturn = PreviewState & PreviewActions
/**
* Custom hook for managing preview state across different data source types.
* Handles file, notion page, and website preview visibility.
*/
function usePreviewState(): UsePreviewStateReturn {
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const showFilePreview = useCallback((file: File) => {
setCurrentFile(file)
}, [])
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const showNotionPagePreview = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const showWebsitePreview = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
return {
currentFile,
currentNotionPage,
currentWebsite,
showFilePreview,
hideFilePreview,
showNotionPagePreview,
hideNotionPagePreview,
showWebsitePreview,
hideWebsitePreview,
}
}
export default usePreviewState

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,25 @@
'use client'
import type { DataSourceAuth } from '@/app/components/header/account-setting/data-source-page-new/types'
import type { DataSourceProvider, NotionPage } from '@/models/common'
import type { CrawlOptions, CrawlResultItem, FileItem } from '@/models/datasets'
import { RiArrowRightLine, RiFolder6Line } from '@remixicon/react'
import { RiFolder6Line } from '@remixicon/react'
import { useBoolean } from 'ahooks'
import * as React from 'react'
import { useCallback, useMemo, useState } from 'react'
import { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import NotionConnector from '@/app/components/base/notion-connector'
import { NotionPageSelector } from '@/app/components/base/notion-page-selector'
import PlanUpgradeModal from '@/app/components/billing/plan-upgrade-modal'
import { Plan } from '@/app/components/billing/type'
import VectorSpaceFull from '@/app/components/billing/vector-space-full'
import { ENABLE_WEBSITE_FIRECRAWL, ENABLE_WEBSITE_JINAREADER, ENABLE_WEBSITE_WATERCRAWL } from '@/config'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import { useProviderContext } from '@/context/provider-context'
import { DataSourceType } from '@/models/datasets'
import { cn } from '@/utils/classnames'
import EmptyDatasetCreationModal from '../empty-dataset-creation-modal'
import FilePreview from '../file-preview'
import FileUploader from '../file-uploader'
import NotionPagePreview from '../notion-page-preview'
import Website from '../website'
import WebsitePreview from '../website/preview'
import { DataSourceTypeSelector, NextStepButton, PreviewPanel } from './components'
import { usePreviewState } from './hooks'
import s from './index.module.css'
import UpgradeCard from './upgrade-card'
@ -50,6 +46,24 @@ type IStepOneProps = {
authedDataSourceList: DataSourceAuth[]
}
// Helper function to check if notion is authenticated
function checkNotionAuth(authedDataSourceList: DataSourceAuth[]): boolean {
const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource')
return Boolean(notionSource && notionSource.credentials_list.length > 0)
}
// Helper function to get notion credential list
function getNotionCredentialList(authedDataSourceList: DataSourceAuth[]) {
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}
// Lookup table for checking multiple items by data source type
const MULTIPLE_ITEMS_CHECK: Record<DataSourceType, (props: { files: FileItem[], notionPages: NotionPage[], websitePages: CrawlResultItem[] }) => boolean> = {
[DataSourceType.FILE]: ({ files }) => files.length > 1,
[DataSourceType.NOTION]: ({ notionPages }) => notionPages.length > 1,
[DataSourceType.WEB]: ({ websitePages }) => websitePages.length > 1,
}
const StepOne = ({
datasetId,
dataSourceType: inCreatePageDataSourceType,
@ -72,76 +86,47 @@ const StepOne = ({
onCrawlOptionsChange,
authedDataSourceList,
}: IStepOneProps) => {
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const [showModal, setShowModal] = useState(false)
const [currentFile, setCurrentFile] = useState<File | undefined>()
const [currentNotionPage, setCurrentNotionPage] = useState<NotionPage | undefined>()
const [currentWebsite, setCurrentWebsite] = useState<CrawlResultItem | undefined>()
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset)
const { plan, enableBilling } = useProviderContext()
const modalShowHandle = () => setShowModal(true)
const modalCloseHandle = () => setShowModal(false)
// Preview state management
const {
currentFile,
currentNotionPage,
currentWebsite,
showFilePreview,
hideFilePreview,
showNotionPagePreview,
hideNotionPagePreview,
showWebsitePreview,
hideWebsitePreview,
} = usePreviewState()
const updateCurrentFile = useCallback((file: File) => {
setCurrentFile(file)
}, [])
// Empty dataset modal state
const [showModal, { setTrue: openModal, setFalse: closeModal }] = useBoolean(false)
const hideFilePreview = useCallback(() => {
setCurrentFile(undefined)
}, [])
const updateCurrentPage = useCallback((page: NotionPage) => {
setCurrentNotionPage(page)
}, [])
const hideNotionPagePreview = useCallback(() => {
setCurrentNotionPage(undefined)
}, [])
const updateWebsite = useCallback((website: CrawlResultItem) => {
setCurrentWebsite(website)
}, [])
const hideWebsitePreview = useCallback(() => {
setCurrentWebsite(undefined)
}, [])
// Plan upgrade modal state
const [isShowPlanUpgradeModal, { setTrue: showPlanUpgradeModal, setFalse: hidePlanUpgradeModal }] = useBoolean(false)
// Computed values
const shouldShowDataSourceTypeList = !datasetId || (datasetId && !dataset?.data_source_type)
const isInCreatePage = shouldShowDataSourceTypeList
const dataSourceType = isInCreatePage ? inCreatePageDataSourceType : dataset?.data_source_type
const { plan, enableBilling } = useProviderContext()
const allFileLoaded = (files.length > 0 && files.every(file => file.file.id))
const hasNotin = notionPages.length > 0
// Default to FILE type when no type is provided from either source
const dataSourceType = isInCreatePage
? (inCreatePageDataSourceType ?? DataSourceType.FILE)
: (dataset?.data_source_type ?? DataSourceType.FILE)
const allFileLoaded = files.length > 0 && files.every(file => file.file.id)
const hasNotion = notionPages.length > 0
const isVectorSpaceFull = plan.usage.vectorSpace >= plan.total.vectorSpace
const isShowVectorSpaceFull = (allFileLoaded || hasNotin) && isVectorSpaceFull && enableBilling
const isShowVectorSpaceFull = (allFileLoaded || hasNotion) && isVectorSpaceFull && enableBilling
const supportBatchUpload = !enableBilling || plan.type !== Plan.sandbox
const notSupportBatchUpload = !supportBatchUpload
const [isShowPlanUpgradeModal, {
setTrue: showPlanUpgradeModal,
setFalse: hidePlanUpgradeModal,
}] = useBoolean(false)
const onStepChange = useCallback(() => {
if (notSupportBatchUpload) {
let isMultiple = false
if (dataSourceType === DataSourceType.FILE && files.length > 1)
isMultiple = true
const isNotionAuthed = useMemo(() => checkNotionAuth(authedDataSourceList), [authedDataSourceList])
const notionCredentialList = useMemo(() => getNotionCredentialList(authedDataSourceList), [authedDataSourceList])
if (dataSourceType === DataSourceType.NOTION && notionPages.length > 1)
isMultiple = true
if (dataSourceType === DataSourceType.WEB && websitePages.length > 1)
isMultiple = true
if (isMultiple) {
showPlanUpgradeModal()
return
}
}
doOnStepChange()
}, [dataSourceType, doOnStepChange, files.length, notSupportBatchUpload, notionPages.length, showPlanUpgradeModal, websitePages.length])
const nextDisabled = useMemo(() => {
const fileNextDisabled = useMemo(() => {
if (!files.length)
return true
if (files.some(file => !file.file.id))
@ -149,109 +134,50 @@ const StepOne = ({
return isShowVectorSpaceFull
}, [files, isShowVectorSpaceFull])
const isNotionAuthed = useMemo(() => {
if (!authedDataSourceList)
return false
const notionSource = authedDataSourceList.find(item => item.provider === 'notion_datasource')
if (!notionSource)
return false
return notionSource.credentials_list.length > 0
}, [authedDataSourceList])
// Clear previews when switching data source type
const handleClearPreviews = useCallback((newType: DataSourceType) => {
if (newType !== DataSourceType.FILE)
hideFilePreview()
if (newType !== DataSourceType.NOTION)
hideNotionPagePreview()
if (newType !== DataSourceType.WEB)
hideWebsitePreview()
}, [hideFilePreview, hideNotionPagePreview, hideWebsitePreview])
const notionCredentialList = useMemo(() => {
return authedDataSourceList.find(item => item.provider === 'notion_datasource')?.credentials_list || []
}, [authedDataSourceList])
// Handle step change with batch upload check
const onStepChange = useCallback(() => {
if (!supportBatchUpload && dataSourceType) {
const checkFn = MULTIPLE_ITEMS_CHECK[dataSourceType]
if (checkFn?.({ files, notionPages, websitePages })) {
showPlanUpgradeModal()
return
}
}
doOnStepChange()
}, [dataSourceType, doOnStepChange, files, supportBatchUpload, notionPages, showPlanUpgradeModal, websitePages])
return (
<div className="h-full w-full overflow-x-auto">
<div className="flex h-full w-full min-w-[1440px]">
{/* Left Panel - Form */}
<div className="relative h-full w-1/2 overflow-y-auto">
<div className="flex justify-end">
<div className={cn(s.form)}>
{
shouldShowDataSourceTypeList && (
{shouldShowDataSourceTypeList && (
<>
<div className={cn(s.stepHeader, 'system-md-semibold text-text-secondary')}>
{t('steps.one', { ns: 'datasetCreation' })}
</div>
)
}
{
shouldShowDataSourceTypeList && (
<div className="mb-8 grid grid-cols-3 gap-4">
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.FILE && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.FILE && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.FILE)
hideNotionPagePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon)} />
<span
title={t('stepOne.dataSourceType.file', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.file', { ns: 'datasetCreation' })}
</span>
</div>
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.NOTION && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.NOTION && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.NOTION)
hideFilePreview()
hideWebsitePreview()
}}
>
<span className={cn(s.datasetIcon, s.notion)} />
<span
title={t('stepOne.dataSourceType.notion', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.notion', { ns: 'datasetCreation' })}
</span>
</div>
{(ENABLE_WEBSITE_FIRECRAWL || ENABLE_WEBSITE_JINAREADER || ENABLE_WEBSITE_WATERCRAWL) && (
<div
className={cn(
s.dataSourceItem,
'system-sm-medium',
dataSourceType === DataSourceType.WEB && s.active,
dataSourceTypeDisable && dataSourceType !== DataSourceType.WEB && s.disabled,
)}
onClick={() => {
if (dataSourceTypeDisable)
return
changeType(DataSourceType.WEB)
hideFilePreview()
hideNotionPagePreview()
}}
>
<span className={cn(s.datasetIcon, s.web)} />
<span
title={t('stepOne.dataSourceType.web', { ns: 'datasetCreation' })!}
className="truncate"
>
{t('stepOne.dataSourceType.web', { ns: 'datasetCreation' })}
</span>
</div>
)}
</div>
)
}
<DataSourceTypeSelector
currentType={dataSourceType}
disabled={dataSourceTypeDisable}
onChange={changeType}
onClearPreviews={handleClearPreviews}
/>
</>
)}
{/* File Data Source */}
{dataSourceType === DataSourceType.FILE && (
<>
<FileUploader
@ -260,7 +186,7 @@ const StepOne = ({
prepareFileList={updateFileList}
onFileListUpdate={updateFileList}
onFileUpdate={updateFile}
onPreview={updateCurrentFile}
onPreview={showFilePreview}
supportBatchUpload={supportBatchUpload}
/>
{isShowVectorSpaceFull && (
@ -268,24 +194,17 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={nextDisabled} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
{
enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle"></div>
<UpgradeCard />
</div>
)
}
<NextStepButton disabled={fileNextDisabled} onClick={onStepChange} />
{enableBilling && plan.type === Plan.sandbox && files.length > 0 && (
<div className="mt-5">
<div className="mb-4 h-px bg-divider-subtle" />
<UpgradeCard />
</div>
)}
</>
)}
{/* Notion Data Source */}
{dataSourceType === DataSourceType.NOTION && (
<>
{!isNotionAuthed && <NotionConnector onSetting={onSetting} />}
@ -295,7 +214,7 @@ const StepOne = ({
<NotionPageSelector
value={notionPages.map(page => page.page_id)}
onSelect={updateNotionPages}
onPreview={updateCurrentPage}
onPreview={showNotionPagePreview}
credentialList={notionCredentialList}
onSelectCredential={updateNotionCredentialId}
datasetId={datasetId}
@ -306,23 +225,21 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !notionPages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
<NextStepButton
disabled={isShowVectorSpaceFull || !notionPages.length}
onClick={onStepChange}
/>
</>
)}
</>
)}
{/* Web Data Source */}
{dataSourceType === DataSourceType.WEB && (
<>
<div className={cn('mb-8 w-[640px]', !shouldShowDataSourceTypeList && 'mt-12')}>
<Website
onPreview={updateWebsite}
onPreview={showWebsitePreview}
checkedCrawlResult={websitePages}
onCheckedCrawlResultChange={updateWebsitePages}
onCrawlProviderChange={onWebsiteCrawlProviderChange}
@ -337,48 +254,43 @@ const StepOne = ({
<VectorSpaceFull />
</div>
)}
<div className="flex max-w-[640px] justify-end gap-2">
<Button disabled={isShowVectorSpaceFull || !websitePages.length} variant="primary" onClick={onStepChange}>
<span className="flex gap-0.5 px-[10px]">
<span className="px-0.5">{t('stepOne.button', { ns: 'datasetCreation' })}</span>
<RiArrowRightLine className="size-4" />
</span>
</Button>
</div>
<NextStepButton
disabled={isShowVectorSpaceFull || !websitePages.length}
onClick={onStepChange}
/>
</>
)}
{/* Empty Dataset Creation Link */}
{!datasetId && (
<>
<div className="my-8 h-px max-w-[640px] bg-divider-regular" />
<span className="inline-flex cursor-pointer items-center text-[13px] leading-4 text-text-accent" onClick={modalShowHandle}>
<span
className="inline-flex cursor-pointer items-center text-[13px] leading-4 text-text-accent"
onClick={openModal}
>
<RiFolder6Line className="mr-1 size-4" />
{t('stepOne.emptyDatasetCreation', { ns: 'datasetCreation' })}
</span>
</>
)}
</div>
<EmptyDatasetCreationModal show={showModal} onHide={modalCloseHandle} />
<EmptyDatasetCreationModal show={showModal} onHide={closeModal} />
</div>
</div>
<div className="h-full w-1/2 overflow-y-auto">
{currentFile && <FilePreview file={currentFile} hidePreview={hideFilePreview} />}
{currentNotionPage && (
<NotionPagePreview
currentPage={currentNotionPage}
hidePreview={hideNotionPagePreview}
notionCredentialId={notionCredentialId}
/>
)}
{currentWebsite && <WebsitePreview payload={currentWebsite} hidePreview={hideWebsitePreview} />}
{isShowPlanUpgradeModal && (
<PlanUpgradeModal
show
onClose={hidePlanUpgradeModal}
title={t('upgrade.uploadMultiplePages.title', { ns: 'billing' })!}
description={t('upgrade.uploadMultiplePages.description', { ns: 'billing' })!}
/>
)}
</div>
{/* Right Panel - Preview */}
<PreviewPanel
currentFile={currentFile}
currentNotionPage={currentNotionPage}
currentWebsite={currentWebsite}
notionCredentialId={notionCredentialId}
isShowPlanUpgradeModal={isShowPlanUpgradeModal}
hideFilePreview={hideFilePreview}
hideNotionPagePreview={hideNotionPagePreview}
hideWebsitePreview={hideWebsitePreview}
hidePlanUpgradeModal={hidePlanUpgradeModal}
/>
</div>
</div>
)

View File

@ -31,14 +31,19 @@ const FixedModelProvider = ['langgenius/openai/openai', 'langgenius/anthropic/an
const ModelProviderPage = ({ searchText }: Props) => {
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
const { t } = useTranslation()
const { data: textGenerationDefaultModel } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
const { data: textGenerationDefaultModel, isLoading: isTextGenerationDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textGeneration)
const { data: embeddingsDefaultModel, isLoading: isEmbeddingsDefaultModelLoading } = useDefaultModel(ModelTypeEnum.textEmbedding)
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
const { modelProviders: providers } = useProviderContext()
const { enable_marketplace } = useGlobalPublicStore(s => s.systemFeatures)
const defaultModelNotConfigured = !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|| isEmbeddingsDefaultModelLoading
|| isRerankDefaultModelLoading
|| isSpeech2textDefaultModelLoading
|| isTTSDefaultModelLoading
const defaultModelNotConfigured = !isDefaultModelLoading && !textGenerationDefaultModel && !embeddingsDefaultModel && !speech2textDefaultModel && !rerankDefaultModel && !ttsDefaultModel
const [configuredProviders, notConfiguredProviders] = useMemo(() => {
const configuredProviders: ModelProvider[] = []
const notConfiguredProviders: ModelProvider[] = []
@ -106,6 +111,7 @@ const ModelProviderPage = ({ searchText }: Props) => {
rerankDefaultModel={rerankDefaultModel}
speech2textDefaultModel={speech2textDefaultModel}
ttsDefaultModel={ttsDefaultModel}
isLoading={isDefaultModelLoading}
/>
</div>
</div>

View File

@ -3,7 +3,7 @@ import type {
DefaultModel,
DefaultModelResponse,
} from '../declarations'
import { RiEqualizer2Line } from '@remixicon/react'
import { RiEqualizer2Line, RiLoader2Line } from '@remixicon/react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
@ -32,6 +32,7 @@ type SystemModelSelectorProps = {
speech2textDefaultModel: DefaultModelResponse | undefined
ttsDefaultModel: DefaultModelResponse | undefined
notConfigured: boolean
isLoading?: boolean
}
const SystemModel: FC<SystemModelSelectorProps> = ({
textGenerationDefaultModel,
@ -40,6 +41,7 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
speech2textDefaultModel,
ttsDefaultModel,
notConfigured,
isLoading,
}) => {
const { t } = useTranslation()
const { notify } = useToastContext()
@ -129,13 +131,16 @@ const SystemModel: FC<SystemModelSelectorProps> = ({
crossAxis: 8,
}}
>
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
<PortalToFollowElemTrigger asChild onClick={() => setOpen(v => !v)}>
<Button
className="relative"
variant={notConfigured ? 'primary' : 'secondary'}
size="small"
disabled={isLoading}
>
<RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />
{isLoading
? <RiLoader2Line className="mr-1 h-3.5 w-3.5 animate-spin" />
: <RiEqualizer2Line className="mr-1 h-3.5 w-3.5" />}
{t('modelProvider.systemModelSettings', { ns: 'common' })}
</Button>
</PortalToFollowElemTrigger>

View File

@ -1,7 +1,5 @@
import { render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Import component after mocks are set up
import Description from './index'
// ================================
@ -30,20 +28,18 @@ const commonTranslations: Record<string, string> = {
'operation.in': 'in',
}
// Mock getLocaleOnServer and translate
vi.mock('@/i18n-config/server', () => ({
getLocaleOnServer: vi.fn(() => Promise.resolve(mockDefaultLocale)),
getTranslation: vi.fn((locale: string, ns: string) => {
return Promise.resolve({
t: (key: string) => {
if (ns === 'plugin')
return pluginTranslations[key] || key
if (ns === 'common')
return commonTranslations[key] || key
return key
},
})
}),
// Mock i18n hooks
vi.mock('#i18n', () => ({
useLocale: vi.fn(() => mockDefaultLocale),
useTranslation: vi.fn((ns: string) => ({
t: (key: string) => {
if (ns === 'plugin')
return pluginTranslations[key] || key
if (ns === 'common')
return commonTranslations[key] || key
return key
},
})),
}))
// ================================
@ -59,29 +55,29 @@ describe('Description', () => {
// Rendering Tests
// ================================
describe('Rendering', () => {
it('should render without crashing', async () => {
const { container } = render(await Description({}))
it('should render without crashing', () => {
const { container } = render(<Description />)
expect(container.firstChild).toBeInTheDocument()
})
it('should render h1 heading with empower text', async () => {
render(await Description({}))
it('should render h1 heading with empower text', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeInTheDocument()
expect(heading).toHaveTextContent('Empower your AI development')
})
it('should render h2 subheading', async () => {
render(await Description({}))
it('should render h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeInTheDocument()
})
it('should apply correct CSS classes to h1', async () => {
render(await Description({}))
it('should apply correct CSS classes to h1', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('title-4xl-semi-bold')
@ -90,8 +86,8 @@ describe('Description', () => {
expect(heading).toHaveClass('text-text-primary')
})
it('should apply correct CSS classes to h2', async () => {
render(await Description({}))
it('should apply correct CSS classes to h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('body-md-regular')
@ -104,14 +100,18 @@ describe('Description', () => {
// Non-Chinese Locale Rendering Tests
// ================================
describe('Non-Chinese Locale Rendering', () => {
it('should render discover text for en-US locale', async () => {
render(await Description({ locale: 'en-US' }))
beforeEach(() => {
mockDefaultLocale = 'en-US'
})
it('should render discover text for en-US locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all category names', async () => {
render(await Description({ locale: 'en-US' }))
it('should render all category names', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
@ -122,36 +122,36 @@ describe('Description', () => {
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render "and" conjunction text', async () => {
render(await Description({ locale: 'en-US' }))
it('should render "and" conjunction text', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('and')
})
it('should render "in" preposition at the end for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should render "in" preposition at the end for non-Chinese locales', () => {
render(<Description />)
expect(screen.getByText('in')).toBeInTheDocument()
})
it('should render Dify Marketplace text at the end for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should render Dify Marketplace text at the end for non-Chinese locales', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render category spans with styled underline effect', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should render category spans with styled underline effect', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.body-md-medium.relative.z-\\[1\\]')
// 7 category spans (models, tools, datasources, triggers, agents, extensions, bundles)
expect(styledSpans.length).toBe(7)
})
it('should apply text-text-secondary class to category spans', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should apply text-text-secondary class to category spans', () => {
const { container } = render(<Description />)
const styledSpans = container.querySelectorAll('.text-text-secondary')
expect(styledSpans.length).toBeGreaterThanOrEqual(7)
@ -162,29 +162,33 @@ describe('Description', () => {
// Chinese (zh-Hans) Locale Rendering Tests
// ================================
describe('Chinese (zh-Hans) Locale Rendering', () => {
it('should render "in" text at the beginning for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
beforeEach(() => {
mockDefaultLocale = 'zh-Hans'
})
it('should render "in" text at the beginning for zh-Hans locale', () => {
render(<Description />)
// In zh-Hans mode, "in" appears at the beginning
const inElements = screen.getAllByText('in')
expect(inElements.length).toBeGreaterThanOrEqual(1)
})
it('should render Dify Marketplace text for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render Dify Marketplace text for zh-Hans locale', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should render discover text for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render discover text for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText(/Discover/)).toBeInTheDocument()
})
it('should render all categories for zh-Hans locale', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render all categories for zh-Hans locale', () => {
render(<Description />)
expect(screen.getByText('Models')).toBeInTheDocument()
expect(screen.getByText('Tools')).toBeInTheDocument()
@ -195,8 +199,8 @@ describe('Description', () => {
expect(screen.getByText('Bundles')).toBeInTheDocument()
})
it('should render both zh-Hans specific elements and shared elements', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render both zh-Hans specific elements and shared elements', () => {
render(<Description />)
// zh-Hans has specific element order: "in" -> Dify Marketplace -> Discover
// then the same category list with "and" -> Bundles
@ -206,61 +210,57 @@ describe('Description', () => {
})
// ================================
// Locale Prop Variations Tests
// Locale Variations Tests
// ================================
describe('Locale Prop Variations', () => {
it('should use default locale when locale prop is undefined', async () => {
describe('Locale Variations', () => {
it('should use en-US locale by default', () => {
mockDefaultLocale = 'en-US'
render(await Description({}))
render(<Description />)
// Should use the default locale from getLocaleOnServer
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should use provided locale prop instead of default', async () => {
it('should handle ja-JP locale as non-Chinese', () => {
mockDefaultLocale = 'ja-JP'
render(await Description({ locale: 'en-US' }))
// The locale prop should be used, triggering non-Chinese rendering
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeInTheDocument()
})
it('should handle ja-JP locale as non-Chinese', async () => {
render(await Description({ locale: 'ja-JP' }))
render(<Description />)
// Should render in non-Chinese format (discover first, then "in Dify Marketplace" at end)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should handle ko-KR locale as non-Chinese', async () => {
render(await Description({ locale: 'ko-KR' }))
it('should handle ko-KR locale as non-Chinese', () => {
mockDefaultLocale = 'ko-KR'
render(<Description />)
// Should render in non-Chinese format
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle de-DE locale as non-Chinese', async () => {
render(await Description({ locale: 'de-DE' }))
it('should handle de-DE locale as non-Chinese', () => {
mockDefaultLocale = 'de-DE'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle fr-FR locale as non-Chinese', async () => {
render(await Description({ locale: 'fr-FR' }))
it('should handle fr-FR locale as non-Chinese', () => {
mockDefaultLocale = 'fr-FR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle pt-BR locale as non-Chinese', async () => {
render(await Description({ locale: 'pt-BR' }))
it('should handle pt-BR locale as non-Chinese', () => {
mockDefaultLocale = 'pt-BR'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
it('should handle es-ES locale as non-Chinese', async () => {
render(await Description({ locale: 'es-ES' }))
it('should handle es-ES locale as non-Chinese', () => {
mockDefaultLocale = 'es-ES'
render(<Description />)
expect(screen.getByText('Empower your AI development')).toBeInTheDocument()
})
@ -270,24 +270,27 @@ describe('Description', () => {
// Conditional Rendering Tests
// ================================
describe('Conditional Rendering', () => {
it('should render zh-Hans specific content when locale is zh-Hans', async () => {
const { container } = render(await Description({ locale: 'zh-Hans' }))
it('should render zh-Hans specific content when locale is zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// zh-Hans has additional span with mr-1 before "in" text at the start
const mrSpan = container.querySelector('span.mr-1')
expect(mrSpan).toBeInTheDocument()
})
it('should render non-Chinese specific content when locale is not zh-Hans', async () => {
render(await Description({ locale: 'en-US' }))
it('should render non-Chinese specific content when locale is not zh-Hans', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// Non-Chinese has "in" and "Dify Marketplace" at the end
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading.textContent).toContain('Dify Marketplace')
})
it('should not render zh-Hans intro content for non-Chinese locales', async () => {
render(await Description({ locale: 'en-US' }))
it('should not render zh-Hans intro content for non-Chinese locales', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
// For en-US, the order should be Discover ... in Dify Marketplace
// The "in" text should only appear once at the end
@ -303,8 +306,9 @@ describe('Description', () => {
expect(inIndex).toBeLessThan(marketplaceIndex)
})
it('should render zh-Hans with proper word order', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render zh-Hans with proper word order', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -323,58 +327,58 @@ describe('Description', () => {
// Category Styling Tests
// ================================
describe('Category Styling', () => {
it('should apply underline effect with after pseudo-element styling', async () => {
const { container } = render(await Description({}))
it('should apply underline effect with after pseudo-element styling', () => {
const { container } = render(<Description />)
const categorySpan = container.querySelector('.after\\:absolute')
expect(categorySpan).toBeInTheDocument()
})
it('should apply correct after pseudo-element classes', async () => {
const { container } = render(await Description({}))
it('should apply correct after pseudo-element classes', () => {
const { container } = render(<Description />)
// Check for the specific after pseudo-element classes
const categorySpans = container.querySelectorAll('.after\\:bottom-\\[1\\.5px\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply full width to after element', async () => {
const { container } = render(await Description({}))
it('should apply full width to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:w-full')
expect(categorySpans.length).toBe(7)
})
it('should apply correct height to after element', async () => {
const { container } = render(await Description({}))
it('should apply correct height to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:h-2')
expect(categorySpans.length).toBe(7)
})
it('should apply bg-text-text-selected to after element', async () => {
const { container } = render(await Description({}))
it('should apply bg-text-text-selected to after element', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.after\\:bg-text-text-selected')
expect(categorySpans.length).toBe(7)
})
it('should have z-index 1 on category spans', async () => {
const { container } = render(await Description({}))
it('should have z-index 1 on category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.z-\\[1\\]')
expect(categorySpans.length).toBe(7)
})
it('should apply left margin to category spans', async () => {
const { container } = render(await Description({}))
it('should apply left margin to category spans', () => {
const { container } = render(<Description />)
const categorySpans = container.querySelectorAll('.ml-1')
expect(categorySpans.length).toBeGreaterThanOrEqual(7)
})
it('should apply both left and right margin to specific spans', async () => {
const { container } = render(await Description({}))
it('should apply both left and right margin to specific spans', () => {
const { container } = render(<Description />)
// Extensions and Bundles spans have both ml-1 and mr-1
const extensionsBundlesSpans = container.querySelectorAll('.ml-1.mr-1')
@ -386,28 +390,17 @@ describe('Description', () => {
// Edge Cases Tests
// ================================
describe('Edge Cases', () => {
it('should handle empty props object', async () => {
const { container } = render(await Description({}))
expect(container.firstChild).toBeInTheDocument()
})
it('should render fragment as root element', async () => {
const { container } = render(await Description({}))
it('should render fragment as root element', () => {
const { container } = render(<Description />)
// Fragment renders h1 and h2 as direct children
expect(container.querySelector('h1')).toBeInTheDocument()
expect(container.querySelector('h2')).toBeInTheDocument()
})
it('should handle locale prop with undefined value', async () => {
render(await Description({ locale: undefined }))
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument()
})
it('should handle zh-Hant as non-Chinese simplified', async () => {
render(await Description({ locale: 'zh-Hant' }))
it('should handle zh-Hant as non-Chinese simplified', () => {
mockDefaultLocale = 'zh-Hant'
render(<Description />)
// zh-Hant is different from zh-Hans, should use non-Chinese format
const subheading = screen.getByRole('heading', { level: 2 })
@ -426,8 +419,8 @@ describe('Description', () => {
// Content Structure Tests
// ================================
describe('Content Structure', () => {
it('should have comma separators between categories', async () => {
render(await Description({}))
it('should have comma separators between categories', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -436,8 +429,8 @@ describe('Description', () => {
expect(content).toMatch(/Models[^\n\r,\u2028\u2029]*,.*Tools[^\n\r,\u2028\u2029]*,.*Data Sources[^\n\r,\u2028\u2029]*,.*Triggers[^\n\r,\u2028\u2029]*,.*Agent Strategies[^\n\r,\u2028\u2029]*,.*Extensions/)
})
it('should have "and" before last category (Bundles)', async () => {
render(await Description({}))
it('should have "and" before last category (Bundles)', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -449,8 +442,9 @@ describe('Description', () => {
expect(andIndex).toBeLessThan(bundlesIndex)
})
it('should render all text elements in correct order for en-US', async () => {
render(await Description({ locale: 'en-US' }))
it('should render all text elements in correct order for en-US', () => {
mockDefaultLocale = 'en-US'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -477,8 +471,9 @@ describe('Description', () => {
}
})
it('should render all text elements in correct order for zh-Hans', async () => {
render(await Description({ locale: 'zh-Hans' }))
it('should render all text elements in correct order for zh-Hans', () => {
mockDefaultLocale = 'zh-Hans'
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
const content = subheading.textContent || ''
@ -499,82 +494,48 @@ describe('Description', () => {
// Layout Tests
// ================================
describe('Layout', () => {
it('should have shrink-0 on h1 heading', async () => {
render(await Description({}))
it('should have shrink-0 on h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toHaveClass('shrink-0')
})
it('should have shrink-0 on h2 subheading', async () => {
render(await Description({}))
it('should have shrink-0 on h2 subheading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('shrink-0')
})
it('should have flex layout on h2', async () => {
render(await Description({}))
it('should have flex layout on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('flex')
})
it('should have items-center on h2', async () => {
render(await Description({}))
it('should have items-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('items-center')
})
it('should have justify-center on h2', async () => {
render(await Description({}))
it('should have justify-center on h2', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toHaveClass('justify-center')
})
})
// ================================
// Translation Function Tests
// ================================
describe('Translation Functions', () => {
it('should call getTranslation for plugin namespace', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'en-US' }))
expect(getTranslation).toHaveBeenCalledWith('en-US', 'plugin')
})
it('should call getTranslation for common namespace', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'en-US' }))
expect(getTranslation).toHaveBeenCalledWith('en-US', 'common')
})
it('should call getLocaleOnServer when locale prop is undefined', async () => {
const { getLocaleOnServer } = await import('@/i18n-config/server')
render(await Description({}))
expect(getLocaleOnServer).toHaveBeenCalled()
})
it('should use locale prop when provided', async () => {
const { getTranslation } = await import('@/i18n-config/server')
render(await Description({ locale: 'ja-JP' }))
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'plugin')
expect(getTranslation).toHaveBeenCalledWith('ja-JP', 'common')
})
})
// ================================
// Accessibility Tests
// ================================
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(await Description({}))
it('should have proper heading hierarchy', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
const h2 = screen.getByRole('heading', { level: 2 })
@ -583,22 +544,22 @@ describe('Description', () => {
expect(h2).toBeInTheDocument()
})
it('should have readable text content', async () => {
render(await Description({}))
it('should have readable text content', () => {
render(<Description />)
const h1 = screen.getByRole('heading', { level: 1 })
expect(h1.textContent).not.toBe('')
})
it('should have visible h1 heading', async () => {
render(await Description({}))
it('should have visible h1 heading', () => {
render(<Description />)
const heading = screen.getByRole('heading', { level: 1 })
expect(heading).toBeVisible()
})
it('should have visible h2 heading', async () => {
render(await Description({}))
it('should have visible h2 heading', () => {
render(<Description />)
const subheading = screen.getByRole('heading', { level: 2 })
expect(subheading).toBeVisible()
@ -615,8 +576,8 @@ describe('Description Integration', () => {
mockDefaultLocale = 'en-US'
})
it('should render complete component structure', async () => {
const { container } = render(await Description({ locale: 'en-US' }))
it('should render complete component structure', () => {
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
@ -627,8 +588,9 @@ describe('Description Integration', () => {
expect(categorySpans.length).toBe(7)
})
it('should render complete zh-Hans structure', async () => {
const { container } = render(await Description({ locale: 'zh-Hans' }))
it('should render complete zh-Hans structure', () => {
mockDefaultLocale = 'zh-Hans'
const { container } = render(<Description />)
// Main headings
expect(container.querySelector('h1')).toBeInTheDocument()
@ -639,14 +601,16 @@ describe('Description Integration', () => {
expect(categorySpans.length).toBe(7)
})
it('should correctly switch between zh-Hans and en-US layouts', async () => {
it('should correctly differentiate between zh-Hans and en-US layouts', () => {
// Render en-US
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enContent = enContainer.querySelector('h2')?.textContent || ''
unmountEn()
// Render zh-Hans
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhContent = zhContainer.querySelector('h2')?.textContent || ''
// Both should have all categories
@ -666,14 +630,16 @@ describe('Description Integration', () => {
expect(zhMarketplaceIndex).toBeLessThan(zhDiscoverIndex)
})
it('should maintain consistent styling across locales', async () => {
it('should maintain consistent styling across locales', () => {
// Render en-US
const { container: enContainer, unmount: unmountEn } = render(await Description({ locale: 'en-US' }))
mockDefaultLocale = 'en-US'
const { container: enContainer, unmount: unmountEn } = render(<Description />)
const enCategoryCount = enContainer.querySelectorAll('.body-md-medium').length
unmountEn()
// Render zh-Hans
const { container: zhContainer } = render(await Description({ locale: 'zh-Hans' }))
mockDefaultLocale = 'zh-Hans'
const { container: zhContainer } = render(<Description />)
const zhCategoryCount = zhContainer.querySelectorAll('.body-md-medium').length
// Both should have same number of styled category spans

View File

@ -1,17 +1,11 @@
/* eslint-disable dify-i18n/require-ns-option */
import type { Locale } from '@/i18n-config'
import { getLocaleOnServer, getTranslation } from '@/i18n-config/server'
import { useLocale, useTranslation } from '#i18n'
type DescriptionProps = {
locale?: Locale
}
const Description = async ({
locale: localeFromProps,
}: DescriptionProps) => {
const localeDefault = await getLocaleOnServer()
const { t } = await getTranslation(localeFromProps || localeDefault, 'plugin')
const { t: tCommon } = await getTranslation(localeFromProps || localeDefault, 'common')
const isZhHans = localeFromProps === 'zh-Hans'
const Description = () => {
const { t } = useTranslation('plugin')
const { t: tCommon } = useTranslation('common')
const locale = useLocale()
const isZhHans = locale === 'zh-Hans'
return (
<>

View File

@ -42,7 +42,7 @@ const Marketplace = async ({
scrollContainerId={scrollContainerId}
showSearchParams={showSearchParams}
>
<Description locale={locale} />
<Description />
<StickySearchAndSwitchWrapper
locale={locale}
pluginTypeSwitchClassName={pluginTypeSwitchClassName}

View File

@ -550,6 +550,7 @@ export const useIsNodeInLoop = (loopId: string) => {
return false
if (node.parentId === loopId)
return true
return false

View File

@ -5,14 +5,9 @@ import type { IterationNodeType } from '../nodes/iteration/types'
import type { LoopNodeType } from '../nodes/loop/types'
import type { QuestionClassifierNodeType } from '../nodes/question-classifier/types'
import type { ToolNodeType } from '../nodes/tool/types'
import type {
Edge,
Node,
} from '../types'
import type { Edge, Node } from '../types'
import { cloneDeep } from 'es-toolkit/object'
import {
getConnectedEdges,
} from 'reactflow'
import { getConnectedEdges } from 'reactflow'
import { getIterationStartNode, getLoopStartNode } from '@/app/components/workflow/utils/node'
import { correctModelProvider } from '@/utils'
import {
@ -24,22 +19,22 @@ import {
NODE_WIDTH_X_OFFSET,
START_INITIAL_POSITION,
} from '../constants'
import {
CUSTOM_GROUP_NODE,
GROUP_CHILDREN_Z_INDEX,
} from '../custom-group-node'
import { CUSTOM_GROUP_NODE, GROUP_CHILDREN_Z_INDEX } from '../custom-group-node'
import { branchNameCorrect } from '../nodes/if-else/utils'
import { CUSTOM_ITERATION_START_NODE } from '../nodes/iteration-start/constants'
import { CUSTOM_LOOP_START_NODE } from '../nodes/loop-start/constants'
import {
BlockEnum,
ErrorHandleMode,
} from '../types'
import { BlockEnum, ErrorHandleMode } from '../types'
const WHITE = 'WHITE'
const GRAY = 'GRAY'
const BLACK = 'BLACK'
const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Record<string, string[]>, stack: string[]) => {
const isCyclicUtil = (
nodeId: string,
color: Record<string, string>,
adjList: Record<string, string[]>,
stack: string[],
) => {
color[nodeId] = GRAY
stack.push(nodeId)
@ -50,8 +45,12 @@ const isCyclicUtil = (nodeId: string, color: Record<string, string>, adjList: Re
stack.push(childId)
return true
}
if (color[childId] === WHITE && isCyclicUtil(childId, color, adjList, stack))
if (
color[childId] === WHITE
&& isCyclicUtil(childId, color, adjList, stack)
) {
return true
}
}
color[nodeId] = BLACK
if (stack.length > 0 && stack[stack.length - 1] === nodeId)
@ -69,8 +68,7 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
adjList[node.id] = []
}
for (const edge of edges)
adjList[edge.source]?.push(edge.target)
for (const edge of edges) adjList[edge.source]?.push(edge.target)
for (let i = 0; i < nodes.length; i++) {
if (color[nodes[i].id] === WHITE)
@ -90,22 +88,34 @@ const getCycleEdges = (nodes: Node[], edges: Edge[]) => {
}
export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
const hasIterationNode = nodes.some(node => node.data.type === BlockEnum.Iteration)
const hasIterationNode = nodes.some(
node => node.data.type === BlockEnum.Iteration,
)
const hasLoopNode = nodes.some(node => node.data.type === BlockEnum.Loop)
const hasGroupNode = nodes.some(node => node.type === CUSTOM_GROUP_NODE)
const hasBusinessGroupNode = nodes.some(node => node.data.type === BlockEnum.Group)
const hasBusinessGroupNode = nodes.some(
node => node.data.type === BlockEnum.Group,
)
if (!hasIterationNode && !hasLoopNode && !hasGroupNode && !hasBusinessGroupNode) {
if (
!hasIterationNode
&& !hasLoopNode
&& !hasGroupNode
&& !hasBusinessGroupNode
) {
return {
nodes,
edges,
}
}
const nodesMap = nodes.reduce((prev, next) => {
prev[next.id] = next
return prev
}, {} as Record<string, Node>)
const nodesMap = nodes.reduce(
(prev, next) => {
prev[next.id] = next
return prev
},
{} as Record<string, Node>,
)
const iterationNodesWithStartNode = []
const iterationNodesWithoutStartNode = []
@ -117,8 +127,12 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
if (currentNode.data.type === BlockEnum.Iteration) {
if (currentNode.data.start_node_id) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_ITERATION_START_NODE)
if (
nodesMap[currentNode.data.start_node_id]?.type
!== CUSTOM_ITERATION_START_NODE
) {
iterationNodesWithStartNode.push(currentNode)
}
}
else {
iterationNodesWithoutStartNode.push(currentNode)
@ -127,8 +141,12 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
if (currentNode.data.type === BlockEnum.Loop) {
if (currentNode.data.start_node_id) {
if (nodesMap[currentNode.data.start_node_id]?.type !== CUSTOM_LOOP_START_NODE)
if (
nodesMap[currentNode.data.start_node_id]?.type
!== CUSTOM_LOOP_START_NODE
) {
loopNodesWithStartNode.push(currentNode)
}
}
else {
loopNodesWithoutStartNode.push(currentNode)
@ -137,7 +155,10 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
}
const newIterationStartNodesMap = {} as Record<string, Node>
const newIterationStartNodes = [...iterationNodesWithStartNode, ...iterationNodesWithoutStartNode].map((iterationNode, index) => {
const newIterationStartNodes = [
...iterationNodesWithStartNode,
...iterationNodesWithoutStartNode,
].map((iterationNode, index) => {
const newNode = getIterationStartNode(iterationNode.id)
newNode.id = newNode.id + index
newIterationStartNodesMap[iterationNode.id] = newNode
@ -145,24 +166,34 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
})
const newLoopStartNodesMap = {} as Record<string, Node>
const newLoopStartNodes = [...loopNodesWithStartNode, ...loopNodesWithoutStartNode].map((loopNode, index) => {
const newLoopStartNodes = [
...loopNodesWithStartNode,
...loopNodesWithoutStartNode,
].map((loopNode, index) => {
const newNode = getLoopStartNode(loopNode.id)
newNode.id = newNode.id + index
newLoopStartNodesMap[loopNode.id] = newNode
return newNode
})
const newEdges = [...iterationNodesWithStartNode, ...loopNodesWithStartNode].map((nodeItem) => {
const newEdges = [
...iterationNodesWithStartNode,
...loopNodesWithStartNode,
].map((nodeItem) => {
const isIteration = nodeItem.data.type === BlockEnum.Iteration
const newNode = (isIteration ? newIterationStartNodesMap : newLoopStartNodesMap)[nodeItem.id]
const newNode = (
isIteration ? newIterationStartNodesMap : newLoopStartNodesMap
)[nodeItem.id]
const startNode = nodesMap[nodeItem.data.start_node_id]
const source = newNode.id
const sourceHandle = 'source'
const target = startNode.id
const targetHandle = 'target'
const parentNode = nodes.find(node => node.id === startNode.parentId) || null
const isInIteration = !!parentNode && parentNode.data.type === BlockEnum.Iteration
const parentNode
= nodes.find(node => node.id === startNode.parentId) || null
const isInIteration
= !!parentNode && parentNode.data.type === BlockEnum.Iteration
const isInLoop = !!parentNode && parentNode.data.type === BlockEnum.Loop
return {
@ -185,11 +216,18 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
}
})
nodes.forEach((node) => {
if (node.data.type === BlockEnum.Iteration && newIterationStartNodesMap[node.id])
(node.data as IterationNodeType).start_node_id = newIterationStartNodesMap[node.id].id
if (
node.data.type === BlockEnum.Iteration
&& newIterationStartNodesMap[node.id]
) {
(node.data as IterationNodeType).start_node_id
= newIterationStartNodesMap[node.id].id
}
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id])
(node.data as LoopNodeType).start_node_id = newLoopStartNodesMap[node.id].id
if (node.data.type === BlockEnum.Loop && newLoopStartNodesMap[node.id]) {
(node.data as LoopNodeType).start_node_id
= newLoopStartNodesMap[node.id].id
}
})
// Derive Group internal edges (input → entries, leaves → exits)
@ -240,7 +278,7 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
targetHandle: 'target',
data: {
sourceType: leafNode.data.type,
targetType: '' as any, // Exit port has empty type
targetType: '' as string, // Exit port has empty type
_isGroupInternal: true,
_groupId: groupNode.id,
},
@ -260,7 +298,12 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
return
const groupData = groupNode.data as GroupNodeData
const { members = [], headNodeIds = [], leafNodeIds = [], handlers = [] } = groupData
const {
members = [],
headNodeIds = [],
leafNodeIds = [],
handlers = [],
} = groupData
const memberSet = new Set(members.map(m => m.id))
const headSet = new Set(headNodeIds)
const leafSet = new Set(leafNodeIds)
@ -293,7 +336,8 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
if (leafSet.has(edge.source) && !memberSet.has(edge.target)) {
const edgeSourceHandle = edge.sourceHandle || 'source'
const handler = handlers.find(
h => h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
h =>
h.nodeId === edge.source && h.sourceHandle === edgeSourceHandle,
)
if (handler) {
groupTempEdges.push({
@ -321,7 +365,10 @@ export const preprocessNodesAndEdges = (nodes: Node[], edges: Edge[]) => {
}
export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
const { nodes, edges } = preprocessNodesAndEdges(
cloneDeep(originNodes),
cloneDeep(originEdges),
)
const firstNode = nodes[0]
if (!firstNode?.position) {
@ -333,23 +380,35 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
})
}
const iterationOrLoopNodeMap = nodes.reduce((acc, node) => {
if (node.parentId) {
if (acc[node.parentId])
acc[node.parentId].push({ nodeId: node.id, nodeType: node.data.type })
else
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
}
return acc
}, {} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>)
const iterationOrLoopNodeMap = nodes.reduce(
(acc, node) => {
if (node.parentId) {
if (acc[node.parentId]) {
acc[node.parentId].push({
nodeId: node.id,
nodeType: node.data.type,
})
}
else {
acc[node.parentId] = [{ nodeId: node.id, nodeType: node.data.type }]
}
}
return acc
},
{} as Record<string, { nodeId: string, nodeType: BlockEnum }[]>,
)
return nodes.map((node) => {
if (!node.type)
node.type = CUSTOM_NODE
const connectedEdges = getConnectedEdges([node], edges)
node.data._connectedSourceHandleIds = connectedEdges.filter(edge => edge.source === node.id).map(edge => edge.sourceHandle || 'source')
node.data._connectedTargetHandleIds = connectedEdges.filter(edge => edge.target === node.id).map(edge => edge.targetHandle || 'target')
node.data._connectedSourceHandleIds = connectedEdges
.filter(edge => edge.source === node.id)
.map(edge => edge.sourceHandle || 'source')
node.data._connectedTargetHandleIds = connectedEdges
.filter(edge => edge.target === node.id)
.map(edge => edge.targetHandle || 'target')
if (node.data.type === BlockEnum.IfElse) {
const nodeData = node.data as IfElseNodeType
@ -364,18 +423,27 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
]
}
node.data._targetBranches = branchNameCorrect([
...(node.data as IfElseNodeType).cases.map(item => ({ id: item.case_id, name: '' })),
...(node.data as IfElseNodeType).cases.map(item => ({
id: item.case_id,
name: '',
})),
{ id: 'false', name: '' },
])
// delete conditions and logical_operator if cases is not empty
if (nodeData.cases.length > 0 && nodeData.conditions && nodeData.logical_operator) {
if (
nodeData.cases.length > 0
&& nodeData.conditions
&& nodeData.logical_operator
) {
delete nodeData.conditions
delete nodeData.logical_operator
}
}
if (node.data.type === BlockEnum.QuestionClassifier) {
node.data._targetBranches = (node.data as QuestionClassifierNodeType).classes.map((topic) => {
node.data._targetBranches = (
node.data as QuestionClassifierNodeType
).classes.map((topic) => {
return topic
})
}
@ -395,28 +463,46 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
iterationNodeData._children = iterationOrLoopNodeMap[node.id] || []
iterationNodeData.is_parallel = iterationNodeData.is_parallel || false
iterationNodeData.parallel_nums = iterationNodeData.parallel_nums || 10
iterationNodeData.error_handle_mode = iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
iterationNodeData.error_handle_mode
= iterationNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// TODO: loop error handle mode
if (node.data.type === BlockEnum.Loop) {
const loopNodeData = node.data as LoopNodeType
loopNodeData._children = iterationOrLoopNodeMap[node.id] || []
loopNodeData.error_handle_mode = loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
loopNodeData.error_handle_mode
= loopNodeData.error_handle_mode || ErrorHandleMode.Terminated
}
// legacy provider handle
if (node.data.type === BlockEnum.LLM)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.LLM) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.KnowledgeRetrieval && (node as any).data.multiple_retrieval_config?.reranking_model)
(node as any).data.multiple_retrieval_config.reranking_model.provider = correctModelProvider((node as any).data.multiple_retrieval_config?.reranking_model.provider)
if (
node.data.type === BlockEnum.KnowledgeRetrieval
&& (node as any).data.multiple_retrieval_config?.reranking_model
) {
(node as any).data.multiple_retrieval_config.reranking_model.provider
= correctModelProvider(
(node as any).data.multiple_retrieval_config?.reranking_model.provider,
)
}
if (node.data.type === BlockEnum.QuestionClassifier)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.QuestionClassifier) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.ParameterExtractor)
(node as any).data.model.provider = correctModelProvider((node as any).data.model.provider)
if (node.data.type === BlockEnum.ParameterExtractor) {
(node as any).data.model.provider = correctModelProvider(
(node as any).data.model.provider,
)
}
if (node.data.type === BlockEnum.HttpRequest && !node.data.retry_config) {
node.data.retry_config = {
@ -426,14 +512,21 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
}
}
if (node.data.type === BlockEnum.Tool && !(node as Node<ToolNodeType>).data.version && !(node as Node<ToolNodeType>).data.tool_node_version) {
if (
node.data.type === BlockEnum.Tool
&& !(node as Node<ToolNodeType>).data.version
&& !(node as Node<ToolNodeType>).data.tool_node_version
) {
(node as Node<ToolNodeType>).data.tool_node_version = '2'
const toolConfigurations = (node as Node<ToolNodeType>).data.tool_configurations
if (toolConfigurations && Object.keys(toolConfigurations).length > 0) {
const newValues = { ...toolConfigurations }
Object.keys(toolConfigurations).forEach((key) => {
if (typeof toolConfigurations[key] !== 'object' || toolConfigurations[key] === null) {
if (
typeof toolConfigurations[key] !== 'object'
|| toolConfigurations[key] === null
) {
newValues[key] = {
type: 'constant',
value: toolConfigurations[key],
@ -449,50 +542,62 @@ export const initialNodes = (originNodes: Node[], originEdges: Edge[]) => {
}
export const initialEdges = (originEdges: Edge[], originNodes: Node[]) => {
const { nodes, edges } = preprocessNodesAndEdges(cloneDeep(originNodes), cloneDeep(originEdges))
const { nodes, edges } = preprocessNodesAndEdges(
cloneDeep(originNodes),
cloneDeep(originEdges),
)
let selectedNode: Node | null = null
const nodesMap = nodes.reduce((acc, node) => {
acc[node.id] = node
const nodesMap = nodes.reduce(
(acc, node) => {
acc[node.id] = node
if (node.data?.selected)
selectedNode = node
if (node.data?.selected)
selectedNode = node
return acc
}, {} as Record<string, Node>)
return acc
},
{} as Record<string, Node>,
)
const cycleEdges = getCycleEdges(nodes, edges)
return edges.filter((edge) => {
return !cycleEdges.find(cycEdge => cycEdge.source === edge.source && cycEdge.target === edge.target)
}).map((edge) => {
edge.type = 'custom'
return edges
.filter((edge) => {
return !cycleEdges.find(
cycEdge =>
cycEdge.source === edge.source && cycEdge.target === edge.target,
)
})
.map((edge) => {
edge.type = 'custom'
if (!edge.sourceHandle)
edge.sourceHandle = 'source'
if (!edge.sourceHandle)
edge.sourceHandle = 'source'
if (!edge.targetHandle)
edge.targetHandle = 'target'
if (!edge.targetHandle)
edge.targetHandle = 'target'
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
edge.data = {
...edge.data,
sourceType: nodesMap[edge.source].data.type!,
} as any
}
if (!edge.data?.sourceType && edge.source && nodesMap[edge.source]) {
edge.data = {
...edge.data,
sourceType: nodesMap[edge.source].data.type!,
} as any
}
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
edge.data = {
...edge.data,
targetType: nodesMap[edge.target].data.type!,
} as any
}
if (!edge.data?.targetType && edge.target && nodesMap[edge.target]) {
edge.data = {
...edge.data,
targetType: nodesMap[edge.target].data.type!,
} as any
}
if (selectedNode) {
edge.data = {
...edge.data,
_connectedNodeIsSelected: edge.source === selectedNode.id || edge.target === selectedNode.id,
} as any
}
if (selectedNode) {
edge.data = {
...edge.data,
_connectedNodeIsSelected:
edge.source === selectedNode.id || edge.target === selectedNode.id,
} as any
}
return edge
})
return edge
})
}

View File

@ -1,6 +1,8 @@
import noAsAnyInT from './rules/no-as-any-in-t.js'
import noExtraKeys from './rules/no-extra-keys.js'
import noLegacyNamespacePrefix from './rules/no-legacy-namespace-prefix.js'
import requireNsOption from './rules/require-ns-option.js'
import validI18nKeys from './rules/valid-i18n-keys.js'
/** @type {import('eslint').ESLint.Plugin} */
const plugin = {
@ -10,8 +12,10 @@ const plugin = {
},
rules: {
'no-as-any-in-t': noAsAnyInT,
'no-extra-keys': noExtraKeys,
'no-legacy-namespace-prefix': noLegacyNamespacePrefix,
'require-ns-option': requireNsOption,
'valid-i18n-keys': validI18nKeys,
},
}

View File

@ -0,0 +1,70 @@
import fs from 'node:fs'
import path, { normalize, sep } from 'node:path'
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Ensure non-English JSON files don\'t have extra keys not present in en-US',
},
fixable: 'code',
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json'))
return
const parts = normalize(filename).split(sep)
// e.g., i18n/ar-TN/common.json -> jsonFile = common.json, lang = ar-TN
const jsonFile = parts.at(-1)
const lang = parts.at(-2)
// Skip English files
if (lang === 'en-US')
return
let currentJson = {}
let englishJson = {}
try {
currentJson = JSON.parse(sourceCode.text)
// Look for the same filename in en-US folder
// e.g., i18n/ar-TN/common.json -> i18n/en-US/common.json
const englishFilePath = path.join(path.dirname(filename), '..', 'en-US', jsonFile ?? '')
englishJson = JSON.parse(fs.readFileSync(englishFilePath, 'utf8'))
}
catch (error) {
context.report({
node,
message: `Error parsing JSON: ${error instanceof Error ? error.message : String(error)}`,
})
return
}
const extraKeys = Object.keys(currentJson).filter(
key => !Object.prototype.hasOwnProperty.call(englishJson, key),
)
for (const key of extraKeys) {
context.report({
node,
message: `Key "${key}" is present in ${lang}/${jsonFile} but not in en-US/${jsonFile}`,
fix(fixer) {
const newJson = Object.fromEntries(
Object.entries(currentJson).filter(([k]) => !extraKeys.includes(k)),
)
const newText = `${JSON.stringify(newJson, null, 2)}\n`
return fixer.replaceText(node, newText)
},
})
}
},
}
},
}

View File

@ -0,0 +1,61 @@
import { cleanJsonText } from '../utils.js'
/** @type {import('eslint').Rule.RuleModule} */
export default {
meta: {
type: 'problem',
docs: {
description: 'Ensure i18n JSON keys are flat and valid as object paths',
},
},
create(context) {
return {
Program(node) {
const { filename, sourceCode } = context
if (!filename.endsWith('.json'))
return
let json
try {
json = JSON.parse(cleanJsonText(sourceCode.text))
}
catch {
context.report({
node,
message: 'Invalid JSON format',
})
return
}
const keys = Object.keys(json)
const keyPrefixes = new Set()
for (const key of keys) {
if (key.includes('.')) {
const parts = key.split('.')
for (let i = 1; i < parts.length; i++) {
const prefix = parts.slice(0, i).join('.')
if (keys.includes(prefix)) {
context.report({
node,
message: `Invalid key structure: '${key}' conflicts with '${prefix}'`,
})
}
keyPrefixes.add(prefix)
}
}
}
for (const key of keys) {
if (keyPrefixes.has(key)) {
context.report({
node,
message: `Invalid key structure: '${key}' is a prefix of another key`,
})
}
}
},
}
},
}

10
web/eslint-rules/utils.js Normal file
View File

@ -0,0 +1,10 @@
export const cleanJsonText = (text) => {
const cleaned = text.replaceAll(/,\s*\}/g, '}')
try {
JSON.parse(cleaned)
return cleaned
}
catch {
return text
}
}

View File

@ -130,15 +130,6 @@ export default antfu(
sonarjs: sonar,
},
},
// allow generated i18n files (like i18n/*/workflow.ts) to exceed max-lines
{
files: ['i18n/**'],
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
},
},
tailwind.configs['flat/recommended'],
{
settings: {
@ -188,7 +179,22 @@ export default antfu(
// 'dify-i18n/no-as-any-in-t': ['error', { mode: 'all' }],
'dify-i18n/no-as-any-in-t': 'error',
// 'dify-i18n/no-legacy-namespace-prefix': 'error',
'dify-i18n/require-ns-option': 'error',
// 'dify-i18n/require-ns-option': 'error',
},
},
// i18n JSON validation rules
{
files: ['i18n/**/*.json'],
plugins: {
'dify-i18n': difyI18n,
},
rules: {
'sonarjs/max-lines': 'off',
'max-lines': 'off',
'jsonc/sort-keys': 'error',
'dify-i18n/valid-i18n-keys': 'error',
'dify-i18n/no-extra-keys': 'error',
},
},
)

View File

@ -0,0 +1,10 @@
'use client'
import type { NamespaceCamelCase } from './i18next-config'
import { useTranslation as useTranslationOriginal } from 'react-i18next'
export function useTranslation(ns?: NamespaceCamelCase) {
return useTranslationOriginal(ns)
}
export { useLocale } from '@/context/i18n'

View File

@ -0,0 +1,16 @@
import type { NamespaceCamelCase } from './i18next-config'
import { use } from 'react'
import { getLocaleOnServer, getTranslation } from './server'
async function getI18nConfig(ns?: NamespaceCamelCase) {
const lang = await getLocaleOnServer()
return getTranslation(lang, ns)
}
export function useTranslation(ns?: NamespaceCamelCase) {
return use(getI18nConfig(ns))
}
export function useLocale() {
return use(getLocaleOnServer())
}

View File

@ -2,13 +2,13 @@ import type { i18n as I18nInstance } from 'i18next'
import type { Locale } from '.'
import type { NamespaceCamelCase, NamespaceKebabCase } from './i18next-config'
import { match } from '@formatjs/intl-localematcher'
import { camelCase, kebabCase } from 'es-toolkit/compat'
import { kebabCase } from 'es-toolkit/compat'
import { createInstance } from 'i18next'
import resourcesToBackend from 'i18next-resources-to-backend'
import Negotiator from 'negotiator'
import { cookies, headers } from 'next/headers'
import { initReactI18next } from 'react-i18next/initReactI18next'
import serverOnlyContext from '@/utils/server-only-context'
import { serverOnlyContext } from '@/utils/server-only-context'
import { i18n } from '.'
const [getLocaleCache, setLocaleCache] = serverOnlyContext<Locale | null>(null)
@ -35,15 +35,14 @@ const getOrCreateI18next = async (lng: Locale) => {
return instance
}
export async function getTranslation(lng: Locale, ns: NamespaceKebabCase) {
const camelNs = camelCase(ns) as NamespaceCamelCase
export async function getTranslation(lng: Locale, ns?: NamespaceCamelCase) {
const i18nextInstance = await getOrCreateI18next(lng)
if (!i18nextInstance.hasLoadedNamespace(camelNs))
await i18nextInstance.loadNamespaces(camelNs)
if (ns && !i18nextInstance.hasLoadedNamespace(ns))
await i18nextInstance.loadNamespaces(ns)
return {
t: i18nextInstance.getFixedT(lng, camelNs),
t: i18nextInstance.getFixedT(lng, ns),
i18n: i18nextInstance,
}
}

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "أوقات الاتصال",
"modelProvider.card.buyQuota": "شراء حصة",
"modelProvider.card.callTimes": "أوقات الاتصال",
"modelProvider.card.modelAPI": "النماذج {{modelName}} تستخدم مفتاح واجهة برمجة التطبيقات.",
"modelProvider.card.modelNotSupported": "النماذج {{modelName}} غير مثبتة.",
"modelProvider.card.modelSupported": "النماذج {{modelName}} تستخدم هذا الحصة.",
"modelProvider.card.onTrial": "في التجربة",
"modelProvider.card.paid": "مدفوع",
"modelProvider.card.priorityUse": "أولوية الاستخدام",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "الرموز المجانية المتاحة المتبقية",
"modelProvider.rerankModel.key": "نموذج إعادة الترتيب",
"modelProvider.rerankModel.tip": "سيعيد نموذج إعادة الترتيب ترتيب قائمة المستندات المرشحة بناءً على المطابقة الدلالية مع استعلام المستخدم، مما يحسن نتائج الترتيب الدلالي",
"modelProvider.resetDate": "إعادة الضبط على {{date}}",
"modelProvider.searchModel": "نموذج البحث",
"modelProvider.selectModel": "اختر نموذجك",
"modelProvider.selector.emptySetting": "يرجى الانتقال إلى الإعدادات للتكوين",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Anrufzeiten",
"modelProvider.card.buyQuota": "Kontingent kaufen",
"modelProvider.card.callTimes": "Anrufzeiten",
"modelProvider.card.modelAPI": "{{modelName}}-Modelle verwenden den API-Schlüssel.",
"modelProvider.card.modelNotSupported": "{{modelName}}-Modelle sind nicht installiert.",
"modelProvider.card.modelSupported": "{{modelName}}-Modelle verwenden dieses Kontingent.",
"modelProvider.card.onTrial": "In Probe",
"modelProvider.card.paid": "Bezahlt",
"modelProvider.card.priorityUse": "Priorisierte Nutzung",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Verbleibende verfügbare kostenlose Token",
"modelProvider.rerankModel.key": "Rerank-Modell",
"modelProvider.rerankModel.tip": "Rerank-Modell wird die Kandidatendokumentenliste basierend auf der semantischen Übereinstimmung mit der Benutzeranfrage neu ordnen und die Ergebnisse der semantischen Rangordnung verbessern",
"modelProvider.resetDate": "Zurücksetzen bei {{date}}",
"modelProvider.searchModel": "Suchmodell",
"modelProvider.selectModel": "Wählen Sie Ihr Modell",
"modelProvider.selector.emptySetting": "Bitte gehen Sie zu den Einstellungen, um zu konfigurieren",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Tiempos de llamada",
"modelProvider.card.buyQuota": "Comprar Cuota",
"modelProvider.card.callTimes": "Tiempos de llamada",
"modelProvider.card.modelAPI": "Los modelos {{modelName}} están usando la clave de API.",
"modelProvider.card.modelNotSupported": "Los modelos {{modelName}} no están instalados.",
"modelProvider.card.modelSupported": "Los modelos {{modelName}} están utilizando esta cuota.",
"modelProvider.card.onTrial": "En prueba",
"modelProvider.card.paid": "Pagado",
"modelProvider.card.priorityUse": "Uso prioritario",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuitos restantes disponibles",
"modelProvider.rerankModel.key": "Modelo de Reordenar",
"modelProvider.rerankModel.tip": "El modelo de reordenar reordenará la lista de documentos candidatos basada en la coincidencia semántica con la consulta del usuario, mejorando los resultados de clasificación semántica",
"modelProvider.resetDate": "Reiniciar en {{date}}",
"modelProvider.searchModel": "Modelo de búsqueda",
"modelProvider.selectModel": "Selecciona tu modelo",
"modelProvider.selector.emptySetting": "Por favor ve a configuraciones para configurar",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "تعداد فراخوانی",
"modelProvider.card.buyQuota": "خرید سهمیه",
"modelProvider.card.callTimes": "تعداد فراخوانی",
"modelProvider.card.modelAPI": "مدل‌های {{modelName}} در حال استفاده از کلید API هستند.",
"modelProvider.card.modelNotSupported": "مدل‌های {{modelName}} نصب نشده‌اند.",
"modelProvider.card.modelSupported": "مدل‌های {{modelName}} از این سهمیه استفاده می‌کنند.",
"modelProvider.card.onTrial": "در حال آزمایش",
"modelProvider.card.paid": "پرداخت شده",
"modelProvider.card.priorityUse": "استفاده با اولویت",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "توکن‌های رایگان باقی‌مانده در دسترس",
"modelProvider.rerankModel.key": "مدل رتبه‌بندی مجدد",
"modelProvider.rerankModel.tip": "مدل رتبه‌بندی مجدد، لیست اسناد کاندید را بر اساس تطابق معنایی با پرسش کاربر مرتب می‌کند و نتایج رتبه‌بندی معنایی را بهبود می‌بخشد",
"modelProvider.resetDate": "بازنشانی در {{date}}",
"modelProvider.searchModel": "جستجوی مدل",
"modelProvider.selectModel": "مدل خود را انتخاب کنید",
"modelProvider.selector.emptySetting": "لطفاً به تنظیمات بروید تا پیکربندی کنید",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Temps d'appel",
"modelProvider.card.buyQuota": "Acheter Quota",
"modelProvider.card.callTimes": "Temps d'appel",
"modelProvider.card.modelAPI": "Les modèles {{modelName}} utilisent la clé API.",
"modelProvider.card.modelNotSupported": "Les modèles {{modelName}} ne sont pas installés.",
"modelProvider.card.modelSupported": "Les modèles {{modelName}} utilisent ce quota.",
"modelProvider.card.onTrial": "En Essai",
"modelProvider.card.paid": "Payé",
"modelProvider.card.priorityUse": "Utilisation prioritaire",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuits restants disponibles",
"modelProvider.rerankModel.key": "Modèle de Réorganisation",
"modelProvider.rerankModel.tip": "Le modèle de réorganisation réorganisera la liste des documents candidats en fonction de la correspondance sémantique avec la requête de l'utilisateur, améliorant ainsi les résultats du classement sémantique.",
"modelProvider.resetDate": "Réinitialiser sur {{date}}",
"modelProvider.searchModel": "Modèle de recherche",
"modelProvider.selectModel": "Sélectionnez votre modèle",
"modelProvider.selector.emptySetting": "Veuillez aller dans les paramètres pour configurer",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "कॉल समय",
"modelProvider.card.buyQuota": "कोटा खरीदें",
"modelProvider.card.callTimes": "कॉल समय",
"modelProvider.card.modelAPI": "{{modelName}} मॉडल एपीआई कुंजी का उपयोग कर रहे हैं।",
"modelProvider.card.modelNotSupported": "{{modelName}} मॉडल इंस्टॉल नहीं हैं।",
"modelProvider.card.modelSupported": "{{modelName}} मॉडल इस कोटा का उपयोग कर रहे हैं।",
"modelProvider.card.onTrial": "परीक्षण पर",
"modelProvider.card.paid": "भुगतान किया हुआ",
"modelProvider.card.priorityUse": "प्राथमिकता उपयोग",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "बचे हुए उपलब्ध मुफ्त टोकन",
"modelProvider.rerankModel.key": "रीरैंक मॉडल",
"modelProvider.rerankModel.tip": "रीरैंक मॉडल उपयोगकर्ता प्रश्न के साथ सांविधिक मेल के आधार पर उम्मीदवार दस्तावेज़ सूची को पुनः क्रमित करेगा, सांविधिक रैंकिंग के परिणामों में सुधार करेगा।",
"modelProvider.resetDate": "{{date}} पर रीसेट करें",
"modelProvider.searchModel": "खोज मॉडल",
"modelProvider.selectModel": "अपने मॉडल का चयन करें",
"modelProvider.selector.emptySetting": "कॉन्फ़िगर करने के लिए कृपया सेटिंग्स पर जाएं",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Waktu panggilan",
"modelProvider.card.buyQuota": "Beli Kuota",
"modelProvider.card.callTimes": "Waktu panggilan",
"modelProvider.card.modelAPI": "Model {{modelName}} sedang menggunakan API Key.",
"modelProvider.card.modelNotSupported": "Model {{modelName}} tidak terpasang.",
"modelProvider.card.modelSupported": "Model {{modelName}} sedang menggunakan kuota ini.",
"modelProvider.card.onTrial": "Sedang Diadili",
"modelProvider.card.paid": "Dibayar",
"modelProvider.card.priorityUse": "Penggunaan prioritas",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Token gratis yang masih tersedia",
"modelProvider.rerankModel.key": "Peringkat ulang Model",
"modelProvider.rerankModel.tip": "Model rerank akan menyusun ulang daftar dokumen kandidat berdasarkan kecocokan semantik dengan kueri pengguna, meningkatkan hasil peringkat semantik",
"modelProvider.resetDate": "Atur ulang pada {{date}}",
"modelProvider.searchModel": "Model pencarian",
"modelProvider.selectModel": "Pilih model Anda",
"modelProvider.selector.emptySetting": "Silakan buka pengaturan untuk mengonfigurasi",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Numero di chiamate",
"modelProvider.card.buyQuota": "Acquista Quota",
"modelProvider.card.callTimes": "Numero di chiamate",
"modelProvider.card.modelAPI": "I modelli {{modelName}} stanno utilizzando la chiave API.",
"modelProvider.card.modelNotSupported": "I modelli {{modelName}} non sono installati.",
"modelProvider.card.modelSupported": "I modelli {{modelName}} stanno utilizzando questa quota.",
"modelProvider.card.onTrial": "In Prova",
"modelProvider.card.paid": "Pagato",
"modelProvider.card.priorityUse": "Uso prioritario",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Token gratuiti rimanenti disponibili",
"modelProvider.rerankModel.key": "Modello di Rerank",
"modelProvider.rerankModel.tip": "Il modello di rerank riordinerà la lista dei documenti candidati basandosi sulla corrispondenza semantica con la query dell'utente, migliorando i risultati del ranking semantico",
"modelProvider.resetDate": "Reimposta su {{date}}",
"modelProvider.searchModel": "Modello di ricerca",
"modelProvider.selectModel": "Seleziona il tuo modello",
"modelProvider.selector.emptySetting": "Per favore vai alle impostazioni per configurare",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "호출 횟수",
"modelProvider.card.buyQuota": "Buy Quota",
"modelProvider.card.callTimes": "호출 횟수",
"modelProvider.card.modelAPI": "{{modelName}} 모델이 API 키를 사용하고 있습니다.",
"modelProvider.card.modelNotSupported": "{{modelName}} 모델이 설치되지 않았습니다.",
"modelProvider.card.modelSupported": "{{modelName}} 모델이 이 할당량을 사용하고 있습니다.",
"modelProvider.card.onTrial": "트라이얼 중",
"modelProvider.card.paid": "유료",
"modelProvider.card.priorityUse": "우선 사용",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "남은 무료 토큰 사용 가능",
"modelProvider.rerankModel.key": "재랭크 모델",
"modelProvider.rerankModel.tip": "재랭크 모델은 사용자 쿼리와의 의미적 일치를 기반으로 후보 문서 목록을 재배열하여 의미적 순위를 향상시킵니다.",
"modelProvider.resetDate": "{{date}}에서 재설정",
"modelProvider.searchModel": "검색 모델",
"modelProvider.selectModel": "모델 선택",
"modelProvider.selector.emptySetting": "설정으로 이동하여 구성하세요",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Czasy wywołań",
"modelProvider.card.buyQuota": "Kup limit",
"modelProvider.card.callTimes": "Czasy wywołań",
"modelProvider.card.modelAPI": "Modele {{modelName}} używają klucza API.",
"modelProvider.card.modelNotSupported": "Modele {{modelName}} nie są zainstalowane.",
"modelProvider.card.modelSupported": "{{modelName}} modeli korzysta z tej kwoty.",
"modelProvider.card.onTrial": "Na próbę",
"modelProvider.card.paid": "Płatny",
"modelProvider.card.priorityUse": "Używanie z priorytetem",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Pozostałe dostępne darmowe tokeny",
"modelProvider.rerankModel.key": "Model ponownego rankingu",
"modelProvider.rerankModel.tip": "Model ponownego rankingu zmieni kolejność listy dokumentów kandydatów na podstawie semantycznego dopasowania z zapytaniem użytkownika, poprawiając wyniki rankingu semantycznego",
"modelProvider.resetDate": "Reset na {{date}}",
"modelProvider.searchModel": "Model wyszukiwania",
"modelProvider.selectModel": "Wybierz swój model",
"modelProvider.selector.emptySetting": "Przejdź do ustawień, aby skonfigurować",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Chamadas",
"modelProvider.card.buyQuota": "Comprar Quota",
"modelProvider.card.callTimes": "Chamadas",
"modelProvider.card.modelAPI": "Os modelos {{modelName}} estão usando a Chave de API.",
"modelProvider.card.modelNotSupported": "Modelos {{modelName}} não estão instalados.",
"modelProvider.card.modelSupported": "Modelos {{modelName}} estão usando esta cota.",
"modelProvider.card.onTrial": "Em Teste",
"modelProvider.card.paid": "Pago",
"modelProvider.card.priorityUse": "Uso prioritário",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Tokens gratuitos disponíveis restantes",
"modelProvider.rerankModel.key": "Modelo de Reordenação",
"modelProvider.rerankModel.tip": "O modelo de reordenaenação reorganizará a lista de documentos candidatos com base na correspondência semântica com a consulta do usuário, melhorando os resultados da classificação semântica",
"modelProvider.resetDate": "Redefinir em {{date}}",
"modelProvider.searchModel": "Modelo de pesquisa",
"modelProvider.selectModel": "Selecione seu modelo",
"modelProvider.selector.emptySetting": "Por favor, vá para configurações para configurar",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Apeluri",
"modelProvider.card.buyQuota": "Cumpără cotă",
"modelProvider.card.callTimes": "Apeluri",
"modelProvider.card.modelAPI": "Modelele {{modelName}} folosesc cheia API.",
"modelProvider.card.modelNotSupported": "Modelele {{modelName}} nu sunt instalate.",
"modelProvider.card.modelSupported": "{{modelName}} modele utilizează această cotă.",
"modelProvider.card.onTrial": "În probă",
"modelProvider.card.paid": "Plătit",
"modelProvider.card.priorityUse": "Utilizare prioritară",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Jetoane gratuite disponibile rămase",
"modelProvider.rerankModel.key": "Model de reordonare",
"modelProvider.rerankModel.tip": "Modelul de reordonare va reordona lista de documente candidate pe baza potrivirii semantice cu interogarea utilizatorului, îmbunătățind rezultatele clasificării semantice",
"modelProvider.resetDate": "Resetați la {{date}}",
"modelProvider.searchModel": "Model de căutare",
"modelProvider.selectModel": "Selectați modelul dvs.",
"modelProvider.selector.emptySetting": "Vă rugăm să mergeți la setări pentru a configura",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Количество вызовов",
"modelProvider.card.buyQuota": "Купить квоту",
"modelProvider.card.callTimes": "Количество вызовов",
"modelProvider.card.modelAPI": "{{modelName}} модели используют ключ API.",
"modelProvider.card.modelNotSupported": "Модели {{modelName}} не установлены.",
"modelProvider.card.modelSupported": "Эту квоту используют модели {{modelName}}.",
"modelProvider.card.onTrial": "Пробная версия",
"modelProvider.card.paid": "Платный",
"modelProvider.card.priorityUse": "Приоритетное использование",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Оставшиеся доступные бесплатные токены",
"modelProvider.rerankModel.key": "Модель повторного ранжирования",
"modelProvider.rerankModel.tip": "Модель повторного ранжирования изменит порядок списка документов-кандидатов на основе семантического соответствия запросу пользователя, улучшая результаты семантического ранжирования",
"modelProvider.resetDate": "Сброс на {{date}}",
"modelProvider.searchModel": "Поиск модели",
"modelProvider.selectModel": "Выберите свою модель",
"modelProvider.selector.emptySetting": "Пожалуйста, перейдите в настройки для настройки",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Število klicev",
"modelProvider.card.buyQuota": "Kupi kvoto",
"modelProvider.card.callTimes": "Časi klicev",
"modelProvider.card.modelAPI": "{{modelName}} modeli uporabljajo API ključ.",
"modelProvider.card.modelNotSupported": "{{modelName}} modeli niso nameščeni.",
"modelProvider.card.modelSupported": "{{modelName}} modeli uporabljajo to kvoto.",
"modelProvider.card.onTrial": "Na preizkusu",
"modelProvider.card.paid": "Plačano",
"modelProvider.card.priorityUse": "Prednostna uporaba",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Preostali razpoložljivi brezplačni žetoni",
"modelProvider.rerankModel.key": "Model za prerazvrstitev",
"modelProvider.rerankModel.tip": "Model za prerazvrstitev bo prerazporedil seznam kandidatskih dokumentov na podlagi semantične ujemanja z uporabniško poizvedbo, s čimer se izboljšajo rezultati semantičnega razvrščanja.",
"modelProvider.resetDate": "Ponastavi na {{date}}",
"modelProvider.searchModel": "Model iskanja",
"modelProvider.selectModel": "Izberite svoj model",
"modelProvider.selector.emptySetting": "Prosimo, pojdite v nastavitve za konfiguracijo",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "เวลาโทร",
"modelProvider.card.buyQuota": "ซื้อโควต้า",
"modelProvider.card.callTimes": "เวลาโทร",
"modelProvider.card.modelAPI": "{{modelName}} โมเดลกำลังใช้คีย์ API",
"modelProvider.card.modelNotSupported": "โมเดล {{modelName}} ยังไม่ได้ติดตั้ง",
"modelProvider.card.modelSupported": "โมเดล {{modelName}} กำลังใช้โควต้านี้อยู่",
"modelProvider.card.onTrial": "ทดลองใช้",
"modelProvider.card.paid": "จ่าย",
"modelProvider.card.priorityUse": "ลําดับความสําคัญในการใช้งาน",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "โทเค็นฟรีที่เหลืออยู่",
"modelProvider.rerankModel.key": "จัดอันดับโมเดลใหม่",
"modelProvider.rerankModel.tip": "โมเดล Rerank จะจัดลําดับรายการเอกสารผู้สมัครใหม่ตามการจับคู่ความหมายกับการสืบค้นของผู้ใช้ ซึ่งช่วยปรับปรุงผลลัพธ์ของการจัดอันดับความหมาย",
"modelProvider.resetDate": "รีเซ็ตเมื่อ {{date}}",
"modelProvider.searchModel": "ค้นหารุ่น",
"modelProvider.selectModel": "เลือกรุ่นของคุณ",
"modelProvider.selector.emptySetting": "โปรดไปที่การตั้งค่าเพื่อกําหนดค่า",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Çağrı Süreleri",
"modelProvider.card.buyQuota": "Kota Satın Al",
"modelProvider.card.callTimes": "Çağrı Süreleri",
"modelProvider.card.modelAPI": "{{modelName}} modelleri API Anahtarını kullanıyor.",
"modelProvider.card.modelNotSupported": "{{modelName}} modelleri yüklü değil.",
"modelProvider.card.modelSupported": "{{modelName}} modelleri bu kotayı kullanıyor.",
"modelProvider.card.onTrial": "Deneme Sürümünde",
"modelProvider.card.paid": "Ücretli",
"modelProvider.card.priorityUse": "Öncelikli Kullan",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Kalan kullanılabilir ücretsiz tokenler",
"modelProvider.rerankModel.key": "Yeniden Sıralama Modeli",
"modelProvider.rerankModel.tip": "Yeniden sıralama modeli, kullanıcı sorgusuyla anlam eşleştirmesine dayalı olarak aday belge listesini yeniden sıralayacak ve anlam sıralama sonuçlarını iyileştirecektir.",
"modelProvider.resetDate": "{{date}} üzerine sıfırlama",
"modelProvider.searchModel": "Model ara",
"modelProvider.selectModel": "Modelinizi seçin",
"modelProvider.selector.emptySetting": "Lütfen ayarlara gidip yapılandırın",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Кількість викликів",
"modelProvider.card.buyQuota": "Придбати квоту",
"modelProvider.card.callTimes": "Кількість викликів",
"modelProvider.card.modelAPI": "Моделі {{modelName}} використовують API-ключ.",
"modelProvider.card.modelNotSupported": "Моделі {{modelName}} не встановлені.",
"modelProvider.card.modelSupported": "Моделі {{modelName}} використовують цю квоту.",
"modelProvider.card.onTrial": "У пробному періоді",
"modelProvider.card.paid": "Оплачено",
"modelProvider.card.priorityUse": "Пріоритетне використання",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Залишилося доступних безкоштовних токенів",
"modelProvider.rerankModel.key": "Модель повторного ранжування",
"modelProvider.rerankModel.tip": "Модель повторного ранжування змінить порядок списку документів-кандидатів на основі семантичної відповідності запиту користувача, покращуючи результати семантичного ранжування.",
"modelProvider.resetDate": "Скинути на {{date}}",
"modelProvider.searchModel": "Пошукова модель",
"modelProvider.selectModel": "Виберіть свою модель",
"modelProvider.selector.emptySetting": "Перейдіть до налаштувань, щоб налаштувати",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "Số lần gọi",
"modelProvider.card.buyQuota": "Mua Quota",
"modelProvider.card.callTimes": "Số lần gọi",
"modelProvider.card.modelAPI": "Các mô hình {{modelName}} đang sử dụng Khóa API.",
"modelProvider.card.modelNotSupported": "Các mô hình {{modelName}} chưa được cài đặt.",
"modelProvider.card.modelSupported": "{{modelName}} mô hình đang sử dụng hạn mức này.",
"modelProvider.card.onTrial": "Thử nghiệm",
"modelProvider.card.paid": "Đã thanh toán",
"modelProvider.card.priorityUse": "Ưu tiên sử dụng",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "Số lượng mã thông báo miễn phí còn lại",
"modelProvider.rerankModel.key": "Mô hình Sắp xếp lại",
"modelProvider.rerankModel.tip": "Mô hình sắp xếp lại sẽ sắp xếp lại danh sách tài liệu ứng cử viên dựa trên sự phù hợp ngữ nghĩa với truy vấn của người dùng, cải thiện kết quả của việc xếp hạng ngữ nghĩa",
"modelProvider.resetDate": "Đặt lại vào {{date}}",
"modelProvider.searchModel": "Mô hình tìm kiếm",
"modelProvider.selectModel": "Chọn mô hình của bạn",
"modelProvider.selector.emptySetting": "Vui lòng vào cài đặt để cấu hình",

View File

@ -339,9 +339,6 @@
"modelProvider.callTimes": "呼叫次數",
"modelProvider.card.buyQuota": "購買額度",
"modelProvider.card.callTimes": "呼叫次數",
"modelProvider.card.modelAPI": "{{modelName}} 模型正在使用 API 金鑰。",
"modelProvider.card.modelNotSupported": "{{modelName}} 模型未安裝。",
"modelProvider.card.modelSupported": "{{modelName}} 模型正在使用這個配額。",
"modelProvider.card.onTrial": "試用中",
"modelProvider.card.paid": "已購買",
"modelProvider.card.priorityUse": "優先使用",
@ -397,7 +394,6 @@
"modelProvider.quotaTip": "剩餘免費額度",
"modelProvider.rerankModel.key": "Rerank 模型",
"modelProvider.rerankModel.tip": "重排序模型將根據候選文件列表與使用者問題語義匹配度進行重新排序,從而改進語義排序的結果",
"modelProvider.resetDate": "在 {{date}} 重置",
"modelProvider.searchModel": "搜尋模型",
"modelProvider.selectModel": "選擇您的模型",
"modelProvider.selector.emptySetting": "請前往設定進行配置",

View File

@ -4,6 +4,12 @@
"version": "1.11.2",
"private": true,
"packageManager": "pnpm@10.27.0+sha512.72d699da16b1179c14ba9e64dc71c9a40988cbdc65c264cb0e489db7de917f20dcf4d64d8723625f2969ba52d4b7e2a1170682d9ac2a5dcaeaab732b7e16f04a",
"imports": {
"#i18n": {
"react-server": "./i18n-config/lib.server.ts",
"default": "./i18n-config/lib.client.ts"
}
},
"engines": {
"node": ">=v22.11.0"
},
@ -47,7 +53,7 @@
"knip": "knip"
},
"dependencies": {
"@amplitude/analytics-browser": "^2.31.3",
"@amplitude/analytics-browser": "^2.33.1",
"@amplitude/plugin-session-replay-browser": "^1.23.6",
"@emoji-mart/data": "^1.2.1",
"@floating-ui/react": "^0.26.28",

81
web/pnpm-lock.yaml generated
View File

@ -58,8 +58,8 @@ importers:
.:
dependencies:
'@amplitude/analytics-browser':
specifier: ^2.31.3
version: 2.31.4
specifier: ^2.33.1
version: 2.33.1
'@amplitude/plugin-session-replay-browser':
specifier: ^1.23.6
version: 1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)
@ -578,8 +578,8 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@amplitude/analytics-browser@2.31.4':
resolution: {integrity: sha512-9O8a0SK55tQOgJJ0z9eE+q/C2xWo6a65wN4iSglxYwm1vvGJKG6Z/QV4XKQ6X0syGscRuG1XoMc0mt3xdVPtDg==}
'@amplitude/analytics-browser@2.33.1':
resolution: {integrity: sha512-93wZjuAFJ7QdyptF82i1pezm5jKuBWITHI++XshDgpks1RstJvJ9n11Ak8MnE4L2BGQ93XDN2aVEHfmQkt0/Pw==}
'@amplitude/analytics-client-common@2.4.16':
resolution: {integrity: sha512-qF7NAl6Qr6QXcWKnldGJfO0Kp1TYoy1xsmzEDnOYzOS96qngtvsZ8MuKya1lWdVACoofwQo82V0VhNZJKk/2YA==}
@ -590,29 +590,32 @@ packages:
'@amplitude/analytics-core@2.33.0':
resolution: {integrity: sha512-56m0R12TjZ41D2YIghb/XNHSdL4CurAVyRT3L2FD+9DCFfbgjfT8xhDBnsZtA+aBkb6Yak1EGUojGBunfAm2/A==}
'@amplitude/analytics-core@2.35.0':
resolution: {integrity: sha512-7RmHYELXCGu8yuO9D6lEXiqkMtiC5sePNhCWmwuP30dneDYHtH06gaYvAFH/YqOFuE6enwEEJfFYtcaPhyiqtA==}
'@amplitude/analytics-types@2.11.0':
resolution: {integrity: sha512-L1niBXYSWmbyHUE/GNuf6YBljbafaxWI3X5jjEIZDFCjQvdWO3DKalY1VPFUbhgYQgWw7+bC6I/AlUaporyfig==}
'@amplitude/experiment-core@0.7.2':
resolution: {integrity: sha512-Wc2NWvgQ+bLJLeF0A9wBSPIaw0XuqqgkPKsoNFQrmS7r5Djd56um75In05tqmVntPJZRvGKU46pAp8o5tdf4mA==}
'@amplitude/plugin-autocapture-browser@1.18.0':
resolution: {integrity: sha512-hBBZpghTEnl+XF8UZaGxe1xCbSjawdmOkJC0/tQF2k1FwlJS/rdWBGmPd8wH7iU4hd55pnSw28Kd2NL7q0zTcA==}
'@amplitude/plugin-autocapture-browser@1.18.3':
resolution: {integrity: sha512-njYque5t1QCEEe5V8Ls4yVVklTM6V7OXxBk6pqznN/hj/Pc4X8Wjy898pZ2VtbnvpagBKKzGb5B6Syl8OXiicw==}
'@amplitude/plugin-network-capture-browser@1.7.0':
resolution: {integrity: sha512-tlwkBL0tlc1OUTT2XYTjWx4mm6O0DSggKzkkDq+8DhW+ZFl9OfHMFIh/hDLJzxs1LTtX7CvFUfAVSDifJOs+NA==}
'@amplitude/plugin-network-capture-browser@1.7.3':
resolution: {integrity: sha512-zfWgAN7g6AigJAsgrGmlgVwydOHH6XvweBoxhU+qEvRydboiIVCDLSxuXczUsBG7kYVLWRdBK1DYoE5J7lqTGA==}
'@amplitude/plugin-page-url-enrichment-browser@0.5.6':
resolution: {integrity: sha512-H6+tf0zYhvM+8oJsdC/kAbIzuxOY/0p+3HBmX4K+G4doo5nCGAB0DYTr6dqMp1GcPOZ09pKT41+DJ6vwSy4ypQ==}
'@amplitude/plugin-page-url-enrichment-browser@0.5.9':
resolution: {integrity: sha512-TqdELx4WrdRutCjHUFUzum/f/UjhbdTZw0UKkYFAj5gwAKDjaPEjL4waRvINOTaVLsne1A6ck4KEMfC8AKByFw==}
'@amplitude/plugin-page-view-tracking-browser@2.6.3':
resolution: {integrity: sha512-lLU4W2r5jXtfn/14cZKM9c9CQDxT7PVVlgm0susHJ3Kfsua9jJQuMHs4Zlg6rwByAtZi5nF4nYE5z0GF09gx0A==}
'@amplitude/plugin-page-view-tracking-browser@2.6.6':
resolution: {integrity: sha512-dBcJlrdKgPzSgS3exDRRrMLqhIaOjwlIy7o8sEMn1PpMawERlbumSSdtfII6L4L67HYUPo4PY4Kp4acqSzaLvQ==}
'@amplitude/plugin-session-replay-browser@1.24.1':
resolution: {integrity: sha512-NHePIu2Yv9ba+fOt5N33b8FFQPzyKvjs1BnWBgBCM5RECos3w6n/+zUWTnTJ4at2ipO2lz111abKDteUwbuptg==}
'@amplitude/plugin-web-vitals-browser@1.1.0':
resolution: {integrity: sha512-TA0X4Np4Wt5hkQ4+Ouhg6nm2xjDd9l03OV9N8Kbe1cqpr/sxvRwSpd+kp2eREbp6D7tHFFkKJA2iNtxbE5Y0cA==}
'@amplitude/plugin-web-vitals-browser@1.1.4':
resolution: {integrity: sha512-XQXI9OjTNSz2yi0lXw2VYMensDzzSkMCfvXNniTb1LgnHwBcQ1JWPcTqHLPFrvvNckeIdOT78vjs7yA+c1FyzA==}
'@amplitude/rrdom@2.0.0-alpha.33':
resolution: {integrity: sha512-uu+1w1RGEJ7QcGPwCC898YBR47DpNYOZTnQMY9/IgMzTXQ0+Hh1/JLsQfMnBBtAePhvCS0BlHd/qGD5w0taIcg==}
@ -8734,8 +8737,8 @@ packages:
web-namespaces@2.0.1:
resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==}
web-vitals@5.0.1:
resolution: {integrity: sha512-BsULPWaCKAAtNntUz0aJq1cu1wyuWmDzf4N6vYNMbYA6zzQAf2pzCYbyClf+Ui2MI54bt225AwugXIfL1W+Syg==}
web-vitals@5.1.0:
resolution: {integrity: sha512-ArI3kx5jI0atlTtmV0fWU3fjpLmq/nD3Zr1iFFlJLaqa5wLBkUSzINwBPySCX/8jRyjlmy1Volw1kz1g9XE4Jg==}
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
@ -9016,14 +9019,14 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@amplitude/analytics-browser@2.31.4':
'@amplitude/analytics-browser@2.33.1':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/plugin-autocapture-browser': 1.18.0
'@amplitude/plugin-network-capture-browser': 1.7.0
'@amplitude/plugin-page-url-enrichment-browser': 0.5.6
'@amplitude/plugin-page-view-tracking-browser': 2.6.3
'@amplitude/plugin-web-vitals-browser': 1.1.0
'@amplitude/analytics-core': 2.35.0
'@amplitude/plugin-autocapture-browser': 1.18.3
'@amplitude/plugin-network-capture-browser': 1.7.3
'@amplitude/plugin-page-url-enrichment-browser': 0.5.9
'@amplitude/plugin-page-view-tracking-browser': 2.6.6
'@amplitude/plugin-web-vitals-browser': 1.1.4
tslib: 2.8.1
'@amplitude/analytics-client-common@2.4.16':
@ -9041,31 +9044,37 @@ snapshots:
tslib: 2.8.1
zen-observable-ts: 1.1.0
'@amplitude/analytics-core@2.35.0':
dependencies:
'@amplitude/analytics-connector': 1.6.4
tslib: 2.8.1
zen-observable-ts: 1.1.0
'@amplitude/analytics-types@2.11.0': {}
'@amplitude/experiment-core@0.7.2':
dependencies:
js-base64: 3.7.8
'@amplitude/plugin-autocapture-browser@1.18.0':
'@amplitude/plugin-autocapture-browser@1.18.3':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
rxjs: 7.8.2
tslib: 2.8.1
'@amplitude/plugin-network-capture-browser@1.7.0':
'@amplitude/plugin-network-capture-browser@1.7.3':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-page-url-enrichment-browser@0.5.6':
'@amplitude/plugin-page-url-enrichment-browser@0.5.9':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-page-view-tracking-browser@2.6.3':
'@amplitude/plugin-page-view-tracking-browser@2.6.6':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
'@amplitude/plugin-session-replay-browser@1.24.1(@amplitude/rrweb@2.0.0-alpha.33)(rollup@2.79.2)':
@ -9080,11 +9089,11 @@ snapshots:
- '@amplitude/rrweb'
- rollup
'@amplitude/plugin-web-vitals-browser@1.1.0':
'@amplitude/plugin-web-vitals-browser@1.1.4':
dependencies:
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
tslib: 2.8.1
web-vitals: 5.0.1
web-vitals: 5.1.0
'@amplitude/rrdom@2.0.0-alpha.33':
dependencies:
@ -9148,7 +9157,7 @@ snapshots:
'@amplitude/targeting@0.2.0':
dependencies:
'@amplitude/analytics-client-common': 2.4.16
'@amplitude/analytics-core': 2.33.0
'@amplitude/analytics-core': 2.35.0
'@amplitude/analytics-types': 2.11.0
'@amplitude/experiment-core': 0.7.2
idb: 8.0.0
@ -18336,7 +18345,7 @@ snapshots:
web-namespaces@2.0.1: {}
web-vitals@5.0.1: {}
web-vitals@5.1.0: {}
webidl-conversions@4.0.2: {}

View File

@ -316,7 +316,7 @@ export type SiteConfig = {
use_icon_as_answer_icon: boolean
}
export type AppIconType = 'image' | 'emoji'
export type AppIconType = 'image' | 'emoji' | 'link'
/**
* App

View File

@ -2,7 +2,7 @@
import { cache } from 'react'
export default <T>(defaultValue: T): [() => T, (v: T) => void] => {
export function serverOnlyContext<T>(defaultValue: T): [() => T, (v: T) => void] {
const getRef = cache(() => ({ current: defaultValue }))
const getValue = (): T => getRef().current