mirror of
https://github.com/langgenius/dify.git
synced 2026-03-17 12:57:51 +08:00
Merge remote-tracking branch 'origin/main' into feat/trigger
This commit is contained in:
@ -71,6 +71,7 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
|
||||
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
|
||||
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
|
||||
const [showExportWarning, setShowExportWarning] = useState(false)
|
||||
|
||||
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
|
||||
name,
|
||||
@ -142,9 +143,11 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
@ -158,6 +161,14 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
|
||||
setShowExportWarning(true)
|
||||
}
|
||||
|
||||
const handleConfirmExport = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
setShowExportWarning(false)
|
||||
try {
|
||||
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
|
||||
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
|
||||
@ -328,13 +339,13 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
|
||||
<div className='flex items-center gap-3 self-stretch'>
|
||||
<AppIcon
|
||||
size="large"
|
||||
size='large'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className='flex w-full grow flex-col items-start justify-center'>
|
||||
<div className='flex flex-1 flex-col items-start justify-center overflow-hidden'>
|
||||
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
@ -430,6 +441,16 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showExportWarning && (
|
||||
<Confirm
|
||||
type="info"
|
||||
isShow={showExportWarning}
|
||||
title={t('workflow.sidebar.exportWarning')}
|
||||
content={t('workflow.sidebar.exportWarningDesc')}
|
||||
onConfirm={handleConfirmExport}
|
||||
onCancel={() => setShowExportWarning(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,47 +0,0 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '../base/app-icon'
|
||||
|
||||
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
|
||||
</svg>
|
||||
|
||||
type Props = {
|
||||
isExternal?: boolean
|
||||
name: string
|
||||
description: string
|
||||
expand: boolean
|
||||
extraInfo?: React.ReactNode
|
||||
}
|
||||
|
||||
const DatasetInfo: FC<Props> = ({
|
||||
name,
|
||||
description,
|
||||
isExternal,
|
||||
expand,
|
||||
extraInfo,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='pl-1 pt-1'>
|
||||
<div className='mr-3 shrink-0'>
|
||||
<AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />
|
||||
</div>
|
||||
<div className={`transition-all duration-200 ease-in-out ${
|
||||
expand
|
||||
? 'mt-2 w-auto opacity-100'
|
||||
: 'pointer-events-none h-0 w-0 overflow-hidden opacity-0'
|
||||
}`}>
|
||||
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>
|
||||
{name}
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase mt-1 whitespace-nowrap text-text-tertiary'>{isExternal ? t('dataset.externalTag') : t('dataset.localDocs')}</div>
|
||||
<div className='system-xs-regular my-3 whitespace-nowrap text-text-tertiary first-letter:capitalize'>{description}</div>
|
||||
</div>
|
||||
{extraInfo}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(DatasetInfo)
|
||||
152
web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
152
web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
@ -0,0 +1,152 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import ActionButton from '../../base/action-button'
|
||||
import { RiMoreFill } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Menu from './menu'
|
||||
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
|
||||
import { useInvalid } from '@/service/use-base'
|
||||
import { useExportPipelineDSL } from '@/service/use-pipeline'
|
||||
import Toast from '../../base/toast'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RenameDatasetModal from '../../datasets/rename-modal'
|
||||
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
|
||||
import Confirm from '../../base/confirm'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
type DropDownProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const DropDown = ({
|
||||
expand,
|
||||
}: DropDownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { replace } = useRouter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showRenameModal, setShowRenameModal] = useState(false)
|
||||
const [confirmMessage, setConfirmMessage] = useState<string>('')
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
|
||||
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(prev => !prev)
|
||||
}, [])
|
||||
|
||||
const invalidDatasetList = useInvalidDatasetList()
|
||||
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
|
||||
|
||||
const refreshDataset = useCallback(() => {
|
||||
invalidDatasetList()
|
||||
invalidDatasetDetail()
|
||||
}, [invalidDatasetDetail, invalidDatasetList])
|
||||
|
||||
const openRenameModal = useCallback(() => {
|
||||
setShowRenameModal(true)
|
||||
handleTrigger()
|
||||
}, [handleTrigger])
|
||||
|
||||
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
|
||||
|
||||
const handleExportPipeline = useCallback(async (include = false) => {
|
||||
const { pipeline_id, name } = dataset
|
||||
if (!pipeline_id)
|
||||
return
|
||||
handleTrigger()
|
||||
try {
|
||||
const { data } = await exportPipelineConfig({
|
||||
pipelineId: pipeline_id,
|
||||
include,
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${name}.pipeline`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
Toast.notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}, [dataset, exportPipelineConfig, handleTrigger, t])
|
||||
|
||||
const detectIsUsedByApp = useCallback(async () => {
|
||||
try {
|
||||
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
|
||||
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
|
||||
setShowConfirmDelete(true)
|
||||
}
|
||||
catch (e: any) {
|
||||
const res = await e.json()
|
||||
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
|
||||
}
|
||||
finally {
|
||||
handleTrigger()
|
||||
}
|
||||
}, [dataset.id, handleTrigger, t])
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
try {
|
||||
await deleteDataset(dataset.id)
|
||||
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
|
||||
invalidDatasetList()
|
||||
replace('/datasets')
|
||||
}
|
||||
finally {
|
||||
setShowConfirmDelete(false)
|
||||
}
|
||||
}, [dataset.id, replace, invalidDatasetList, t])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement={expand ? 'bottom-end' : 'right'}
|
||||
offset={expand ? {
|
||||
mainAxis: 4,
|
||||
crossAxis: 10,
|
||||
} : {
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
|
||||
<RiMoreFill className='size-4' />
|
||||
</ActionButton>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[60]'>
|
||||
<Menu
|
||||
showDelete={!isCurrentWorkspaceDatasetOperator}
|
||||
openRenameModal={openRenameModal}
|
||||
handleExportPipeline={handleExportPipeline}
|
||||
detectIsUsedByApp={detectIsUsedByApp}
|
||||
/>
|
||||
</PortalToFollowElemContent>
|
||||
{showRenameModal && (
|
||||
<RenameDatasetModal
|
||||
show={showRenameModal}
|
||||
dataset={dataset!}
|
||||
onClose={() => setShowRenameModal(false)}
|
||||
onSuccess={refreshDataset}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('dataset.deleteDatasetConfirmTitle')}
|
||||
content={confirmMessage}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(DropDown)
|
||||
91
web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
91
web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import Effect from '../../base/effect'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
import Dropdown from './dropdown'
|
||||
|
||||
type DatasetInfoProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const DatasetInfo: FC<DatasetInfoProps> = ({
|
||||
expand,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
const isExternalProvider = dataset.provider === 'external'
|
||||
const isPipelinePublished = useMemo(() => {
|
||||
return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published
|
||||
}, [dataset.runtime_mode, dataset.is_published])
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
|
||||
return (
|
||||
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
|
||||
{expand && (
|
||||
<Effect className='-left-5 top-[-22px] opacity-15' />
|
||||
)}
|
||||
|
||||
<div className='flex flex-col gap-2 p-2'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className={cn(!expand && '-ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className='ml-auto'>
|
||||
<Dropdown expand />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className='-mb-2 -mt-1 flex items-center justify-center'>
|
||||
<Dropdown expand={false} />
|
||||
</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className='flex flex-col gap-y-1 pb-0.5'>
|
||||
<div
|
||||
className='system-md-semibold truncate text-text-secondary'
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{isExternalProvider && t('dataset.externalTag')}
|
||||
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(DatasetInfo)
|
||||
30
web/app/components/app-sidebar/dataset-info/menu-item.tsx
Normal file
30
web/app/components/app-sidebar/dataset-info/menu-item.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React from 'react'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
|
||||
type MenuItemProps = {
|
||||
name: string
|
||||
Icon: RemixiconComponentType
|
||||
handleClick?: () => void
|
||||
}
|
||||
|
||||
const MenuItem = ({
|
||||
Icon,
|
||||
name,
|
||||
handleClick,
|
||||
}: MenuItemProps) => {
|
||||
return (
|
||||
<div
|
||||
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleClick?.()
|
||||
}}
|
||||
>
|
||||
<Icon className='size-4 text-text-tertiary' />
|
||||
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(MenuItem)
|
||||
52
web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
52
web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MenuItem from './menu-item'
|
||||
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
|
||||
import Divider from '../../base/divider'
|
||||
|
||||
type MenuProps = {
|
||||
showDelete: boolean
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: () => void
|
||||
detectIsUsedByApp: () => void
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
showDelete,
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
}: MenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
<div className='flex flex-col p-1'>
|
||||
<MenuItem
|
||||
Icon={RiEditLine}
|
||||
name={t('common.operation.edit')}
|
||||
handleClick={openRenameModal}
|
||||
/>
|
||||
<MenuItem
|
||||
Icon={RiFileDownloadLine}
|
||||
name={t('datasetPipeline.operations.exportPipeline')}
|
||||
handleClick={handleExportPipeline}
|
||||
/>
|
||||
</div>
|
||||
{showDelete && (
|
||||
<>
|
||||
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
|
||||
<div className='flex flex-col p-1'>
|
||||
<MenuItem
|
||||
Icon={RiDeleteBinLine}
|
||||
name={t('common.operation.delete')}
|
||||
handleClick={detectIsUsedByApp}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Menu)
|
||||
164
web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
164
web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import {
|
||||
RiMenuLine,
|
||||
} from '@remixicon/react'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Divider from '../base/divider'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
import Effect from '../base/effect'
|
||||
import Dropdown from './dataset-info/dropdown'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DOC_FORM_TEXT } from '@/models/datasets'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
|
||||
import ExtraInfo from '../datasets/extra-info'
|
||||
|
||||
type DatasetSidebarDropdownProps = {
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
disabled?: boolean
|
||||
}>
|
||||
}
|
||||
|
||||
const DatasetSidebarDropdown = ({
|
||||
navigation,
|
||||
}: DatasetSidebarDropdownProps) => {
|
||||
const { t } = useTranslation()
|
||||
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
|
||||
|
||||
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
|
||||
|
||||
const [open, doSetOpen] = useState(false)
|
||||
const openRef = useRef(open)
|
||||
const setOpen = useCallback((v: boolean) => {
|
||||
doSetOpen(v)
|
||||
openRef.current = v
|
||||
}, [doSetOpen])
|
||||
const handleTrigger = useCallback(() => {
|
||||
setOpen(!openRef.current)
|
||||
}, [setOpen])
|
||||
|
||||
const iconInfo = dataset.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
const isExternalProvider = dataset.provider === 'external'
|
||||
const { formatIndexingTechniqueAndMethod } = useKnowledge()
|
||||
|
||||
if (!dataset)
|
||||
return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='fixed left-2 top-2 z-20'>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={{
|
||||
mainAxis: -41,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
|
||||
open && 'bg-background-default-hover',
|
||||
)}
|
||||
>
|
||||
<AppIcon
|
||||
size='small'
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<RiMenuLine className='size-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50'>
|
||||
<div className='relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg'>
|
||||
<Effect className='-left-5 top-[-22px] opacity-15' />
|
||||
<div className='flex flex-col gap-y-2 p-4'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<AppIcon
|
||||
size='medium'
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_url}
|
||||
/>
|
||||
<Dropdown expand />
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 pb-0.5'>
|
||||
<div
|
||||
className='system-md-semibold truncate text-text-secondary'
|
||||
title={dataset.name}
|
||||
>
|
||||
{dataset.name}
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>
|
||||
{isExternalProvider && t('dataset.externalTag')}
|
||||
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
|
||||
<div className='flex items-center gap-x-2'>
|
||||
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
|
||||
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!!dataset.description && (
|
||||
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
|
||||
{dataset.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className='px-4 py-2'>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
bgStyle='gradient'
|
||||
className='my-0 h-px bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||
/>
|
||||
</div>
|
||||
<nav className='flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2'>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink
|
||||
key={index}
|
||||
mode='expand'
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<ExtraInfo
|
||||
relatedApps={relatedApps}
|
||||
expand
|
||||
documentCount={dataset.document_count}
|
||||
/>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatasetSidebarDropdown
|
||||
@ -1,10 +1,8 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { RiLayoutLeft2Line, RiLayoutRight2Line } from '@remixicon/react'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import AppBasic from './basic'
|
||||
import AppInfo from './app-info'
|
||||
import DatasetInfo from './dataset-info'
|
||||
import AppSidebarDropdown from './app-sidebar-dropdown'
|
||||
@ -12,39 +10,48 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import cn from '@/utils/classnames'
|
||||
import Divider from '../base/divider'
|
||||
import { useHover, useKeyPress } from 'ahooks'
|
||||
import ToggleButton from './toggle-button'
|
||||
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
|
||||
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
|
||||
|
||||
export type IAppDetailNavProps = {
|
||||
iconType?: 'app' | 'dataset' | 'notion'
|
||||
title: string
|
||||
desc: string
|
||||
isExternal?: boolean
|
||||
icon: string
|
||||
icon_background: string | null
|
||||
iconType?: 'app' | 'dataset'
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigation, extraInfo, iconType = 'app' }: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSiderbarExpand } = useAppStore(useShallow(state => ({
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
setAppSiderbarExpand: state.setAppSiderbarExpand,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
})))
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null)
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const expand = appSidebarExpand === 'expand'
|
||||
|
||||
const handleToggle = (state: string) => {
|
||||
setAppSiderbarExpand(state === 'expand' ? 'collapse' : 'expand')
|
||||
}
|
||||
const handleToggle = useCallback(() => {
|
||||
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
// // Check if the current path is a workflow canvas & fullscreen
|
||||
const isHoveringSidebar = useHover(sidebarRef)
|
||||
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const pathname = usePathname()
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
|
||||
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
@ -57,9 +64,14 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
|
||||
useEffect(() => {
|
||||
if (appSidebarExpand) {
|
||||
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
|
||||
setAppSiderbarExpand(appSidebarExpand)
|
||||
setAppSidebarExpand(appSidebarExpand)
|
||||
}
|
||||
}, [appSidebarExpand, setAppSiderbarExpand])
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
if (inWorkflowCanvas && hideHeader) {
|
||||
return (
|
||||
@ -69,76 +81,74 @@ const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigati
|
||||
)
|
||||
}
|
||||
|
||||
if (isPipelineCanvas && hideHeader) {
|
||||
return (
|
||||
<div className='flex w-0 shrink-0'>
|
||||
<DatasetSidebarDropdown navigation={navigation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
|
||||
${expand ? 'w-[216px]' : 'w-14'}
|
||||
`}
|
||||
ref={sidebarRef}
|
||||
className={cn(
|
||||
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
|
||||
expand ? 'w-[216px]' : 'w-14',
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
shrink-0
|
||||
${expand ? 'p-2' : 'p-1'}
|
||||
`}
|
||||
className={cn(
|
||||
'shrink-0',
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType === 'dataset' && (
|
||||
<DatasetInfo
|
||||
name={title}
|
||||
description={desc}
|
||||
isExternal={isExternal}
|
||||
expand={expand}
|
||||
extraInfo={extraInfo && extraInfo(appSidebarExpand)}
|
||||
/>
|
||||
)}
|
||||
{!['app', 'dataset'].includes(iconType) && (
|
||||
<AppBasic
|
||||
mode={appSidebarExpand}
|
||||
iconType={iconType}
|
||||
icon={icon}
|
||||
icon_background={icon_background}
|
||||
name={title}
|
||||
type={desc}
|
||||
isExternal={isExternal}
|
||||
/>
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
<div className='px-4'>
|
||||
<div className={cn('mx-auto mt-1 h-px bg-divider-subtle', !expand && 'w-6')} />
|
||||
<div className='relative px-4 py-2'>
|
||||
<Divider
|
||||
type='horizontal'
|
||||
bgStyle={expand ? 'gradient' : 'solid'}
|
||||
className={cn(
|
||||
'my-0 h-px',
|
||||
expand
|
||||
? 'bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
|
||||
: 'bg-divider-subtle',
|
||||
)}
|
||||
/>
|
||||
{!isMobile && isHoveringSidebar && (
|
||||
<ToggleButton
|
||||
className='absolute -right-3 top-[-3.5px] z-20'
|
||||
expand={expand}
|
||||
handleToggle={handleToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<nav
|
||||
className={`
|
||||
grow space-y-1
|
||||
${expand ? 'p-4' : 'px-2.5 py-4'}
|
||||
`}
|
||||
className={cn(
|
||||
'flex grow flex-col gap-y-0.5',
|
||||
expand ? 'px-3 py-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink key={index} mode={appSidebarExpand} iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
|
||||
<NavLink
|
||||
key={index}
|
||||
mode={appSidebarExpand}
|
||||
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
|
||||
name={item.name}
|
||||
href={item.href}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{
|
||||
!isMobile && (
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
>
|
||||
<div
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center'
|
||||
onClick={() => handleToggle(appSidebarExpand)}
|
||||
>
|
||||
{
|
||||
expand
|
||||
? <RiLayoutRight2Line className='h-5 w-5 text-components-menu-item-text' />
|
||||
: <RiLayoutLeft2Line className='h-5 w-5 text-components-menu-item-text' />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -25,7 +25,7 @@ const MockIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="nav-icon" />
|
||||
)
|
||||
|
||||
describe('NavLink Text Animation Issues', () => {
|
||||
describe('NavLink Animation and Layout Issues', () => {
|
||||
const mockProps: NavLinkProps = {
|
||||
name: 'Orchestrate',
|
||||
href: '/app/123/workflow',
|
||||
@ -61,108 +61,129 @@ describe('NavLink Text Animation Issues', () => {
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('w-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
|
||||
// Icon should still be present
|
||||
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
|
||||
|
||||
// Check padding in collapse mode
|
||||
// Check consistent padding in collapse mode
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('px-2.5')
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// Switch to expand mode - this is where the squeeze effect occurs
|
||||
// Switch to expand mode - should have smooth text transition
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text should now appear
|
||||
// Text should now be visible with opacity animation
|
||||
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// Check padding change - this contributes to the squeeze effect
|
||||
expect(linkElement).toHaveClass('px-3')
|
||||
// Check padding remains consistent - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// The bug: text appears abruptly without smooth transition
|
||||
// This test documents the current behavior that causes the squeeze effect
|
||||
// Fixed: text now uses max-width animation instead of abrupt show/hide
|
||||
const expandedTextElement = screen.getByText('Orchestrate')
|
||||
expect(expandedTextElement).toBeInTheDocument()
|
||||
expect(expandedTextElement).toHaveClass('max-w-none')
|
||||
expect(expandedTextElement).toHaveClass('opacity-100')
|
||||
|
||||
// In a properly animated version, we would expect:
|
||||
// The fix provides:
|
||||
// - Opacity transition from 0 to 1
|
||||
// - Width transition from 0 to auto
|
||||
// - No layout shift from padding changes
|
||||
// - Max-width transition from 0 to none (prevents squashing)
|
||||
// - No layout shift from consistent padding
|
||||
})
|
||||
|
||||
it('should maintain icon position consistency during text appearance', () => {
|
||||
it('should maintain icon position consistency using wrapper div', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const initialIconClasses = iconElement.className
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Icon should have mr-0 in collapse mode
|
||||
expect(iconElement).toHaveClass('mr-0')
|
||||
// Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const expandedIconClasses = iconElement.className
|
||||
// In expand mode, wrapper should not have the micro-adjustment
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
|
||||
|
||||
// Icon should have mr-2 in expand mode - this shift contributes to the squeeze effect
|
||||
expect(iconElement).toHaveClass('mr-2')
|
||||
// Icon itself maintains consistent classes - no margin changes
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
console.log('Collapsed icon classes:', initialIconClasses)
|
||||
console.log('Expanded icon classes:', expandedIconClasses)
|
||||
|
||||
// This margin change causes the icon to shift when text appears
|
||||
// This wrapper approach eliminates the icon margin shift issue
|
||||
})
|
||||
|
||||
it('should document the abrupt text rendering issue', () => {
|
||||
it('should provide smooth text transition with max-width animation', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// Text is present in DOM but hidden via CSS classes
|
||||
// Text is always in DOM but controlled via CSS classes
|
||||
const collapsedText = screen.getByText('Orchestrate')
|
||||
expect(collapsedText).toBeInTheDocument()
|
||||
expect(collapsedText).toHaveClass('opacity-0')
|
||||
expect(collapsedText).toHaveClass('pointer-events-none')
|
||||
expect(collapsedText).toHaveClass('max-w-0')
|
||||
expect(collapsedText).toHaveClass('overflow-hidden')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text suddenly appears in DOM - no transition
|
||||
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
|
||||
// Text smoothly transitions to visible state
|
||||
const expandedText = screen.getByText('Orchestrate')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('max-w-none')
|
||||
|
||||
// The issue: {mode === 'expand' && name} causes abrupt show/hide
|
||||
// instead of smooth opacity/width transition
|
||||
// Fixed: Always present in DOM with smooth CSS transitions
|
||||
// instead of abrupt conditional rendering
|
||||
})
|
||||
})
|
||||
|
||||
describe('Layout Shift Issues', () => {
|
||||
it('should detect padding differences causing layout shifts', () => {
|
||||
describe('Layout Consistency Improvements', () => {
|
||||
it('should maintain consistent padding across all states', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
|
||||
// Collapsed state padding
|
||||
expect(linkElement).toHaveClass('px-2.5')
|
||||
// Consistent padding in collapsed state
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Expanded state padding - different value causes layout shift
|
||||
expect(linkElement).toHaveClass('px-3')
|
||||
// Same padding in expanded state - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// This 2px difference (10px vs 12px) contributes to the squeeze effect
|
||||
// This consistency eliminates the layout shift issue
|
||||
})
|
||||
|
||||
it('should detect icon margin changes causing shifts', () => {
|
||||
it('should use wrapper-based icon positioning instead of margin changes', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Collapsed: no right margin
|
||||
expect(iconElement).toHaveClass('mr-0')
|
||||
// Collapsed: wrapper has micro-adjustment for centering
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
|
||||
// Icon itself has consistent classes
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Expanded: 8px right margin (mr-2)
|
||||
expect(iconElement).toHaveClass('mr-2')
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
|
||||
// This sudden margin appearance causes the squeeze effect
|
||||
// Expanded: no wrapper adjustment needed
|
||||
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
|
||||
|
||||
// Icon classes remain consistent - no margin shifts
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
})
|
||||
})
|
||||
|
||||
@ -172,7 +193,7 @@ describe('NavLink Text Animation Issues', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
let linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).not.toHaveClass('bg-state-accent-active')
|
||||
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
|
||||
|
||||
// Test with active state (when href matches current segment)
|
||||
const activeProps = {
|
||||
@ -183,7 +204,63 @@ describe('NavLink Text Animation Issues', () => {
|
||||
rerender(<NavLink {...activeProps} mode="expand" />)
|
||||
|
||||
linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('bg-state-accent-active')
|
||||
expect(linkElement).toHaveClass('bg-components-menu-item-bg-active')
|
||||
expect(linkElement).toHaveClass('text-text-accent-light-mode-only')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Animation Classes', () => {
|
||||
it('should have proper text classes in collapsed mode', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
})
|
||||
|
||||
it('should have proper text classes in expanded mode', () => {
|
||||
render(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
expect(textElement).toHaveClass('ml-2')
|
||||
expect(textElement).toHaveClass('max-w-none')
|
||||
expect(textElement).toHaveClass('opacity-100')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Disabled State', () => {
|
||||
it('should render as button when disabled', () => {
|
||||
render(<NavLink {...mockProps} mode="expand" disabled={true} />)
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toBeInTheDocument()
|
||||
expect(buttonElement).toBeDisabled()
|
||||
expect(buttonElement).toHaveClass('cursor-not-allowed')
|
||||
expect(buttonElement).toHaveClass('opacity-30')
|
||||
})
|
||||
|
||||
it('should maintain consistent styling in disabled state', () => {
|
||||
render(<NavLink {...mockProps} mode="collapse" disabled={true} />)
|
||||
|
||||
const buttonElement = screen.getByRole('button')
|
||||
expect(buttonElement).toHaveClass('pl-3')
|
||||
expect(buttonElement).toHaveClass('pr-1')
|
||||
|
||||
const iconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { useSelectedLayoutSegment } from 'next/navigation'
|
||||
import Link from 'next/link'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
|
||||
export type NavIcon = React.ComponentType<
|
||||
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
|
||||
title?: string | undefined
|
||||
titleId?: string | undefined
|
||||
}> | RemixiconComponentType
|
||||
React.PropsWithoutRef<React.ComponentProps<'svg'>> & {
|
||||
title?: string | undefined
|
||||
titleId?: string | undefined
|
||||
}> | RemixiconComponentType
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
@ -19,14 +19,16 @@ export type NavLinkProps = {
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export default function NavLink({
|
||||
const NavLink = ({
|
||||
name,
|
||||
href,
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
}: NavLinkProps) {
|
||||
disabled = false,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
let res = segment?.toLowerCase()
|
||||
@ -39,30 +41,59 @@ export default function NavLink({
|
||||
const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment
|
||||
const NavIcon = isActive ? iconMap.selected : iconMap.normal
|
||||
|
||||
const renderIcon = () => (
|
||||
<div className={classNames(mode !== 'expand' && '-ml-1')}>
|
||||
<NavIcon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
</div>
|
||||
)
|
||||
|
||||
if (disabled) {
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type='button'
|
||||
disabled
|
||||
className={classNames(
|
||||
'system-sm-medium flex h-8 cursor-not-allowed items-center rounded-lg text-components-menu-item-text opacity-30 hover:bg-components-menu-item-bg-hover',
|
||||
'pl-3 pr-1',
|
||||
)}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
aria-disabled
|
||||
>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={name}
|
||||
href={href}
|
||||
className={classNames(
|
||||
isActive ? 'bg-state-accent-active font-semibold text-text-accent' : 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
|
||||
'group flex h-9 items-center rounded-md py-2 text-sm font-normal',
|
||||
mode === 'expand' ? 'px-3' : 'px-2.5',
|
||||
isActive
|
||||
? 'system-sm-semibold border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only'
|
||||
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover',
|
||||
'flex h-8 items-center rounded-lg pl-3 pr-1',
|
||||
)}
|
||||
title={mode === 'collapse' ? name : ''}
|
||||
>
|
||||
<NavIcon
|
||||
className={classNames(
|
||||
'h-4 w-4 shrink-0',
|
||||
mode === 'expand' ? 'mr-2' : 'mr-0',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{renderIcon()}
|
||||
<span
|
||||
className={classNames(
|
||||
'whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
'overflow-hidden whitespace-nowrap transition-all duration-200 ease-in-out',
|
||||
mode === 'expand'
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0',
|
||||
? 'ml-2 max-w-none opacity-100'
|
||||
: 'ml-0 max-w-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
@ -70,3 +101,5 @@ export default function NavLink({
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NavLink)
|
||||
|
||||
71
web/app/components/app-sidebar/toggle-button.tsx
Normal file
71
web/app/components/app-sidebar/toggle-button.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React from 'react'
|
||||
import Button from '../base/button'
|
||||
import { RiArrowLeftSLine, RiArrowRightSLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '../base/tooltip'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { getKeyboardKeyNameBySystem } from '../workflow/utils'
|
||||
|
||||
type TooltipContentProps = {
|
||||
expand: boolean
|
||||
}
|
||||
|
||||
const TOGGLE_SHORTCUT = ['ctrl', 'B']
|
||||
|
||||
const TooltipContent = ({
|
||||
expand,
|
||||
}: TooltipContentProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<span className='system-xs-medium px-0.5 text-text-secondary'>{expand ? t('layout.sidebar.collapseSidebar') : t('layout.sidebar.expandSidebar')}</span>
|
||||
<div className='flex items-center gap-x-0.5'>
|
||||
{
|
||||
TOGGLE_SHORTCUT.map(key => (
|
||||
<span
|
||||
key={key}
|
||||
className='system-kbd inline-flex items-center justify-center rounded-[4px] bg-components-kbd-bg-gray px-1 text-text-tertiary'
|
||||
>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</span>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type ToggleButtonProps = {
|
||||
expand: boolean
|
||||
handleToggle: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
const ToggleButton = ({
|
||||
expand,
|
||||
handleToggle,
|
||||
className,
|
||||
}: ToggleButtonProps) => {
|
||||
return (
|
||||
<Tooltip
|
||||
popupContent={<TooltipContent expand={expand} />}
|
||||
popupClassName='p-1.5 rounded-lg'
|
||||
position='right'
|
||||
>
|
||||
<Button
|
||||
size='small'
|
||||
onClick={handleToggle}
|
||||
className={cn('rounded-full px-1', className)}
|
||||
>
|
||||
{
|
||||
expand
|
||||
? <RiArrowLeftSLine className='size-4' />
|
||||
: <RiArrowRightSLine className='size-4' />
|
||||
}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ToggleButton)
|
||||
@ -60,9 +60,11 @@ const HeaderOptions: FC<Props> = ({
|
||||
const a = document.createElement('a')
|
||||
const content = listTransformer(list).join('\n')
|
||||
const file = new Blob([content], { type: 'application/jsonl' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `annotations-${locale}.jsonl`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
|
||||
@ -18,7 +18,7 @@ import {
|
||||
RiVerifiedBadgeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
|
||||
import Toast from '../../base/toast'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import Divider from '../../base/divider'
|
||||
@ -66,7 +66,7 @@ export type AppPublisherProps = {
|
||||
onRefreshData?: () => void
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['⌘', '⇧', 'P']
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
const AppPublisher = ({
|
||||
disabled = false,
|
||||
@ -250,7 +250,7 @@ const AppPublisher = ({
|
||||
<div className='flex gap-0.5'>
|
||||
{PUBLISH_SHORTCUT.map(key => (
|
||||
<span key={key} className='system-kbd h-4 w-4 rounded-[4px] bg-components-kbd-bg-white text-text-primary-on-surface'>
|
||||
{key}
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -54,7 +54,7 @@ const TypeSelector: FC<Props> = ({
|
||||
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className='size-4 shrink-0 text-text-secondary' />
|
||||
<span
|
||||
className={`
|
||||
ml-1.5 ${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
`}
|
||||
>
|
||||
{selectedItem?.name}
|
||||
|
||||
@ -45,7 +45,7 @@ const ConfigVision: FC = () => {
|
||||
if (draft.file) {
|
||||
draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0
|
||||
draft.file.image = {
|
||||
...(draft.file.image || {}),
|
||||
...draft.file.image,
|
||||
enabled: value,
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,6 +50,7 @@ export type IGetAutomaticResProps = {
|
||||
onFinished: (res: GenRes) => void
|
||||
flowId?: string
|
||||
nodeId?: string
|
||||
editorId?: string
|
||||
currentPrompt?: string
|
||||
isBasicMode?: boolean
|
||||
}
|
||||
@ -76,6 +77,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
onClose,
|
||||
flowId,
|
||||
nodeId,
|
||||
editorId,
|
||||
currentPrompt,
|
||||
isBasicMode,
|
||||
onFinished,
|
||||
@ -132,7 +134,8 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
},
|
||||
]
|
||||
|
||||
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}`}`)
|
||||
// eslint-disable-next-line sonarjs/no-nested-template-literals, sonarjs/no-nested-conditional
|
||||
const [instructionFromSessionStorage, setInstruction] = useSessionStorageState<string>(`improve-instruction-${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`)
|
||||
const instruction = instructionFromSessionStorage || ''
|
||||
const [ideaOutput, setIdeaOutput] = useState<string>('')
|
||||
|
||||
@ -166,7 +169,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
return true
|
||||
}
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}`}`
|
||||
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`
|
||||
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
@ -8,16 +8,13 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SettingsModal from '../settings-modal'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { DataSourceType } from '@/models/datasets'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import { Folder } from '@/app/components/base/icons/src/vender/solid/files'
|
||||
import { Globe06 } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
type ItemProps = {
|
||||
className?: string
|
||||
@ -47,33 +44,26 @@ const Item: FC<ItemProps> = ({
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
const iconInfo = config.icon_info || {
|
||||
icon: '📙',
|
||||
icon_type: 'emoji',
|
||||
icon_background: '#FFF4ED',
|
||||
icon_url: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}>
|
||||
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||
{
|
||||
config.data_source_type === DataSourceType.FILE && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF]'>
|
||||
<Folder className='h-4 w-4 text-[#444CE7]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
config.data_source_type === DataSourceType.NOTION && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5]'>
|
||||
<FileIcon type='notion' className='h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
config.data_source_type === DataSourceType.WEB && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-blue-100 bg-[#F5FAFF]'>
|
||||
<Globe06 className='h-4 w-4 text-blue-600' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<AppIcon
|
||||
size='tiny'
|
||||
iconType={iconInfo.icon_type}
|
||||
icon={iconInfo.icon}
|
||||
background={iconInfo.icon_type === 'image' ? undefined : iconInfo.icon_background}
|
||||
imageUrl={iconInfo.icon_type === 'image' ? iconInfo.icon_url : undefined}
|
||||
/>
|
||||
<div className='system-sm-medium w-0 grow truncate text-text-secondary' title={config.name}>{config.name}</div>
|
||||
</div>
|
||||
<div className='ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex'>
|
||||
|
||||
@ -21,10 +21,12 @@ type Value = {
|
||||
type WeightedScoreProps = {
|
||||
value: Value
|
||||
onChange: (value: Value) => void
|
||||
readonly?: boolean
|
||||
}
|
||||
const WeightedScore = ({
|
||||
value,
|
||||
onChange = noop,
|
||||
readonly = false,
|
||||
}: WeightedScoreProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
@ -37,8 +39,9 @@ const WeightedScore = ({
|
||||
min={0}
|
||||
step={0.1}
|
||||
value={value.value[0]}
|
||||
onChange={v => onChange({ value: [v, (10 - v * 10) / 10] })}
|
||||
onChange={v => !readonly && onChange({ value: [v, (10 - v * 10) / 10] })}
|
||||
trackClassName='weightedScoreSliderTrack'
|
||||
disabled={readonly}
|
||||
/>
|
||||
<div className='mt-3 flex justify-between'>
|
||||
<div className='system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500'>
|
||||
|
||||
@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react'
|
||||
import { useGetState, useInfiniteScroll } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import TypeIcon from '../type-icon'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -13,6 +12,7 @@ import Loading from '@/app/components/base/loading'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { useKnowledge } from '@/hooks/use-knowledge'
|
||||
import cn from '@/utils/classnames'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
|
||||
export type ISelectDataSetProps = {
|
||||
isShow: boolean
|
||||
@ -88,6 +88,7 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
|
||||
const handleSelect = () => {
|
||||
onSelect(selected)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
@ -132,7 +133,13 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
|
||||
>
|
||||
<div className='mr-1 flex items-center overflow-hidden'>
|
||||
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
|
||||
<TypeIcon type="upload_file" size='md' />
|
||||
<AppIcon
|
||||
size='tiny'
|
||||
iconType={item.icon_info.icon_type}
|
||||
icon={item.icon_info.icon}
|
||||
background={item.icon_info.icon_type === 'image' ? undefined : item.icon_info.icon_background}
|
||||
imageUrl={item.icon_info.icon_type === 'image' ? item.icon_info.icon_url : undefined}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn('max-w-[200px] truncate text-[13px] font-medium text-text-secondary', !item.embedding_available && '!max-w-[120px] opacity-30')}>{item.name}</div>
|
||||
{!item.embedding_available && (
|
||||
|
||||
@ -6,7 +6,7 @@ import { isEqual } from 'lodash-es'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import cn from '@/utils/classnames'
|
||||
import IndexMethodRadio from '@/app/components/datasets/settings/index-method-radio'
|
||||
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -31,6 +31,7 @@ import {
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import type { Member } from '@/models/common'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type SettingsModalProps = {
|
||||
@ -73,6 +74,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
|
||||
const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
|
||||
const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
|
||||
const [keywordNumber, setKeywordNumber] = useState(currentDataset.keyword_number ?? 10)
|
||||
|
||||
const handleValueChange = (type: string, value: string) => {
|
||||
setLocaleCurrentDataset({ ...localeCurrentDataset, [type]: value })
|
||||
@ -124,6 +126,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
description,
|
||||
permission,
|
||||
indexing_technique: indexMethod,
|
||||
keyword_number: keywordNumber,
|
||||
retrieval_model: {
|
||||
...retrievalConfig,
|
||||
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
|
||||
@ -245,17 +248,18 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.indexMethod')}</div>
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<IndexMethodRadio
|
||||
disable={!localeCurrentDataset?.embedding_available}
|
||||
<IndexMethod
|
||||
disabled={!localeCurrentDataset?.embedding_available}
|
||||
value={indexMethod}
|
||||
onChange={v => setIndexMethod(v!)}
|
||||
docForm={currentDataset.doc_form}
|
||||
onChange={setIndexMethod}
|
||||
currentValue={currentDataset.indexing_technique}
|
||||
keywordNumber={keywordNumber}
|
||||
onKeywordNumberChange={setKeywordNumber}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{indexMethod === 'high_quality' && (
|
||||
{indexMethod === IndexingType.QUALIFIED && (
|
||||
<div className={cn(rowClass)}>
|
||||
<div className={labelClass}>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.embeddingModel')}</div>
|
||||
@ -334,7 +338,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{indexMethod === 'high_quality'
|
||||
{indexMethod === IndexingType.QUALIFIED
|
||||
? (
|
||||
<RetrievalMethodConfig
|
||||
value={retrievalConfig}
|
||||
|
||||
@ -94,9 +94,9 @@ const Configuration: FC = () => {
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
|
||||
|
||||
const { appDetail, showAppConfigureFeaturesModal, setAppSiderbarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
|
||||
const { appDetail, showAppConfigureFeaturesModal, setAppSidebarExpand, setShowAppConfigureFeaturesModal } = useAppStore(useShallow(state => ({
|
||||
appDetail: state.appDetail,
|
||||
setAppSiderbarExpand: state.setAppSiderbarExpand,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
showAppConfigureFeaturesModal: state.showAppConfigureFeaturesModal,
|
||||
setShowAppConfigureFeaturesModal: state.setShowAppConfigureFeaturesModal,
|
||||
})))
|
||||
@ -464,6 +464,7 @@ const Configuration: FC = () => {
|
||||
provider,
|
||||
modelId,
|
||||
completionParams,
|
||||
isAdvancedMode,
|
||||
)
|
||||
if (Object.keys(removedDetails).length)
|
||||
Toast.notify({ type: 'warning', message: `${t('common.modelProvider.parametersInvalidRemoved')}: ${Object.entries(removedDetails).map(([k, reason]) => `${k} (${reason})`).join(', ')}` })
|
||||
@ -842,7 +843,7 @@ const Configuration: FC = () => {
|
||||
{ id: `${Date.now()}-no-repeat`, model: '', provider: '', parameters: {} },
|
||||
],
|
||||
)
|
||||
setAppSiderbarExpand('collapse')
|
||||
setAppSidebarExpand('collapse')
|
||||
}
|
||||
|
||||
if (isLoading || isLoadingCurrentWorkspace || !currentWorkspace.id) {
|
||||
@ -850,84 +851,83 @@ const Configuration: FC = () => {
|
||||
<Loading type='area' />
|
||||
</div>
|
||||
}
|
||||
|
||||
const value = {
|
||||
appId,
|
||||
isAPIKeySet,
|
||||
isTrailFinished: false,
|
||||
mode,
|
||||
modelModeType,
|
||||
promptMode,
|
||||
isAdvancedMode,
|
||||
isAgent,
|
||||
isOpenAI,
|
||||
isFunctionCall,
|
||||
collectionList,
|
||||
setPromptMode,
|
||||
canReturnToSimpleMode,
|
||||
setCanReturnToSimpleMode,
|
||||
chatPromptConfig,
|
||||
completionPromptConfig,
|
||||
currentAdvancedPrompt,
|
||||
setCurrentAdvancedPrompt,
|
||||
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
|
||||
showHistoryModal,
|
||||
setConversationHistoriesRole,
|
||||
hasSetBlockStatus,
|
||||
conversationId,
|
||||
introduction,
|
||||
setIntroduction,
|
||||
suggestedQuestions,
|
||||
setSuggestedQuestions,
|
||||
setConversationId,
|
||||
controlClearChatMessage,
|
||||
setControlClearChatMessage,
|
||||
prevPromptConfig,
|
||||
setPrevPromptConfig,
|
||||
moreLikeThisConfig,
|
||||
setMoreLikeThisConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
setSuggestedQuestionsAfterAnswerConfig,
|
||||
speechToTextConfig,
|
||||
setSpeechToTextConfig,
|
||||
textToSpeechConfig,
|
||||
setTextToSpeechConfig,
|
||||
citationConfig,
|
||||
setCitationConfig,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
moderationConfig,
|
||||
setModerationConfig,
|
||||
externalDataToolsConfig,
|
||||
setExternalDataToolsConfig,
|
||||
formattingChanged,
|
||||
setFormattingChanged,
|
||||
inputs,
|
||||
setInputs,
|
||||
query,
|
||||
setQuery,
|
||||
completionParams,
|
||||
setCompletionParams,
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
showSelectDataSet,
|
||||
dataSets,
|
||||
setDataSets,
|
||||
datasetConfigs,
|
||||
datasetConfigsRef,
|
||||
setDatasetConfigs,
|
||||
hasSetContextVar,
|
||||
isShowVisionConfig,
|
||||
visionConfig,
|
||||
setVisionConfig: handleSetVisionConfig,
|
||||
isAllowVideoUpload,
|
||||
isShowDocumentConfig,
|
||||
isShowAudioConfig,
|
||||
rerankSettingModalOpen,
|
||||
setRerankSettingModalOpen,
|
||||
}
|
||||
return (
|
||||
<ConfigContext.Provider value={{
|
||||
appId,
|
||||
isAPIKeySet,
|
||||
isTrailFinished: false,
|
||||
mode,
|
||||
modelModeType,
|
||||
promptMode,
|
||||
isAdvancedMode,
|
||||
isAgent,
|
||||
isOpenAI,
|
||||
isFunctionCall,
|
||||
collectionList,
|
||||
setPromptMode,
|
||||
canReturnToSimpleMode,
|
||||
setCanReturnToSimpleMode,
|
||||
chatPromptConfig,
|
||||
completionPromptConfig,
|
||||
currentAdvancedPrompt,
|
||||
setCurrentAdvancedPrompt,
|
||||
conversationHistoriesRole: completionPromptConfig.conversation_histories_role,
|
||||
showHistoryModal,
|
||||
setConversationHistoriesRole,
|
||||
hasSetBlockStatus,
|
||||
conversationId,
|
||||
introduction,
|
||||
setIntroduction,
|
||||
suggestedQuestions,
|
||||
setSuggestedQuestions,
|
||||
setConversationId,
|
||||
controlClearChatMessage,
|
||||
setControlClearChatMessage,
|
||||
prevPromptConfig,
|
||||
setPrevPromptConfig,
|
||||
moreLikeThisConfig,
|
||||
setMoreLikeThisConfig,
|
||||
suggestedQuestionsAfterAnswerConfig,
|
||||
setSuggestedQuestionsAfterAnswerConfig,
|
||||
speechToTextConfig,
|
||||
setSpeechToTextConfig,
|
||||
textToSpeechConfig,
|
||||
setTextToSpeechConfig,
|
||||
citationConfig,
|
||||
setCitationConfig,
|
||||
annotationConfig,
|
||||
setAnnotationConfig,
|
||||
moderationConfig,
|
||||
setModerationConfig,
|
||||
externalDataToolsConfig,
|
||||
setExternalDataToolsConfig,
|
||||
formattingChanged,
|
||||
setFormattingChanged,
|
||||
inputs,
|
||||
setInputs,
|
||||
query,
|
||||
setQuery,
|
||||
completionParams,
|
||||
setCompletionParams,
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
showSelectDataSet,
|
||||
dataSets,
|
||||
setDataSets,
|
||||
datasetConfigs,
|
||||
datasetConfigsRef,
|
||||
setDatasetConfigs,
|
||||
hasSetContextVar,
|
||||
isShowVisionConfig,
|
||||
visionConfig,
|
||||
setVisionConfig: handleSetVisionConfig,
|
||||
isAllowVideoUpload,
|
||||
isShowDocumentConfig,
|
||||
isShowAudioConfig,
|
||||
rerankSettingModalOpen,
|
||||
setRerankSettingModalOpen,
|
||||
}}
|
||||
>
|
||||
<ConfigContext.Provider value={value}>
|
||||
<FeaturesProvider features={featuresData}>
|
||||
<MittProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
|
||||
@ -153,7 +153,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
return
|
||||
}
|
||||
|
||||
if (localeData.variable && !/[a-zA-Z_]\w{0,29}/g.test(localeData.variable)) {
|
||||
if (localeData.variable && !/^[a-zA-Z_]\w{0,29}$/.test(localeData.variable)) {
|
||||
notify({ type: 'error', message: t('appDebug.varKeyError.notValid', { key: t('appDebug.feature.tools.modal.variableName.title') }) })
|
||||
return
|
||||
}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useRouter } from 'next/navigation'
|
||||
@ -35,14 +35,15 @@ type CreateAppProps = {
|
||||
onSuccess: () => void
|
||||
onClose: () => void
|
||||
onCreateFromTemplate?: () => void
|
||||
defaultAppMode?: AppMode
|
||||
}
|
||||
|
||||
function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps) {
|
||||
function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppProps) {
|
||||
const { t } = useTranslation()
|
||||
const { push } = useRouter()
|
||||
const { notify } = useContext(ToastContext)
|
||||
|
||||
const [appMode, setAppMode] = useState<AppMode>('advanced-chat')
|
||||
const [appMode, setAppMode] = useState<AppMode>(defaultAppMode || 'advanced-chat')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
@ -55,6 +56,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (appMode === 'chat' || appMode === 'agent-chat' || appMode === 'completion')
|
||||
setIsAppTypeExpanded(true)
|
||||
}, [appMode])
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
if (!appMode) {
|
||||
notify({ type: 'error', message: t('app.newApp.appTypeRequired') })
|
||||
@ -264,7 +270,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
type CreateAppDialogProps = CreateAppProps & {
|
||||
show: boolean
|
||||
}
|
||||
const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate }: CreateAppDialogProps) => {
|
||||
const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate, defaultAppMode }: CreateAppDialogProps) => {
|
||||
return (
|
||||
<FullScreenModal
|
||||
overflowVisible
|
||||
@ -272,7 +278,7 @@ const CreateAppModal = ({ show, onClose, onSuccess, onCreateFromTemplate }: Crea
|
||||
open={show}
|
||||
onClose={onClose}
|
||||
>
|
||||
<CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} />
|
||||
<CreateApp onClose={onClose} onSuccess={onSuccess} onCreateFromTemplate={onCreateFromTemplate} defaultAppMode={defaultAppMode} />
|
||||
</FullScreenModal>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,12 +17,16 @@ export type Props = {
|
||||
file: File | undefined
|
||||
updateFile: (file?: File) => void
|
||||
className?: string
|
||||
accept?: string
|
||||
displayName?: string
|
||||
}
|
||||
|
||||
const Uploader: FC<Props> = ({
|
||||
file,
|
||||
updateFile,
|
||||
className,
|
||||
accept = '.yaml,.yml',
|
||||
displayName = 'YAML',
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
@ -95,9 +99,9 @@ const Uploader: FC<Props> = ({
|
||||
<input
|
||||
ref={fileUploader}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
id="fileUploader"
|
||||
accept='.yaml,.yml'
|
||||
type='file'
|
||||
id='fileUploader'
|
||||
accept={accept}
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
@ -116,12 +120,12 @@ const Uploader: FC<Props> = ({
|
||||
{file && (
|
||||
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className='flex items-center justify-center p-3'>
|
||||
<YamlIcon className="h-6 w-6 shrink-0" />
|
||||
<YamlIcon className='h-6 w-6 shrink-0' />
|
||||
</div>
|
||||
<div className='flex grow flex-col items-start gap-0.5 py-1 pr-2'>
|
||||
<span className='font-inter max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-medium leading-4 text-text-secondary'>{file.name}</span>
|
||||
<div className='font-inter flex h-3 items-center gap-1 self-stretch text-[10px] font-medium uppercase leading-3 text-text-tertiary'>
|
||||
<span>YAML</span>
|
||||
<span>{displayName}</span>
|
||||
<span className='text-text-quaternary'>·</span>
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
|
||||
@ -15,7 +15,7 @@ type State = {
|
||||
|
||||
type Action = {
|
||||
setAppDetail: (appDetail?: App & Partial<AppSSO>) => void
|
||||
setAppSiderbarExpand: (state: string) => void
|
||||
setAppSidebarExpand: (state: string) => void
|
||||
setCurrentLogItem: (item?: IChatItem) => void
|
||||
setCurrentLogModalActiveTab: (tab: string) => void
|
||||
setShowPromptLogModal: (showPromptLogModal: boolean) => void
|
||||
@ -28,7 +28,7 @@ export const useStore = create<State & Action>(set => ({
|
||||
appDetail: undefined,
|
||||
setAppDetail: appDetail => set(() => ({ appDetail })),
|
||||
appSidebarExpand: '',
|
||||
setAppSiderbarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
|
||||
setAppSidebarExpand: appSidebarExpand => set(() => ({ appSidebarExpand })),
|
||||
currentLogItem: undefined,
|
||||
currentLogModalActiveTab: 'DETAIL',
|
||||
setCurrentLogItem: currentLogItem => set(() => ({ currentLogItem })),
|
||||
|
||||
@ -103,7 +103,7 @@ export const AppTypeIcon = React.memo(({ type, className, wrapperClassName, styl
|
||||
return null
|
||||
})
|
||||
|
||||
function AppTypeSelectTrigger({ values }: { values: AppSelectorProps['value'] }) {
|
||||
function AppTypeSelectTrigger({ values }: { readonly values: AppSelectorProps['value'] }) {
|
||||
const { t } = useTranslation()
|
||||
if (!values || values.length === 0) {
|
||||
return <div className={cn(
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import Run from '@/app/components/workflow/run'
|
||||
import { useStore } from '@/app/components/app/store'
|
||||
|
||||
type ILogDetail = {
|
||||
runID: string
|
||||
@ -11,6 +12,7 @@ type ILogDetail = {
|
||||
|
||||
const DetailPanel: FC<ILogDetail> = ({ runID, onClose }) => {
|
||||
const { t } = useTranslation()
|
||||
const appDetail = useStore(state => state.appDetail)
|
||||
|
||||
return (
|
||||
<div className='relative flex grow flex-col pt-3'>
|
||||
@ -18,7 +20,10 @@ const DetailPanel: FC<ILogDetail> = ({ runID, onClose }) => {
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</span>
|
||||
<h1 className='system-xl-semibold shrink-0 px-4 py-1 text-text-primary'>{t('appLog.runDetail.workflowTitle')}</h1>
|
||||
<Run runID={runID}/>
|
||||
<Run
|
||||
runDetailUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}` : ''}
|
||||
tracingListUrl={runID ? `/apps/${appDetail?.id}/workflow-runs/${runID}/node-executions` : ''}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -33,6 +33,19 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='mb-2 flex flex-row flex-wrap gap-2'>
|
||||
<Chip
|
||||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, status: item.value as string })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
|
||||
items={[{ value: 'all', name: 'All' },
|
||||
{ value: 'succeeded', name: 'Success' },
|
||||
{ value: 'failed', name: 'Fail' },
|
||||
{ value: 'stopped', name: 'Stop' },
|
||||
{ value: 'partial-succeeded', name: 'Partial Success' },
|
||||
]}
|
||||
/>
|
||||
<Chip
|
||||
className='min-w-[150px]'
|
||||
panelClassName='w-[270px]'
|
||||
@ -44,19 +57,6 @@ const Filter: FC<IFilterProps> = ({ queryParams, setQueryParams }: IFilterProps)
|
||||
onClear={() => setQueryParams({ ...queryParams, period: '9' })}
|
||||
items={Object.entries(TIME_PERIOD_MAPPING).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
|
||||
/>
|
||||
<Chip
|
||||
value={queryParams.status || 'all'}
|
||||
onSelect={(item) => {
|
||||
setQueryParams({ ...queryParams, status: item.value as string })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, status: 'all' })}
|
||||
items={[{ value: 'all', name: 'All' },
|
||||
{ value: 'succeeded', name: 'Success' },
|
||||
{ value: 'partial-succeeded', name: 'Partial Success' },
|
||||
{ value: 'failed', name: 'Failure' },
|
||||
{ value: 'stopped', name: 'Stop' },
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
wrapperClassName='w-[200px]'
|
||||
showLeftIcon
|
||||
|
||||
@ -159,9 +159,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
})
|
||||
const a = document.createElement('a')
|
||||
const file = new Blob([data], { type: 'application/yaml' })
|
||||
a.href = URL.createObjectURL(file)
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `${app.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
@ -257,7 +259,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
}
|
||||
return (
|
||||
<div className="relative flex w-full flex-col py-1" onMouseLeave={onMouseLeave}>
|
||||
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
|
||||
<button type="button" className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickSettings}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.editApp')}</span>
|
||||
</button>
|
||||
<Divider className="my-1" />
|
||||
@ -279,12 +281,21 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
</>
|
||||
)}
|
||||
{
|
||||
(isGettingUserCanAccessApp || !userCanAccessApp?.result) ? null : <>
|
||||
<Divider className="my-1" />
|
||||
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||
</button>
|
||||
</>
|
||||
(!systemFeatures.webapp_auth.enabled)
|
||||
? <>
|
||||
<Divider className="my-1" />
|
||||
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||
</button>
|
||||
</>
|
||||
: !(isGettingUserCanAccessApp || !userCanAccessApp?.result) && (
|
||||
<>
|
||||
<Divider className="my-1" />
|
||||
<button className='mx-1 flex h-8 cursor-pointer items-center gap-2 rounded-lg px-3 hover:bg-state-base-hover' onClick={onClickInstalledApp}>
|
||||
<span className='system-sm-regular text-text-secondary'>{t('app.openInExplore')}</span>
|
||||
</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<Divider className="my-1" />
|
||||
{
|
||||
|
||||
@ -211,14 +211,14 @@ const List = () => {
|
||||
{(data && data[0].total > 0)
|
||||
? <div className='relative grid grow grid-cols-1 content-start gap-4 px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} />}
|
||||
&& <NewAppCard ref={newAppCardRef} onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
{data.map(({ data: apps }) => apps.map(app => (
|
||||
<AppCard key={app.id} app={app} onRefresh={mutate} />
|
||||
)))}
|
||||
</div>
|
||||
: <div className='relative grid grow grid-cols-1 content-start gap-4 overflow-hidden px-12 pt-2 sm:grid-cols-1 md:grid-cols-2 xl:grid-cols-4 2xl:grid-cols-5 2k:grid-cols-6'>
|
||||
{isCurrentWorkspaceEditor
|
||||
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} />}
|
||||
&& <NewAppCard ref={newAppCardRef} className='z-10' onSuccess={mutate} selectedAppType={activeTab} />}
|
||||
<Empty />
|
||||
</div>}
|
||||
|
||||
|
||||
@ -26,12 +26,14 @@ export type CreateAppCardProps = {
|
||||
className?: string
|
||||
onSuccess?: () => void
|
||||
ref: React.RefObject<HTMLDivElement | null>
|
||||
selectedAppType?: string
|
||||
}
|
||||
|
||||
const CreateAppCard = ({
|
||||
ref,
|
||||
className,
|
||||
onSuccess,
|
||||
selectedAppType,
|
||||
}: CreateAppCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
@ -86,6 +88,7 @@ const CreateAppCard = ({
|
||||
setShowNewAppTemplateDialog(true)
|
||||
setShowNewAppModal(false)
|
||||
}}
|
||||
defaultAppMode={selectedAppType !== 'all' ? selectedAppType as any : undefined}
|
||||
/>
|
||||
)}
|
||||
{showNewAppTemplateDialog && (
|
||||
|
||||
76
web/app/components/base/action-button/index.spec.tsx
Normal file
76
web/app/components/base/action-button/index.spec.tsx
Normal file
@ -0,0 +1,76 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ActionButton, ActionButtonState } from './index'
|
||||
|
||||
describe('ActionButton', () => {
|
||||
test('renders button with default props', () => {
|
||||
render(<ActionButton>Click me</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Click me' })
|
||||
expect(button).toBeInTheDocument()
|
||||
expect(button.classList.contains('action-btn')).toBe(true)
|
||||
expect(button.classList.contains('action-btn-m')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with xs size', () => {
|
||||
render(<ActionButton size='xs'>Small Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Small Button' })
|
||||
expect(button.classList.contains('action-btn-xs')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with l size', () => {
|
||||
render(<ActionButton size='l'>Large Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Large Button' })
|
||||
expect(button.classList.contains('action-btn-l')).toBe(true)
|
||||
})
|
||||
|
||||
test('renders button with xl size', () => {
|
||||
render(<ActionButton size='xl'>Extra Large Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Extra Large Button' })
|
||||
expect(button.classList.contains('action-btn-xl')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies correct state classes', () => {
|
||||
const { rerender } = render(
|
||||
<ActionButton state={ActionButtonState.Destructive}>Destructive</ActionButton>,
|
||||
)
|
||||
let button = screen.getByRole('button', { name: 'Destructive' })
|
||||
expect(button.classList.contains('action-btn-destructive')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Active}>Active</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Active' })
|
||||
expect(button.classList.contains('action-btn-active')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Disabled}>Disabled</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Disabled' })
|
||||
expect(button.classList.contains('action-btn-disabled')).toBe(true)
|
||||
|
||||
rerender(<ActionButton state={ActionButtonState.Hover}>Hover</ActionButton>)
|
||||
button = screen.getByRole('button', { name: 'Hover' })
|
||||
expect(button.classList.contains('action-btn-hover')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies custom className', () => {
|
||||
render(<ActionButton className='custom-class'>Custom Class</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Custom Class' })
|
||||
expect(button.classList.contains('custom-class')).toBe(true)
|
||||
})
|
||||
|
||||
test('applies custom style', () => {
|
||||
render(
|
||||
<ActionButton styleCss={{ color: 'red', backgroundColor: 'blue' }}>
|
||||
Custom Style
|
||||
</ActionButton>,
|
||||
)
|
||||
const button = screen.getByRole('button', { name: 'Custom Style' })
|
||||
expect(button).toHaveStyle({
|
||||
color: 'red',
|
||||
backgroundColor: 'blue',
|
||||
})
|
||||
})
|
||||
|
||||
test('forwards additional button props', () => {
|
||||
render(<ActionButton disabled data-testid='test-button'>Disabled Button</ActionButton>)
|
||||
const button = screen.getByRole('button', { name: 'Disabled Button' })
|
||||
expect(button).toBeDisabled()
|
||||
expect(button).toHaveAttribute('data-testid', 'test-button')
|
||||
})
|
||||
})
|
||||
@ -32,6 +32,7 @@ export type ActionButtonProps = {
|
||||
size?: 'xs' | 's' | 'm' | 'l' | 'xl'
|
||||
state?: ActionButtonState
|
||||
styleCss?: CSSProperties
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof actionButtonVariants>
|
||||
|
||||
function getActionButtonState(state: ActionButtonState) {
|
||||
@ -49,24 +50,22 @@ function getActionButtonState(state: ActionButtonState) {
|
||||
}
|
||||
}
|
||||
|
||||
const ActionButton = React.forwardRef<HTMLButtonElement, ActionButtonProps>(
|
||||
({ className, size, state = ActionButtonState.Default, styleCss, children, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
actionButtonVariants({ className, size }),
|
||||
getActionButtonState(state),
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
const ActionButton = ({ className, size, state = ActionButtonState.Default, styleCss, children, ref, ...props }: ActionButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
actionButtonVariants({ className, size }),
|
||||
getActionButtonState(state),
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
ActionButton.displayName = 'ActionButton'
|
||||
|
||||
export default ActionButton
|
||||
|
||||
@ -33,7 +33,7 @@ const ToolCallItem: FC<Props> = ({ toolCall, isLLM = false, isFinal, tokens, obs
|
||||
if (time < 1)
|
||||
return `${(time * 1000).toFixed(3)} ms`
|
||||
if (time > 60)
|
||||
return `${Number.parseInt(Math.round(time / 60).toString())} m ${(time % 60).toFixed(3)} s`
|
||||
return `${Math.floor(time / 60)} m ${(time % 60).toFixed(3)} s`
|
||||
return `${time.toFixed(3)} s`
|
||||
}
|
||||
|
||||
|
||||
@ -5,7 +5,6 @@ import type { Area } from 'react-easy-crop'
|
||||
import Modal from '../modal'
|
||||
import Divider from '../divider'
|
||||
import Button from '../button'
|
||||
import { ImagePlus } from '../icons/src/vender/line/images'
|
||||
import { useLocalFileUploader } from '../image-uploader/hooks'
|
||||
import EmojiPickerInner from '../emoji-picker/Inner'
|
||||
import type { OnImageInput } from './ImageInput'
|
||||
@ -16,6 +15,7 @@ import type { AppIconType, ImageFile } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
|
||||
import { noop } from 'lodash-es'
|
||||
import { RiImageCircleAiLine } from '@remixicon/react'
|
||||
|
||||
export type AppIconEmojiSelection = {
|
||||
type: 'emoji'
|
||||
@ -46,7 +46,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
|
||||
const tabs = [
|
||||
{ key: 'emoji', label: t('app.iconPicker.emoji'), icon: <span className="text-lg">🤖</span> },
|
||||
{ key: 'image', label: t('app.iconPicker.image'), icon: <ImagePlus /> },
|
||||
{ key: 'image', label: t('app.iconPicker.image'), icon: <RiImageCircleAiLine className='size-4' /> },
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState<AppIconType>('emoji')
|
||||
|
||||
@ -119,10 +119,10 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
className={`
|
||||
flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-sm font-medium
|
||||
${activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active shadow-md'}
|
||||
`}
|
||||
className={cn(
|
||||
'system-sm-medium flex h-8 flex-1 shrink-0 items-center justify-center rounded-lg p-2 text-text-tertiary',
|
||||
activeTab === tab.key && 'bg-components-main-nav-nav-button-bg-active text-text-accent shadow-md',
|
||||
)}
|
||||
onClick={() => setActiveTab(tab.key as AppIconType)}
|
||||
>
|
||||
{tab.icon} {tab.label}
|
||||
|
||||
159
web/app/components/base/app-icon/index.spec.tsx
Normal file
159
web/app/components/base/app-icon/index.spec.tsx
Normal file
@ -0,0 +1,159 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import AppIcon from './index'
|
||||
|
||||
// Mock emoji-mart initialization
|
||||
jest.mock('emoji-mart', () => ({
|
||||
init: jest.fn(),
|
||||
}))
|
||||
|
||||
// Mock emoji data
|
||||
jest.mock('@emoji-mart/data', () => ({}))
|
||||
|
||||
// Mock the ahooks useHover hook
|
||||
jest.mock('ahooks', () => ({
|
||||
useHover: jest.fn(() => false),
|
||||
}))
|
||||
|
||||
describe('AppIcon', () => {
|
||||
beforeEach(() => {
|
||||
// Mock custom element
|
||||
if (!customElements.get('em-emoji')) {
|
||||
customElements.define('em-emoji', class extends HTMLElement {
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
// Mock basic functionality
|
||||
connectedCallback() {
|
||||
this.innerHTML = '🤖'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Reset mocks
|
||||
require('ahooks').useHover.mockReset().mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('renders default emoji when no icon or image is provided', () => {
|
||||
render(<AppIcon />)
|
||||
const emojiElement = document.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement?.getAttribute('id')).toBe('🤖')
|
||||
})
|
||||
|
||||
it('renders with custom emoji when icon is provided', () => {
|
||||
render(<AppIcon icon='smile' />)
|
||||
const emojiElement = document.querySelector('em-emoji')
|
||||
expect(emojiElement).toBeInTheDocument()
|
||||
expect(emojiElement?.getAttribute('id')).toBe('smile')
|
||||
})
|
||||
|
||||
it('renders image when iconType is image and imageUrl is provided', () => {
|
||||
render(<AppIcon iconType='image' imageUrl='test-image.jpg' />)
|
||||
const imgElement = screen.getByAltText('app icon')
|
||||
expect(imgElement).toBeInTheDocument()
|
||||
expect(imgElement).toHaveAttribute('src', 'test-image.jpg')
|
||||
})
|
||||
|
||||
it('renders innerIcon when provided', () => {
|
||||
render(<AppIcon innerIcon={<div data-testid='inner-icon'>Custom Icon</div>} />)
|
||||
const innerIcon = screen.getByTestId('inner-icon')
|
||||
expect(innerIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('applies size classes correctly', () => {
|
||||
const { container: xsContainer } = render(<AppIcon size='xs' />)
|
||||
expect(xsContainer.firstChild).toHaveClass('w-4 h-4 rounded-[4px]')
|
||||
|
||||
const { container: tinyContainer } = render(<AppIcon size='tiny' />)
|
||||
expect(tinyContainer.firstChild).toHaveClass('w-6 h-6 rounded-md')
|
||||
|
||||
const { container: smallContainer } = render(<AppIcon size='small' />)
|
||||
expect(smallContainer.firstChild).toHaveClass('w-8 h-8 rounded-lg')
|
||||
|
||||
const { container: mediumContainer } = render(<AppIcon size='medium' />)
|
||||
expect(mediumContainer.firstChild).toHaveClass('w-9 h-9 rounded-[10px]')
|
||||
|
||||
const { container: largeContainer } = render(<AppIcon size='large' />)
|
||||
expect(largeContainer.firstChild).toHaveClass('w-10 h-10 rounded-[10px]')
|
||||
|
||||
const { container: xlContainer } = render(<AppIcon size='xl' />)
|
||||
expect(xlContainer.firstChild).toHaveClass('w-12 h-12 rounded-xl')
|
||||
|
||||
const { container: xxlContainer } = render(<AppIcon size='xxl' />)
|
||||
expect(xxlContainer.firstChild).toHaveClass('w-14 h-14 rounded-2xl')
|
||||
})
|
||||
|
||||
it('applies rounded class when rounded=true', () => {
|
||||
const { container } = render(<AppIcon rounded />)
|
||||
expect(container.firstChild).toHaveClass('rounded-full')
|
||||
})
|
||||
|
||||
it('applies custom background color', () => {
|
||||
const { container } = render(<AppIcon background='#FF5500' />)
|
||||
expect(container.firstChild).toHaveStyle('background: #FF5500')
|
||||
})
|
||||
|
||||
it('uses default background color when no background is provided for non-image icons', () => {
|
||||
const { container } = render(<AppIcon />)
|
||||
expect(container.firstChild).toHaveStyle('background: #FFEAD5')
|
||||
})
|
||||
|
||||
it('does not apply background style for image icons', () => {
|
||||
const { container } = render(<AppIcon iconType='image' imageUrl='test.jpg' background='#FF5500' />)
|
||||
// Should not have the background style from the prop
|
||||
expect(container.firstChild).not.toHaveStyle('background: #FF5500')
|
||||
})
|
||||
|
||||
it('calls onClick handler when clicked', () => {
|
||||
const handleClick = jest.fn()
|
||||
const { container } = render(<AppIcon onClick={handleClick} />)
|
||||
fireEvent.click(container.firstChild!)
|
||||
|
||||
expect(handleClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('applies custom className', () => {
|
||||
const { container } = render(<AppIcon className='custom-class' />)
|
||||
expect(container.firstChild).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('does not display edit icon when showEditIcon=false', () => {
|
||||
render(<AppIcon />)
|
||||
const editIcon = screen.queryByRole('svg')
|
||||
expect(editIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('displays edit icon when showEditIcon=true and hovering', () => {
|
||||
// Mock the useHover hook to return true for this test
|
||||
require('ahooks').useHover.mockReturnValue(true)
|
||||
|
||||
render(<AppIcon showEditIcon />)
|
||||
const editIcon = document.querySelector('svg')
|
||||
expect(editIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not display edit icon when showEditIcon=true but not hovering', () => {
|
||||
// useHover returns false by default from our mock setup
|
||||
render(<AppIcon showEditIcon />)
|
||||
const editIcon = document.querySelector('svg')
|
||||
expect(editIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles conditional isValidImageIcon check correctly', () => {
|
||||
// Case 1: Valid image icon
|
||||
const { rerender } = render(
|
||||
<AppIcon iconType='image' imageUrl='test.jpg' />,
|
||||
)
|
||||
expect(screen.getByAltText('app icon')).toBeInTheDocument()
|
||||
|
||||
// Case 2: Invalid - missing image URL
|
||||
rerender(<AppIcon iconType='image' imageUrl={null} />)
|
||||
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
|
||||
|
||||
// Case 3: Invalid - wrong icon type
|
||||
rerender(<AppIcon iconType='emoji' imageUrl='test.jpg' />)
|
||||
expect(screen.queryByAltText('app icon')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -1,12 +1,13 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { type FC, useRef } from 'react'
|
||||
import { init } from 'emoji-mart'
|
||||
import data from '@emoji-mart/data'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useHover } from 'ahooks'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
|
||||
init({ data })
|
||||
|
||||
@ -20,20 +21,21 @@ export type AppIconProps = {
|
||||
className?: string
|
||||
innerIcon?: React.ReactNode
|
||||
coverElement?: React.ReactNode
|
||||
showEditIcon?: boolean
|
||||
onClick?: () => void
|
||||
}
|
||||
const appIconVariants = cva(
|
||||
'flex items-center justify-center relative text-lg rounded-lg grow-0 shrink-0 overflow-hidden leading-none',
|
||||
'flex items-center justify-center relative grow-0 shrink-0 overflow-hidden leading-none border-[0.5px] border-divider-regular',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-4 h-4 text-xs',
|
||||
tiny: 'w-6 h-6 text-base',
|
||||
small: 'w-8 h-8 text-xl',
|
||||
medium: 'w-9 h-9 text-[22px]',
|
||||
large: 'w-10 h-10 text-[24px]',
|
||||
xl: 'w-12 h-12 text-[28px]',
|
||||
xxl: 'w-14 h-14 text-[32px]',
|
||||
xs: 'w-4 h-4 text-xs rounded-[4px]',
|
||||
tiny: 'w-6 h-6 text-base rounded-md',
|
||||
small: 'w-8 h-8 text-xl rounded-lg',
|
||||
medium: 'w-9 h-9 text-[22px] rounded-[10px]',
|
||||
large: 'w-10 h-10 text-[24px] rounded-[10px]',
|
||||
xl: 'w-12 h-12 text-[28px] rounded-xl',
|
||||
xxl: 'w-14 h-14 text-[32px] rounded-2xl',
|
||||
},
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
@ -44,6 +46,46 @@ const appIconVariants = cva(
|
||||
rounded: false,
|
||||
},
|
||||
})
|
||||
const EditIconWrapperVariants = cva(
|
||||
'absolute left-0 top-0 z-10 flex items-center justify-center bg-background-overlay-alt',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'w-4 h-4 rounded-[4px]',
|
||||
tiny: 'w-6 h-6 rounded-md',
|
||||
small: 'w-8 h-8 rounded-lg',
|
||||
medium: 'w-9 h-9 rounded-[10px]',
|
||||
large: 'w-10 h-10 rounded-[10px]',
|
||||
xl: 'w-12 h-12 rounded-xl',
|
||||
xxl: 'w-14 h-14 rounded-2xl',
|
||||
},
|
||||
rounded: {
|
||||
true: 'rounded-full',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
rounded: false,
|
||||
},
|
||||
})
|
||||
const EditIconVariants = cva(
|
||||
'text-text-primary-on-surface',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: 'size-3',
|
||||
tiny: 'size-3.5',
|
||||
small: 'size-5',
|
||||
medium: 'size-[22px]',
|
||||
large: 'size-6',
|
||||
xl: 'size-7',
|
||||
xxl: 'size-8',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
})
|
||||
const AppIcon: FC<AppIconProps> = ({
|
||||
size = 'medium',
|
||||
rounded = false,
|
||||
@ -55,21 +97,35 @@ const AppIcon: FC<AppIconProps> = ({
|
||||
innerIcon,
|
||||
coverElement,
|
||||
onClick,
|
||||
showEditIcon = false,
|
||||
}) => {
|
||||
const isValidImageIcon = iconType === 'image' && imageUrl
|
||||
const Icon = (icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />
|
||||
const wrapperRef = useRef<HTMLSpanElement>(null)
|
||||
const isHovering = useHover(wrapperRef)
|
||||
|
||||
return <span
|
||||
className={classNames(appIconVariants({ size, rounded }), className)}
|
||||
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{isValidImageIcon
|
||||
|
||||
? <img src={imageUrl} className="h-full w-full" alt="app icon" />
|
||||
: (innerIcon || ((icon && icon !== '') ? <em-emoji id={icon} /> : <em-emoji id='🤖' />))
|
||||
}
|
||||
{coverElement}
|
||||
</span>
|
||||
return (
|
||||
<span
|
||||
ref={wrapperRef}
|
||||
className={classNames(appIconVariants({ size, rounded }), className)}
|
||||
style={{ background: isValidImageIcon ? undefined : (background || '#FFEAD5') }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{
|
||||
isValidImageIcon
|
||||
? <img src={imageUrl} className='h-full w-full' alt='app icon' />
|
||||
: (innerIcon || Icon)
|
||||
}
|
||||
{
|
||||
showEditIcon && isHovering && (
|
||||
<div className={EditIconWrapperVariants({ size, rounded })}>
|
||||
<RiEditLine className={EditIconVariants({ size })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{coverElement}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppIcon)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
'use client'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type AvatarProps = {
|
||||
@ -27,6 +27,12 @@ const Avatar = ({
|
||||
onError?.(true)
|
||||
}
|
||||
|
||||
// after uploaded, api would first return error imgs url: '.../files//file-preview/...'. Then return the right url, Which caused not show the avatar
|
||||
useEffect(() => {
|
||||
if(avatar && imgError)
|
||||
setImgError(false)
|
||||
}, [avatar])
|
||||
|
||||
if (avatar && !imgError) {
|
||||
return (
|
||||
<img
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
@apply
|
||||
border-[0.5px]
|
||||
shadow-xs
|
||||
backdrop-blur-[5px]
|
||||
bg-components-button-secondary-bg
|
||||
border-components-button-secondary-border
|
||||
hover:bg-components-button-secondary-bg-hover
|
||||
@ -69,6 +70,7 @@
|
||||
|
||||
.btn-secondary.btn-disabled {
|
||||
@apply
|
||||
backdrop-blur-sm
|
||||
bg-components-button-secondary-bg-disabled
|
||||
border-components-button-secondary-border-disabled
|
||||
text-components-button-secondary-text-disabled;
|
||||
|
||||
@ -35,27 +35,26 @@ export type ButtonProps = {
|
||||
loading?: boolean
|
||||
styleCss?: CSSProperties
|
||||
spinnerClassName?: string
|
||||
ref?: React.Ref<HTMLButtonElement>
|
||||
} & React.ButtonHTMLAttributes<HTMLButtonElement> & VariantProps<typeof buttonVariants>
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
buttonVariants({ variant, size, className }),
|
||||
destructive && 'btn-destructive',
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
)
|
||||
const Button = ({ className, variant, size, destructive, loading, styleCss, children, spinnerClassName, ref, ...props }: ButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
type='button'
|
||||
className={classNames(
|
||||
buttonVariants({ variant, size, className }),
|
||||
destructive && 'btn-destructive',
|
||||
)}
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export default Button
|
||||
|
||||
@ -52,6 +52,10 @@ const ChatWrapper = () => {
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
// Semantic variable for better code readability
|
||||
const isHistoryConversation = !!currentConversationId
|
||||
|
||||
const appConfig = useMemo(() => {
|
||||
const config = appParams || {}
|
||||
|
||||
@ -62,9 +66,9 @@ const ChatWrapper = () => {
|
||||
fileUploadConfig: (config as any).system_parameters,
|
||||
},
|
||||
supportFeedback: true,
|
||||
opening_statement: currentConversationId ? currentConversationItem?.introduction : (config as any).opening_statement,
|
||||
opening_statement: isHistoryConversation ? currentConversationItem?.introduction : (config as any).opening_statement,
|
||||
} as ChatConfig
|
||||
}, [appParams, currentConversationItem?.introduction, currentConversationId])
|
||||
}, [appParams, currentConversationItem?.introduction, isHistoryConversation])
|
||||
const {
|
||||
chatList,
|
||||
setTargetMessageId,
|
||||
@ -75,7 +79,7 @@ const ChatWrapper = () => {
|
||||
} = useChat(
|
||||
appConfig,
|
||||
{
|
||||
inputs: (currentConversationId ? currentConversationInputs : newConversationInputs) as any,
|
||||
inputs: (isHistoryConversation ? currentConversationInputs : newConversationInputs) as any,
|
||||
inputsForm: inputsForms,
|
||||
},
|
||||
appPrevChatTree,
|
||||
@ -83,7 +87,7 @@ const ChatWrapper = () => {
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputsFormValue = isHistoryConversation ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
if (allInputsHidden)
|
||||
return false
|
||||
@ -132,7 +136,7 @@ const ChatWrapper = () => {
|
||||
const data: any = {
|
||||
query: message,
|
||||
files,
|
||||
inputs: formatBooleanInputs(inputsForms, currentConversationId ? currentConversationInputs : newConversationInputs),
|
||||
inputs: formatBooleanInputs(inputsForms, isHistoryConversation ? currentConversationInputs : newConversationInputs),
|
||||
conversation_id: currentConversationId,
|
||||
parent_message_id: (isRegenerate ? parentAnswer?.id : getLastAnswer(chatList)?.id) || null,
|
||||
}
|
||||
@ -142,11 +146,11 @@ const ChatWrapper = () => {
|
||||
data,
|
||||
{
|
||||
onGetSuggestedQuestions: responseItemId => fetchSuggestedQuestions(responseItemId, isInstalledApp, appId),
|
||||
onConversationComplete: currentConversationId ? undefined : handleNewConversationCompleted,
|
||||
onConversationComplete: isHistoryConversation ? undefined : handleNewConversationCompleted,
|
||||
isPublicAPI: !isInstalledApp,
|
||||
},
|
||||
)
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, currentConversationId, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
}, [chatList, handleNewConversationCompleted, handleSend, isHistoryConversation, currentConversationInputs, newConversationInputs, isInstalledApp, appId])
|
||||
|
||||
const doRegenerate = useCallback((chatItem: ChatItemInTree, editedQuestion?: { message: string, files?: FileEntity[] }) => {
|
||||
const question = editedQuestion ? chatItem : chatList.find(item => item.id === chatItem.parentMessageId)!
|
||||
@ -159,31 +163,30 @@ const ChatWrapper = () => {
|
||||
}, [chatList, doSend])
|
||||
|
||||
const messageList = useMemo(() => {
|
||||
if (currentConversationId)
|
||||
return chatList
|
||||
// Always filter out opening statement from message list as it's handled separately in welcome component
|
||||
return chatList.filter(item => !item.isOpeningStatement)
|
||||
}, [chatList, currentConversationId])
|
||||
}, [chatList])
|
||||
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
const [collapsed, setCollapsed] = useState(isHistoryConversation)
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
if (isMobile) {
|
||||
if (!currentConversationId)
|
||||
if (!isHistoryConversation)
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
return null
|
||||
}
|
||||
else {
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
}, [inputsForms.length, isMobile, isHistoryConversation, collapsed, allInputsHidden])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
if (respondingState)
|
||||
return null
|
||||
if (currentConversationId)
|
||||
if (isHistoryConversation)
|
||||
return null
|
||||
if (!welcomeMessage)
|
||||
return null
|
||||
@ -224,7 +227,7 @@ const ChatWrapper = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, isHistoryConversation, inputsForms.length, respondingState, allInputsHidden])
|
||||
|
||||
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
|
||||
? <AnswerIcon
|
||||
@ -248,7 +251,7 @@ const ChatWrapper = () => {
|
||||
chatFooterClassName='pb-4'
|
||||
chatFooterInnerClassName={`mx-auto w-full max-w-[768px] ${isMobile ? 'px-2' : 'px-4'}`}
|
||||
onSend={doSend}
|
||||
inputs={currentConversationId ? currentConversationInputs as any : newConversationInputs}
|
||||
inputs={isHistoryConversation ? currentConversationInputs as any : newConversationInputs}
|
||||
inputsForm={inputsForms}
|
||||
onRegenerate={doRegenerate}
|
||||
onStopResponding={handleStop}
|
||||
|
||||
@ -122,19 +122,31 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
setLocaleFromProps()
|
||||
}, [appData])
|
||||
|
||||
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(false)
|
||||
const [sidebarCollapseState, setSidebarCollapseState] = useState<boolean>(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const localState = localStorage.getItem('webappSidebarCollapse')
|
||||
return localState === 'collapsed'
|
||||
}
|
||||
catch (e) {
|
||||
// localStorage may be disabled in private browsing mode or by security settings
|
||||
// fallback to default value
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
const handleSidebarCollapse = useCallback((state: boolean) => {
|
||||
if (appId) {
|
||||
setSidebarCollapseState(state)
|
||||
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
|
||||
try {
|
||||
localStorage.setItem('webappSidebarCollapse', state ? 'collapsed' : 'expanded')
|
||||
}
|
||||
catch (e) {
|
||||
// localStorage may be disabled, continue without persisting state
|
||||
}
|
||||
}
|
||||
}, [appId, setSidebarCollapseState])
|
||||
useEffect(() => {
|
||||
if (appId) {
|
||||
const localState = localStorage.getItem('webappSidebarCollapse')
|
||||
setSidebarCollapseState(localState === 'collapsed')
|
||||
}
|
||||
}, [appId])
|
||||
const [conversationIdInfo, setConversationIdInfo] = useLocalStorageState<Record<string, Record<string, string>>>(CONVERSATION_ID_INFO, {
|
||||
defaultValue: {},
|
||||
})
|
||||
@ -215,7 +227,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
|
||||
const convertedNumber = Number(initInputs[item.number.variable])
|
||||
return {
|
||||
...item.number,
|
||||
default: convertedNumber || item.default || item.number.default,
|
||||
@ -508,7 +520,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
}, [mutateAppConversationData, handleConversationIdInfoChange])
|
||||
|
||||
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
|
||||
@ -47,6 +47,11 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSidebarCollapsed)
|
||||
setShowSidePanel(false)
|
||||
}, [isSidebarCollapsed])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
return (
|
||||
@ -76,7 +81,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
onMouseEnter={() => setShowSidePanel(true)}
|
||||
onMouseLeave={() => setShowSidePanel(false)}
|
||||
>
|
||||
<Sidebar isPanel />
|
||||
<Sidebar isPanel panelVisible={showSidePanel} />
|
||||
</div>
|
||||
)}
|
||||
<div className={cn('flex h-full flex-col overflow-hidden border-[0,5px] border-components-panel-border-subtle bg-chatbot-bg', isMobile ? 'rounded-t-2xl' : 'rounded-2xl')}>
|
||||
|
||||
@ -23,9 +23,10 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
type Props = {
|
||||
isPanel?: boolean
|
||||
panelVisible?: boolean
|
||||
}
|
||||
|
||||
const Sidebar = ({ isPanel }: Props) => {
|
||||
const Sidebar = ({ isPanel, panelVisible }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isInstalledApp,
|
||||
@ -138,7 +139,12 @@ const Sidebar = ({ isPanel }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center justify-between p-3'>
|
||||
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
|
||||
<MenuDropdown
|
||||
hideLogout={isInstalledApp}
|
||||
placement='top-start'
|
||||
data={appData?.site}
|
||||
forceClose={isPanel && !panelVisible}
|
||||
/>
|
||||
{/* powered by */}
|
||||
<div className='shrink-0'>
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
|
||||
@ -20,6 +20,8 @@ import EditReplyModal from '@/app/components/app/annotation/edit-annotation-moda
|
||||
import Log from '@/app/components/base/chat/chat/log'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Modal from '@/app/components/base/modal/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type OperationProps = {
|
||||
@ -32,6 +34,7 @@ type OperationProps = {
|
||||
hasWorkflowProcess: boolean
|
||||
noChatInput?: boolean
|
||||
}
|
||||
|
||||
const Operation: FC<OperationProps> = ({
|
||||
item,
|
||||
question,
|
||||
@ -52,6 +55,8 @@ const Operation: FC<OperationProps> = ({
|
||||
onRegenerate,
|
||||
} = useChatContext()
|
||||
const [isShowReplyModal, setIsShowReplyModal] = useState(false)
|
||||
const [isShowFeedbackModal, setIsShowFeedbackModal] = useState(false)
|
||||
const [feedbackContent, setFeedbackContent] = useState('')
|
||||
const {
|
||||
id,
|
||||
isOpeningStatement,
|
||||
@ -70,14 +75,29 @@ const Operation: FC<OperationProps> = ({
|
||||
return messageContent
|
||||
}, [agent_thoughts, messageContent])
|
||||
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null) => {
|
||||
const handleFeedback = async (rating: 'like' | 'dislike' | null, content?: string) => {
|
||||
if (!config?.supportFeedback || !onFeedback)
|
||||
return
|
||||
|
||||
await onFeedback?.(id, { rating })
|
||||
await onFeedback?.(id, { rating, content })
|
||||
setLocalFeedback({ rating })
|
||||
}
|
||||
|
||||
const handleThumbsDown = () => {
|
||||
setIsShowFeedbackModal(true)
|
||||
}
|
||||
|
||||
const handleFeedbackSubmit = async () => {
|
||||
await handleFeedback('dislike', feedbackContent)
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const handleFeedbackCancel = () => {
|
||||
setFeedbackContent('')
|
||||
setIsShowFeedbackModal(false)
|
||||
}
|
||||
|
||||
const operationWidth = useMemo(() => {
|
||||
let width = 0
|
||||
if (!isOpeningStatement)
|
||||
@ -153,7 +173,7 @@ const Operation: FC<OperationProps> = ({
|
||||
<ActionButton onClick={() => handleFeedback('like')}>
|
||||
<RiThumbUpLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton onClick={() => handleFeedback('dislike')}>
|
||||
<ActionButton onClick={handleThumbsDown}>
|
||||
<RiThumbDownLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</>
|
||||
@ -188,6 +208,32 @@ const Operation: FC<OperationProps> = ({
|
||||
createdAt={annotation?.created_at}
|
||||
onRemove={() => onAnnotationRemoved?.(index)}
|
||||
/>
|
||||
{isShowFeedbackModal && (
|
||||
<Modal
|
||||
title={t('common.feedback.title') || 'Provide Feedback'}
|
||||
subTitle={t('common.feedback.subtitle') || 'Please tell us what went wrong with this response'}
|
||||
onClose={handleFeedbackCancel}
|
||||
onConfirm={handleFeedbackSubmit}
|
||||
onCancel={handleFeedbackCancel}
|
||||
confirmButtonText={t('common.operation.submit') || 'Submit'}
|
||||
cancelButtonText={t('common.operation.cancel') || 'Cancel'}
|
||||
>
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<label className='system-sm-semibold mb-2 block text-text-secondary'>
|
||||
{t('common.feedback.content') || 'Feedback Content'}
|
||||
</label>
|
||||
<Textarea
|
||||
value={feedbackContent}
|
||||
onChange={e => setFeedbackContent(e.target.value)}
|
||||
placeholder={t('common.feedback.placeholder') || 'Please describe what went wrong or how we can improve...'}
|
||||
rows={4}
|
||||
className='w-full'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -682,7 +682,7 @@ export const useChat = (
|
||||
updateChatTreeNode(targetAnswerId, {
|
||||
content: chatList[index].content,
|
||||
annotation: {
|
||||
...(chatList[index].annotation || {}),
|
||||
...chatList[index].annotation,
|
||||
id: '',
|
||||
} as Annotation,
|
||||
})
|
||||
|
||||
@ -188,7 +188,7 @@ export const useEmbeddedChatbot = () => {
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
|
||||
const convertedNumber = Number(initInputs[item.number.variable])
|
||||
return {
|
||||
...item.number,
|
||||
default: convertedNumber || item.default || item.number.default,
|
||||
@ -390,7 +390,7 @@ export const useEmbeddedChatbot = () => {
|
||||
}, [mutateAppConversationData, handleConversationIdInfoChange])
|
||||
|
||||
const handleFeedback = useCallback(async (messageId: string, feedback: Feedback) => {
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating } }, isInstalledApp, appId)
|
||||
await updateFeedback({ url: `/messages/${messageId}/feedbacks`, body: { rating: feedback.rating, content: feedback.content } }, isInstalledApp, appId)
|
||||
notify({ type: 'success', message: t('common.api.success') })
|
||||
}, [isInstalledApp, appId, t, notify])
|
||||
|
||||
|
||||
@ -93,4 +93,5 @@ export type Callback = {
|
||||
|
||||
export type Feedback = {
|
||||
rating: 'like' | 'dislike' | null
|
||||
content?: string | null
|
||||
}
|
||||
|
||||
@ -43,6 +43,16 @@ async function getProcessedInputsFromUrlParams(): Promise<Record<string, any>> {
|
||||
|
||||
async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const redirectUrl = urlParams.get('redirect_url')
|
||||
if (redirectUrl) {
|
||||
const decodedRedirectUrl = decodeURIComponent(redirectUrl)
|
||||
const queryString = decodedRedirectUrl.split('?')[1]
|
||||
if (queryString) {
|
||||
const redirectParams = new URLSearchParams(queryString)
|
||||
for (const [key, value] of redirectParams.entries())
|
||||
urlParams.set(key, value)
|
||||
}
|
||||
}
|
||||
const systemVariables: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
await Promise.all(
|
||||
|
||||
@ -5,7 +5,7 @@ import IndeterminateIcon from './assets/indeterminate-icon'
|
||||
type CheckboxProps = {
|
||||
id?: string
|
||||
checked?: boolean
|
||||
onCheck?: () => void
|
||||
onCheck?: (event: React.MouseEvent<HTMLDivElement>) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
indeterminate?: boolean
|
||||
@ -35,10 +35,10 @@ const Checkbox = ({
|
||||
disabled && disabledClassName,
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick={(event) => {
|
||||
if (disabled)
|
||||
return
|
||||
onCheck?.()
|
||||
onCheck?.(event)
|
||||
}}
|
||||
data-testid={`checkbox-${id}`}
|
||||
>
|
||||
|
||||
@ -18,8 +18,8 @@ const ContentDialog = ({
|
||||
return (
|
||||
<Transition
|
||||
show={show}
|
||||
as="div"
|
||||
className="absolute left-0 top-0 z-20 box-border h-full w-full p-2"
|
||||
as='div'
|
||||
className='absolute left-0 top-0 z-30 box-border h-full w-full p-2'
|
||||
>
|
||||
<TransitionChild>
|
||||
<div
|
||||
|
||||
@ -10,8 +10,8 @@ type CornerLabelProps = {
|
||||
const CornerLabel: React.FC<CornerLabelProps> = ({ label, className, labelClassName }) => {
|
||||
return (
|
||||
<div className={cn('group/corner-label inline-flex items-start', className)}>
|
||||
<Corner className='h-5 w-[13px] text-background-section group-hover/corner-label:text-background-section-burn' />
|
||||
<div className={cn('flex items-center gap-0.5 bg-background-section py-1 pr-2 group-hover/corner-label:bg-background-section-burn', labelClassName)}>
|
||||
<Corner className='h-5 w-[13px] text-background-section-burn' />
|
||||
<div className={cn('flex items-center gap-0.5 bg-background-section-burn py-1 pr-2', labelClassName)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{label}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -43,7 +43,14 @@ const DatePicker = ({
|
||||
const [view, setView] = useState(ViewType.date)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const isInitial = useRef(true)
|
||||
const inputValue = useRef(value ? value.tz(timezone) : undefined).current
|
||||
|
||||
// Normalize the value to ensure that all subsequent uses are Day.js objects.
|
||||
const normalizedValue = useMemo(() => {
|
||||
if (!value) return undefined
|
||||
return dayjs.isDayjs(value) ? value.tz(timezone) : dayjs(value).tz(timezone)
|
||||
}, [value, timezone])
|
||||
|
||||
const inputValue = useRef(normalizedValue).current
|
||||
const defaultValue = useRef(getDateWithTimezone({ timezone })).current
|
||||
|
||||
const [currentDate, setCurrentDate] = useState(inputValue || defaultValue)
|
||||
@ -69,8 +76,8 @@ const DatePicker = ({
|
||||
return
|
||||
}
|
||||
clearMonthMapCache()
|
||||
if (value) {
|
||||
const newValue = getDateWithTimezone({ date: value, timezone })
|
||||
if (normalizedValue) {
|
||||
const newValue = getDateWithTimezone({ date: normalizedValue, timezone })
|
||||
setCurrentDate(newValue)
|
||||
setSelectedDate(newValue)
|
||||
onChange(newValue)
|
||||
@ -89,9 +96,9 @@ const DatePicker = ({
|
||||
}
|
||||
setView(ViewType.date)
|
||||
setIsOpen(true)
|
||||
if (value) {
|
||||
setCurrentDate(value)
|
||||
setSelectedDate(value)
|
||||
if (normalizedValue) {
|
||||
setCurrentDate(normalizedValue)
|
||||
setSelectedDate(normalizedValue)
|
||||
}
|
||||
}
|
||||
|
||||
@ -193,7 +200,7 @@ const DatePicker = ({
|
||||
}
|
||||
|
||||
const timeFormat = needTimePicker ? t('time.dateFormats.displayWithTime') : t('time.dateFormats.display')
|
||||
const displayValue = value?.format(timeFormat) || ''
|
||||
const displayValue = normalizedValue?.format(timeFormat) || ''
|
||||
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
|
||||
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
|
||||
|
||||
@ -205,7 +212,7 @@ const DatePicker = ({
|
||||
>
|
||||
<PortalToFollowElemTrigger className={triggerWrapClassName}>
|
||||
{renderTrigger ? (renderTrigger({
|
||||
value,
|
||||
value: normalizedValue,
|
||||
selectedDate,
|
||||
isOpen,
|
||||
handleClear,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
@ -8,6 +9,8 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import type { ActionButtonProps } from '@/app/components/base/action-button'
|
||||
|
||||
export type Item = {
|
||||
value: string | number
|
||||
@ -18,14 +21,20 @@ type DropdownProps = {
|
||||
secondItems?: Item[]
|
||||
onSelect: (item: Item) => void
|
||||
renderTrigger?: (open: boolean) => React.ReactNode
|
||||
triggerProps?: ActionButtonProps
|
||||
popupClassName?: string
|
||||
itemClassName?: string
|
||||
secondItemClassName?: string
|
||||
}
|
||||
const Dropdown: FC<DropdownProps> = ({
|
||||
items,
|
||||
onSelect,
|
||||
secondItems,
|
||||
renderTrigger,
|
||||
triggerProps,
|
||||
popupClassName,
|
||||
itemClassName,
|
||||
secondItemClassName,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@ -45,14 +54,15 @@ const Dropdown: FC<DropdownProps> = ({
|
||||
renderTrigger
|
||||
? renderTrigger(open)
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
flex h-6 w-6 cursor-pointer items-center justify-center rounded-md
|
||||
${open && 'bg-divider-regular'}
|
||||
`}
|
||||
<ActionButton
|
||||
{...triggerProps}
|
||||
className={cn(
|
||||
open && 'bg-divider-regular',
|
||||
triggerProps?.className,
|
||||
)}
|
||||
>
|
||||
<RiMoreFill className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</ActionButton>
|
||||
)
|
||||
}
|
||||
</PortalToFollowElemTrigger>
|
||||
@ -65,7 +75,10 @@ const Dropdown: FC<DropdownProps> = ({
|
||||
items.map(item => (
|
||||
<div
|
||||
key={item.value}
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
itemClassName,
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
@ -87,7 +100,10 @@ const Dropdown: FC<DropdownProps> = ({
|
||||
secondItems.map(item => (
|
||||
<div
|
||||
key={item.value}
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover'
|
||||
className={cn(
|
||||
'flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
secondItemClassName,
|
||||
)}
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
{item.text}
|
||||
|
||||
18
web/app/components/base/effect/index.tsx
Normal file
18
web/app/components/base/effect/index.tsx
Normal file
@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type EffectProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Effect = ({
|
||||
className,
|
||||
}: EffectProps) => {
|
||||
return (
|
||||
<div
|
||||
className={cn('absolute size-[112px] rounded-full bg-util-colors-blue-brand-blue-brand-500 blur-[80px]', className)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Effect)
|
||||
@ -83,9 +83,7 @@ const OpeningSettingModal = ({
|
||||
}, [handleSave, hideConfirmAddVar])
|
||||
|
||||
const autoAddVar = useCallback(() => {
|
||||
onAutoAddPromptVariable?.([
|
||||
...notIncludeKeys.map(key => getNewVar(key, 'string')),
|
||||
])
|
||||
onAutoAddPromptVariable?.(notIncludeKeys.map(key => getNewVar(key, 'string')))
|
||||
hideConfirmAddVar()
|
||||
handleSave(true)
|
||||
}, [handleSave, hideConfirmAddVar, notIncludeKeys, onAutoAddPromptVariable])
|
||||
|
||||
@ -29,6 +29,11 @@ export type SensitiveWordAvoidance = EnabledOrDisabled & {
|
||||
config?: any
|
||||
}
|
||||
|
||||
export enum PreviewMode {
|
||||
NewPage = 'new_page',
|
||||
CurrentPage = 'current_page',
|
||||
}
|
||||
|
||||
export type FileUpload = {
|
||||
image?: EnabledOrDisabled & {
|
||||
detail?: Resolution
|
||||
@ -56,6 +61,10 @@ export type FileUpload = {
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
number_limits?: number
|
||||
fileUploadConfig?: FileUploadConfigResponse
|
||||
preview_config?: {
|
||||
mode?: PreviewMode
|
||||
file_type_list?: string[]
|
||||
}
|
||||
} & EnabledOrDisabled
|
||||
|
||||
export type AnnotationReplyConfig = {
|
||||
|
||||
@ -66,7 +66,7 @@ const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPaddi
|
||||
<div key={id} className='rounded-md border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg p-1.5 shadow-xs'>
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='md'
|
||||
size='lg'
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
@ -69,13 +69,14 @@ const FILE_TYPE_ICON_MAP = {
|
||||
}
|
||||
type FileTypeIconProps = {
|
||||
type: FileAppearanceType
|
||||
size?: 'sm' | 'lg' | 'md'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
className?: string
|
||||
}
|
||||
const SizeMap = {
|
||||
sm: 'w-4 h-4',
|
||||
md: 'w-5 h-5',
|
||||
lg: 'w-6 h-6',
|
||||
sm: 'size-4',
|
||||
md: 'size-[18px]',
|
||||
lg: 'size-5',
|
||||
xl: 'size-6',
|
||||
}
|
||||
const FileTypeIcon = ({
|
||||
type,
|
||||
|
||||
@ -23,6 +23,7 @@ import cn from '@/utils/classnames'
|
||||
import { ReplayLine } from '@/app/components/base/icons/src/vender/other'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import { PreviewMode } from '@/app/components/base/features/types'
|
||||
|
||||
type FileInAttachmentItemProps = {
|
||||
file: FileEntity
|
||||
@ -31,6 +32,7 @@ type FileInAttachmentItemProps = {
|
||||
onRemove?: (fileId: string) => void
|
||||
onReUpload?: (fileId: string) => void
|
||||
canPreview?: boolean
|
||||
previewMode?: PreviewMode
|
||||
}
|
||||
const FileInAttachmentItem = ({
|
||||
file,
|
||||
@ -39,6 +41,7 @@ const FileInAttachmentItem = ({
|
||||
onRemove,
|
||||
onReUpload,
|
||||
canPreview,
|
||||
previewMode = PreviewMode.CurrentPage,
|
||||
}: FileInAttachmentItemProps) => {
|
||||
const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
|
||||
const ext = getFileExtension(name, type, isRemote)
|
||||
@ -49,7 +52,13 @@ const FileInAttachmentItem = ({
|
||||
<div className={cn(
|
||||
'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs',
|
||||
progress === -1 && 'border-state-destructive-border bg-state-destructive-hover',
|
||||
)}>
|
||||
canPreview && previewMode === PreviewMode.NewPage && 'cursor-pointer',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (canPreview && previewMode === PreviewMode.NewPage)
|
||||
window.open(url || base64Url || '', '_blank')
|
||||
}}
|
||||
>
|
||||
<div className='flex h-12 w-12 items-center justify-center'>
|
||||
{
|
||||
isImageFile && (
|
||||
@ -63,7 +72,7 @@ const FileInAttachmentItem = ({
|
||||
!isImageFile && (
|
||||
<FileTypeIcon
|
||||
type={getFileAppearanceType(name, type)}
|
||||
size='lg'
|
||||
size='xl'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -106,6 +106,8 @@ const FileUploaderInAttachment = ({
|
||||
showDownloadAction={false}
|
||||
onRemove={() => handleRemoveFile(file.id)}
|
||||
onReUpload={() => handleReUploadFile(file.id)}
|
||||
canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)}
|
||||
previewMode={fileConfig.preview_config?.mode}
|
||||
/>
|
||||
))
|
||||
}
|
||||
@ -114,7 +116,7 @@ const FileUploaderInAttachment = ({
|
||||
)
|
||||
}
|
||||
|
||||
type FileUploaderInAttachmentWrapperProps = {
|
||||
export type FileUploaderInAttachmentWrapperProps = {
|
||||
value?: FileEntity[]
|
||||
onChange: (files: FileEntity[]) => void
|
||||
fileConfig: FileUpload
|
||||
|
||||
@ -14,17 +14,18 @@ import { useStore } from '@tanstack/react-form'
|
||||
import {
|
||||
isValidElement,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
|
||||
const getInputType = (type: FormTypeEnum) => {
|
||||
const getExtraProps = (type: FormTypeEnum) => {
|
||||
switch (type) {
|
||||
case FormTypeEnum.secretInput:
|
||||
return 'password'
|
||||
return { type: 'password', autoComplete: 'new-password' }
|
||||
case FormTypeEnum.textNumber:
|
||||
return 'number'
|
||||
return { type: 'number' }
|
||||
default:
|
||||
return 'text'
|
||||
return { type: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,6 +37,7 @@ export type BaseFieldProps = {
|
||||
formSchema: FormSchema
|
||||
field: AnyFieldApi
|
||||
disabled?: boolean
|
||||
onChange?: (field: string, value: any) => void
|
||||
}
|
||||
|
||||
const BaseField = ({
|
||||
@ -46,6 +48,7 @@ const BaseField = ({
|
||||
formSchema,
|
||||
field,
|
||||
disabled: propsDisabled,
|
||||
onChange,
|
||||
}: BaseFieldProps) => {
|
||||
const renderI18nObject = useRenderI18nObject()
|
||||
const {
|
||||
@ -54,9 +57,7 @@ const BaseField = ({
|
||||
placeholder,
|
||||
options,
|
||||
labelClassName: formLabelClassName,
|
||||
show_on = [],
|
||||
disabled: formSchemaDisabled,
|
||||
showRadioUI,
|
||||
type: formItemType,
|
||||
dynamicSelectParams,
|
||||
} = formSchema
|
||||
@ -86,11 +87,8 @@ const BaseField = ({
|
||||
variables.add(condition.variable)
|
||||
}
|
||||
|
||||
for (const condition of show_on || [])
|
||||
variables.add(condition.variable)
|
||||
|
||||
return Array.from(variables)
|
||||
}, [options, show_on])
|
||||
}, [options])
|
||||
|
||||
const watchedValues = useStore(field.form.store, (s) => {
|
||||
const result: Record<string, any> = {}
|
||||
@ -138,20 +136,10 @@ const BaseField = ({
|
||||
}))
|
||||
}, [dynamicOptionsData, renderI18nObject])
|
||||
|
||||
const show = useMemo(() => {
|
||||
return show_on.every((condition) => {
|
||||
return watchedValues[condition.variable] === condition.value
|
||||
})
|
||||
}, [watchedValues, show_on])
|
||||
|
||||
const booleanRadioValue = useMemo(() => {
|
||||
if (value === null || value === undefined)
|
||||
return undefined
|
||||
return value ? 1 : 0
|
||||
}, [value])
|
||||
|
||||
if (!show)
|
||||
return null
|
||||
const handleChange = useCallback((value: any) => {
|
||||
field.handleChange(value)
|
||||
onChange?.(field.name, value)
|
||||
}, [field, onChange])
|
||||
|
||||
return (
|
||||
<div className={cn(fieldClassName)}>
|
||||
@ -171,11 +159,13 @@ const BaseField = ({
|
||||
name={field.name}
|
||||
className={cn(inputClassName)}
|
||||
value={value || ''}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onChange={(e) => {
|
||||
handleChange(e.target.value)
|
||||
}}
|
||||
onBlur={field.handleBlur}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
type={getInputType(formItemType)}
|
||||
{...getExtraProps(formItemType)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -183,11 +173,14 @@ const BaseField = ({
|
||||
formItemType === FormTypeEnum.select && (
|
||||
<PureSelect
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
onChange={v => handleChange(v)}
|
||||
disabled={disabled}
|
||||
placeholder={memorizedPlaceholder}
|
||||
options={memorizedOptions}
|
||||
triggerPopupSameWidth
|
||||
popupProps={{
|
||||
className: 'max-h-[320px] overflow-y-auto',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -222,9 +215,16 @@ const BaseField = ({
|
||||
disabled && 'cursor-not-allowed opacity-50',
|
||||
inputClassName,
|
||||
)}
|
||||
onClick={() => !disabled && field.handleChange(option.value)}
|
||||
onClick={() => !disabled && handleChange(option.value)}
|
||||
>
|
||||
{showRadioUI && <RadioE isChecked={value === option.value} />}
|
||||
{
|
||||
formSchema.showRadioUI && (
|
||||
<RadioE
|
||||
className='mr-2'
|
||||
isChecked={value === option.value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{option.label}
|
||||
</div>
|
||||
))
|
||||
@ -235,12 +235,12 @@ const BaseField = ({
|
||||
{
|
||||
formItemType === FormTypeEnum.boolean && (
|
||||
<Radio.Group
|
||||
className='flex w-fit items-center gap-1'
|
||||
value={booleanRadioValue}
|
||||
onChange={val => field.handleChange(val === 1)}
|
||||
className='flex w-fit items-center'
|
||||
value={value}
|
||||
onChange={v => field.handleChange(v)}
|
||||
>
|
||||
<Radio value={1}>True</Radio>
|
||||
<Radio value={0}>False</Radio>
|
||||
<Radio value={true} className='!mr-1'>True</Radio>
|
||||
<Radio value={false}>False</Radio>
|
||||
</Radio.Group>
|
||||
)
|
||||
}
|
||||
|
||||
@ -8,7 +8,10 @@ import type {
|
||||
AnyFieldApi,
|
||||
AnyFormApi,
|
||||
} from '@tanstack/react-form'
|
||||
import { useForm } from '@tanstack/react-form'
|
||||
import {
|
||||
useForm,
|
||||
useStore,
|
||||
} from '@tanstack/react-form'
|
||||
import type {
|
||||
FormRef,
|
||||
FormSchema,
|
||||
@ -32,6 +35,7 @@ export type BaseFormProps = {
|
||||
ref?: FormRef
|
||||
disabled?: boolean
|
||||
formFromProps?: AnyFormApi
|
||||
onChange?: (field: string, value: any) => void
|
||||
onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
|
||||
preventDefaultSubmit?: boolean
|
||||
} & Pick<BaseFieldProps, 'fieldClassName' | 'labelClassName' | 'inputContainerClassName' | 'inputClassName'>
|
||||
@ -47,6 +51,7 @@ const BaseForm = ({
|
||||
ref,
|
||||
disabled,
|
||||
formFromProps,
|
||||
onChange,
|
||||
onSubmit,
|
||||
preventDefaultSubmit = false,
|
||||
}: BaseFormProps) => {
|
||||
@ -67,6 +72,19 @@ const BaseForm = ({
|
||||
const { getFormValues } = useGetFormValues(form, formSchemas)
|
||||
const { getValidators } = useGetValidators()
|
||||
|
||||
const showOnValues = useStore(form.store, (s: any) => {
|
||||
const result: Record<string, any> = {}
|
||||
formSchemas.forEach((schema) => {
|
||||
const { show_on } = schema
|
||||
if (show_on?.length) {
|
||||
show_on.forEach((condition) => {
|
||||
result[condition.variable] = s.values[condition.variable]
|
||||
})
|
||||
}
|
||||
})
|
||||
return result
|
||||
})
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
getForm() {
|
||||
@ -91,19 +109,29 @@ const BaseForm = ({
|
||||
inputContainerClassName={inputContainerClassName}
|
||||
inputClassName={inputClassName}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled])
|
||||
}, [formSchemas, fieldClassName, labelClassName, inputContainerClassName, inputClassName, disabled, onChange])
|
||||
|
||||
const renderFieldWrapper = useCallback((formSchema: FormSchema) => {
|
||||
const validators = getValidators(formSchema)
|
||||
const {
|
||||
name,
|
||||
show_on = [],
|
||||
} = formSchema
|
||||
|
||||
const show = show_on?.every((condition) => {
|
||||
const conditionValue = showOnValues[condition.variable]
|
||||
return conditionValue === condition.value
|
||||
})
|
||||
|
||||
if (!show)
|
||||
return null
|
||||
|
||||
return (
|
||||
<form.Field
|
||||
key={name}
|
||||
@ -113,7 +141,7 @@ const BaseForm = ({
|
||||
{renderField}
|
||||
</form.Field>
|
||||
)
|
||||
}, [renderField, form, getValidators])
|
||||
}, [renderField, form, getValidators, showOnValues])
|
||||
|
||||
if (!formSchemas?.length)
|
||||
return null
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { CustomSelectProps, Option } from '../../../select/custom'
|
||||
import CustomSelect from '../../../select/custom'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
|
||||
type CustomSelectFieldProps<T extends Option> = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
options: T[]
|
||||
className?: string
|
||||
} & Omit<CustomSelectProps<T>, 'options' | 'value' | 'onChange'>
|
||||
|
||||
const CustomSelectField = <T extends Option>({
|
||||
label,
|
||||
labelOptions,
|
||||
options,
|
||||
className,
|
||||
...selectProps
|
||||
}: CustomSelectFieldProps<T>) => {
|
||||
const field = useFieldContext<string>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<CustomSelect<T>
|
||||
value={field.state.value}
|
||||
options={options}
|
||||
onChange={value => field.handleChange(value)}
|
||||
{...selectProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CustomSelectField
|
||||
83
web/app/components/base/form/components/field/file-types.tsx
Normal file
83
web/app/components/base/form/components/field/file-types.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import type { LabelProps } from '../label'
|
||||
import { useFieldContext } from '../..'
|
||||
import Label from '../label'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import FileTypeItem from '@/app/components/workflow/nodes/_base/components/file-type-item'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type FieldValue = {
|
||||
allowedFileTypes: string[],
|
||||
allowedFileExtensions: string[]
|
||||
}
|
||||
|
||||
type FileTypesFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const FileTypesField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
}: FileTypesFieldProps) => {
|
||||
const field = useFieldContext<FieldValue>()
|
||||
|
||||
const handleSupportFileTypeChange = useCallback((type: SupportUploadFileTypes) => {
|
||||
let newAllowFileTypes = [...field.state.value.allowedFileTypes]
|
||||
if (type === SupportUploadFileTypes.custom) {
|
||||
if (!newAllowFileTypes.includes(SupportUploadFileTypes.custom))
|
||||
newAllowFileTypes = [SupportUploadFileTypes.custom]
|
||||
else
|
||||
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
|
||||
}
|
||||
else {
|
||||
newAllowFileTypes = newAllowFileTypes.filter(v => v !== SupportUploadFileTypes.custom)
|
||||
if (newAllowFileTypes.includes(type))
|
||||
newAllowFileTypes = newAllowFileTypes.filter(v => v !== type)
|
||||
else
|
||||
newAllowFileTypes.push(type)
|
||||
}
|
||||
field.handleChange({
|
||||
...field.state.value,
|
||||
allowedFileTypes: newAllowFileTypes,
|
||||
})
|
||||
}, [field])
|
||||
|
||||
const handleCustomFileTypesChange = useCallback((customFileTypes: string[]) => {
|
||||
field.handleChange({
|
||||
...field.state.value,
|
||||
allowedFileExtensions: customFileTypes,
|
||||
})
|
||||
}, [field])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
{
|
||||
[SupportUploadFileTypes.document, SupportUploadFileTypes.image, SupportUploadFileTypes.audio, SupportUploadFileTypes.video].map((type: SupportUploadFileTypes) => (
|
||||
<FileTypeItem
|
||||
key={type}
|
||||
type={type as SupportUploadFileTypes.image | SupportUploadFileTypes.document | SupportUploadFileTypes.audio | SupportUploadFileTypes.video}
|
||||
selected={field.state.value.allowedFileTypes.includes(type)}
|
||||
onToggle={handleSupportFileTypeChange}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<FileTypeItem
|
||||
type={SupportUploadFileTypes.custom}
|
||||
selected={field.state.value.allowedFileTypes.includes(SupportUploadFileTypes.custom)}
|
||||
onToggle={handleSupportFileTypeChange}
|
||||
customFileTypes={field.state.value.allowedFileExtensions}
|
||||
onCustomFileTypesChange={handleCustomFileTypesChange}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileTypesField
|
||||
@ -0,0 +1,40 @@
|
||||
import React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileUploaderInAttachmentWrapperProps } from '../../../file-uploader/file-uploader-in-attachment'
|
||||
import FileUploaderInAttachmentWrapper from '../../../file-uploader/file-uploader-in-attachment'
|
||||
import type { FileEntity } from '../../../file-uploader/types'
|
||||
|
||||
type FileUploaderFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<FileUploaderInAttachmentWrapperProps, 'value' | 'onChange'>
|
||||
|
||||
const FileUploaderField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
...inputProps
|
||||
}: FileUploaderFieldProps) => {
|
||||
const field = useFieldContext<FileEntity[]>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={field.state.value}
|
||||
onChange={value => field.handleChange(value)}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FileUploaderField
|
||||
@ -0,0 +1,52 @@
|
||||
import { InputTypeEnum } from './types'
|
||||
import { PipelineInputVarType } from '@/models/pipeline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAlignLeft,
|
||||
RiCheckboxLine,
|
||||
RiFileCopy2Line,
|
||||
RiFileTextLine,
|
||||
RiHashtag,
|
||||
RiListCheck3,
|
||||
RiTextSnippet,
|
||||
} from '@remixicon/react'
|
||||
|
||||
const i18nFileTypeMap: Record<string, string> = {
|
||||
'number': 'number',
|
||||
'file': 'single-file',
|
||||
'file-list': 'multi-files',
|
||||
}
|
||||
|
||||
const INPUT_TYPE_ICON = {
|
||||
[PipelineInputVarType.textInput]: RiTextSnippet,
|
||||
[PipelineInputVarType.paragraph]: RiAlignLeft,
|
||||
[PipelineInputVarType.number]: RiHashtag,
|
||||
[PipelineInputVarType.select]: RiListCheck3,
|
||||
[PipelineInputVarType.checkbox]: RiCheckboxLine,
|
||||
[PipelineInputVarType.singleFile]: RiFileTextLine,
|
||||
[PipelineInputVarType.multiFiles]: RiFileCopy2Line,
|
||||
}
|
||||
|
||||
const DATA_TYPE = {
|
||||
[PipelineInputVarType.textInput]: 'string',
|
||||
[PipelineInputVarType.paragraph]: 'string',
|
||||
[PipelineInputVarType.number]: 'number',
|
||||
[PipelineInputVarType.select]: 'string',
|
||||
[PipelineInputVarType.checkbox]: 'boolean',
|
||||
[PipelineInputVarType.singleFile]: 'file',
|
||||
[PipelineInputVarType.multiFiles]: 'array[file]',
|
||||
}
|
||||
|
||||
export const useInputTypeOptions = (supportFile: boolean) => {
|
||||
const { t } = useTranslation()
|
||||
const options = supportFile ? InputTypeEnum.options : InputTypeEnum.exclude(['file', 'file-list']).options
|
||||
|
||||
return options.map((value) => {
|
||||
return {
|
||||
value,
|
||||
label: t(`appDebug.variableConfig.${i18nFileTypeMap[value] || value}`),
|
||||
Icon: INPUT_TYPE_ICON[value],
|
||||
type: DATA_TYPE[value],
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFieldContext } from '../../..'
|
||||
import type { CustomSelectProps } from '../../../../select/custom'
|
||||
import CustomSelect from '../../../../select/custom'
|
||||
import type { LabelProps } from '../../label'
|
||||
import Label from '../../label'
|
||||
import { useCallback } from 'react'
|
||||
import Trigger from './trigger'
|
||||
import type { FileTypeSelectOption, InputType } from './types'
|
||||
import { useInputTypeOptions } from './hooks'
|
||||
import Option from './option'
|
||||
|
||||
type InputTypeSelectFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
supportFile: boolean
|
||||
className?: string
|
||||
} & Omit<CustomSelectProps<FileTypeSelectOption>, 'options' | 'value' | 'onChange' | 'CustomTrigger' | 'CustomOption'>
|
||||
|
||||
const InputTypeSelectField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
supportFile,
|
||||
className,
|
||||
...customSelectProps
|
||||
}: InputTypeSelectFieldProps) => {
|
||||
const field = useFieldContext<InputType>()
|
||||
const inputTypeOptions = useInputTypeOptions(supportFile)
|
||||
|
||||
const renderTrigger = useCallback((option: FileTypeSelectOption | undefined, open: boolean) => {
|
||||
return <Trigger option={option} open={open} />
|
||||
}, [])
|
||||
const renderOption = useCallback((option: FileTypeSelectOption) => {
|
||||
return <Option option={option} />
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<CustomSelect<FileTypeSelectOption>
|
||||
value={field.state.value}
|
||||
options={inputTypeOptions}
|
||||
onChange={value => field.handleChange(value as InputType)}
|
||||
triggerProps={{
|
||||
className: 'gap-x-0.5',
|
||||
}}
|
||||
popupProps={{
|
||||
className: 'w-[368px]',
|
||||
wrapperClassName: 'z-[9999999]',
|
||||
itemClassName: 'gap-x-1',
|
||||
}}
|
||||
CustomTrigger={renderTrigger}
|
||||
CustomOption={renderOption}
|
||||
{...customSelectProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default InputTypeSelectField
|
||||
@ -0,0 +1,21 @@
|
||||
import React from 'react'
|
||||
import type { FileTypeSelectOption } from './types'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
type OptionProps = {
|
||||
option: FileTypeSelectOption
|
||||
}
|
||||
|
||||
const Option = ({
|
||||
option,
|
||||
}: OptionProps) => {
|
||||
return (
|
||||
<>
|
||||
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
<span className='grow px-1'>{option.label}</span>
|
||||
<Badge text={option.type} uppercase={false} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Option)
|
||||
@ -0,0 +1,42 @@
|
||||
import React from 'react'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import cn from '@/utils/classnames'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { FileTypeSelectOption } from './types'
|
||||
|
||||
type TriggerProps = {
|
||||
option: FileTypeSelectOption | undefined
|
||||
open: boolean
|
||||
}
|
||||
|
||||
const Trigger = ({
|
||||
option,
|
||||
open,
|
||||
}: TriggerProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<>
|
||||
{option ? (
|
||||
<>
|
||||
<option.Icon className='h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
<span className='grow p-1'>{option.label}</span>
|
||||
<div className='pr-0.5'>
|
||||
<Badge text={option.type} uppercase={false} />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<span className='grow p-1'>{t('common.placeholder.select')}</span>
|
||||
)}
|
||||
<RiArrowDownSLine
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary',
|
||||
open && 'text-text-secondary',
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Trigger)
|
||||
@ -0,0 +1,21 @@
|
||||
import type { RemixiconComponentType } from '@remixicon/react'
|
||||
import { z } from 'zod'
|
||||
|
||||
export const InputTypeEnum = z.enum([
|
||||
'text-input',
|
||||
'paragraph',
|
||||
'number',
|
||||
'select',
|
||||
'checkbox',
|
||||
'file',
|
||||
'file-list',
|
||||
])
|
||||
|
||||
export type InputType = z.infer<typeof InputTypeEnum>
|
||||
|
||||
export type FileTypeSelectOption = {
|
||||
value: InputType
|
||||
label: string
|
||||
Icon: RemixiconComponentType
|
||||
type: string
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
import {
|
||||
memo,
|
||||
} from 'react'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import cn from '@/utils/classnames'
|
||||
import Placeholder from './placeholder'
|
||||
|
||||
type MixedVariableTextInputProps = {
|
||||
editable?: boolean
|
||||
value?: string
|
||||
onChange?: (text: string) => void
|
||||
}
|
||||
const MixedVariableTextInput = ({
|
||||
editable = true,
|
||||
value = '',
|
||||
onChange,
|
||||
}: MixedVariableTextInputProps) => {
|
||||
return (
|
||||
<PromptEditor
|
||||
wrapperClassName={cn(
|
||||
'rounded-lg border border-transparent bg-components-input-bg-normal px-2 py-1',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus-within:border-components-input-border-active focus-within:bg-components-input-bg-active focus-within:shadow-xs',
|
||||
)}
|
||||
className='caret:text-text-accent'
|
||||
editable={editable}
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: [],
|
||||
workflowNodesMap: {},
|
||||
}}
|
||||
placeholder={<Placeholder />}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(MixedVariableTextInput)
|
||||
@ -0,0 +1,49 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
|
||||
import { FOCUS_COMMAND } from 'lexical'
|
||||
import { $insertNodes } from 'lexical'
|
||||
import { CustomTextNode } from '@/app/components/base/prompt-editor/plugins/custom-text/node'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
|
||||
const Placeholder = () => {
|
||||
const [editor] = useLexicalComposerContext()
|
||||
|
||||
const handleInsert = useCallback((text: string) => {
|
||||
editor.update(() => {
|
||||
const textNode = new CustomTextNode(text)
|
||||
$insertNodes([textNode])
|
||||
})
|
||||
editor.dispatchCommand(FOCUS_COMMAND, undefined as any)
|
||||
}, [editor])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='pointer-events-auto flex h-full w-full cursor-text items-center px-2'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleInsert('')
|
||||
}}
|
||||
>
|
||||
<div className='flex grow items-center'>
|
||||
Type or press
|
||||
<div className='system-kbd mx-0.5 flex h-4 w-4 items-center justify-center rounded bg-components-kbd-bg-gray text-text-placeholder'>/</div>
|
||||
<div
|
||||
className='system-sm-regular cursor-pointer text-components-input-text-placeholder underline decoration-dotted decoration-auto underline-offset-auto hover:text-text-tertiary'
|
||||
onClick={((e) => {
|
||||
e.stopPropagation()
|
||||
handleInsert('/')
|
||||
})}
|
||||
>
|
||||
insert variable
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className='shrink-0'
|
||||
text='String'
|
||||
uppercase={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Placeholder
|
||||
@ -1,5 +1,6 @@
|
||||
import React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { InputNumberProps } from '../../../input-number'
|
||||
@ -7,33 +8,24 @@ import { InputNumber } from '../../../input-number'
|
||||
|
||||
type TextFieldProps = {
|
||||
label: string
|
||||
isRequired?: boolean
|
||||
showOptional?: boolean
|
||||
tooltip?: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
} & Omit<InputNumberProps, 'id' | 'value' | 'onChange' | 'onBlur'>
|
||||
|
||||
const NumberInputField = ({
|
||||
label,
|
||||
isRequired,
|
||||
showOptional,
|
||||
tooltip,
|
||||
labelOptions,
|
||||
className,
|
||||
labelClassName,
|
||||
...inputProps
|
||||
}: TextFieldProps) => {
|
||||
const field = useFieldContext<number | undefined>()
|
||||
const field = useFieldContext<number>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
isRequired={isRequired}
|
||||
showOptional={showOptional}
|
||||
tooltip={tooltip}
|
||||
className={labelClassName}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<InputNumber
|
||||
id={field.name}
|
||||
|
||||
@ -0,0 +1,47 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import type { LabelProps } from '../label'
|
||||
import { useFieldContext } from '../..'
|
||||
import Label from '../label'
|
||||
import type { InputNumberWithSliderProps } from '@/app/components/workflow/nodes/_base/components/input-number-with-slider'
|
||||
import InputNumberWithSlider from '@/app/components/workflow/nodes/_base/components/input-number-with-slider'
|
||||
|
||||
type NumberSliderFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
description?: string
|
||||
className?: string
|
||||
} & Omit<InputNumberWithSliderProps, 'value' | 'onChange'>
|
||||
|
||||
const NumberSliderField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
description,
|
||||
className,
|
||||
...InputNumberWithSliderProps
|
||||
}: NumberSliderFieldProps) => {
|
||||
const field = useFieldContext<number>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<div>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
{description && (
|
||||
<div className='body-xs-regular pb-0.5 text-text-tertiary'>
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<InputNumberWithSlider
|
||||
value={field.state.value}
|
||||
onChange={value => field.handleChange(value)}
|
||||
{...InputNumberWithSliderProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default NumberSliderField
|
||||
@ -1,27 +1,29 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
import type { Options } from '@/app/components/app/configuration/config-var/config-select'
|
||||
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
|
||||
|
||||
type OptionsFieldProps = {
|
||||
label: string;
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string;
|
||||
labelClassName?: string;
|
||||
}
|
||||
|
||||
const OptionsField = ({
|
||||
label,
|
||||
className,
|
||||
labelClassName,
|
||||
labelOptions,
|
||||
}: OptionsFieldProps) => {
|
||||
const field = useFieldContext<string[]>()
|
||||
const field = useFieldContext<Options>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
className={labelClassName}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<ConfigSelect
|
||||
options={field.state.value}
|
||||
|
||||
@ -1,31 +1,25 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { Option, PureSelectProps } from '../../../select/pure'
|
||||
import PureSelect from '../../../select/pure'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
|
||||
type SelectOption = {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
type SelectFieldProps = {
|
||||
label: string
|
||||
options: SelectOption[]
|
||||
isRequired?: boolean
|
||||
showOptional?: boolean
|
||||
tooltip?: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
options: Option[]
|
||||
onChange?: (value: string) => void
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
}
|
||||
} & Omit<PureSelectProps, 'options' | 'value' | 'onChange'>
|
||||
|
||||
const SelectField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
options,
|
||||
isRequired,
|
||||
showOptional,
|
||||
tooltip,
|
||||
onChange,
|
||||
className,
|
||||
labelClassName,
|
||||
...selectProps
|
||||
}: SelectFieldProps) => {
|
||||
const field = useFieldContext<string>()
|
||||
|
||||
@ -34,15 +28,13 @@ const SelectField = ({
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
isRequired={isRequired}
|
||||
showOptional={showOptional}
|
||||
tooltip={tooltip}
|
||||
className={labelClassName}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<PureSelect
|
||||
value={field.state.value}
|
||||
options={options}
|
||||
onChange={value => field.handleChange(value)}
|
||||
{...selectProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
41
web/app/components/base/form/components/field/text-area.tsx
Normal file
41
web/app/components/base/form/components/field/text-area.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { TextareaProps } from '../../../textarea'
|
||||
import Textarea from '../../../textarea'
|
||||
|
||||
type TextAreaFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
|
||||
|
||||
const TextAreaField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
...inputProps
|
||||
}: TextAreaFieldProps) => {
|
||||
const field = useFieldContext<string>()
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TextAreaField
|
||||
@ -1,25 +1,20 @@
|
||||
import React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import Input, { type InputProps } from '../../../input'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type TextFieldProps = {
|
||||
label: string
|
||||
isRequired?: boolean
|
||||
showOptional?: boolean
|
||||
tooltip?: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
} & Omit<InputProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
|
||||
|
||||
const TextField = ({
|
||||
label,
|
||||
isRequired,
|
||||
showOptional,
|
||||
tooltip,
|
||||
labelOptions,
|
||||
className,
|
||||
labelClassName,
|
||||
...inputProps
|
||||
}: TextFieldProps) => {
|
||||
const field = useFieldContext<string>()
|
||||
@ -29,10 +24,7 @@ const TextField = ({
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
isRequired={isRequired}
|
||||
showOptional={showOptional}
|
||||
tooltip={tooltip}
|
||||
className={labelClassName}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<Input
|
||||
id={field.name}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import type { LabelProps } from '../label'
|
||||
import { useFieldContext } from '../..'
|
||||
import Label from '../label'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { useCallback } from 'react'
|
||||
|
||||
type UploadMethodFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const UploadMethodField = ({
|
||||
label,
|
||||
labelOptions,
|
||||
className,
|
||||
}: UploadMethodFieldProps) => {
|
||||
const { t } = useTranslation()
|
||||
const field = useFieldContext<TransferMethod[]>()
|
||||
|
||||
const { value } = field.state
|
||||
|
||||
const handleUploadMethodChange = useCallback((method: TransferMethod) => {
|
||||
field.handleChange(method === TransferMethod.all ? [TransferMethod.local_file, TransferMethod.remote_url] : [method])
|
||||
}, [field])
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={field.name}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<div className='grid grid-cols-3 gap-2'>
|
||||
<OptionCard
|
||||
title={t('appDebug.variableConfig.localUpload')}
|
||||
selected={value.length === 1 && value.includes(TransferMethod.local_file)}
|
||||
onSelect={handleUploadMethodChange.bind(null, TransferMethod.local_file)}
|
||||
/>
|
||||
<OptionCard
|
||||
title="URL"
|
||||
selected={value.length === 1 && value.includes(TransferMethod.remote_url)}
|
||||
onSelect={handleUploadMethodChange.bind(null, TransferMethod.remote_url)}
|
||||
/>
|
||||
<OptionCard
|
||||
title={t('appDebug.variableConfig.both')}
|
||||
selected={value.includes(TransferMethod.local_file) && value.includes(TransferMethod.remote_url)}
|
||||
onSelect={handleUploadMethodChange.bind(null, TransferMethod.all)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadMethodField
|
||||
@ -0,0 +1,86 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { RiEditLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import SegmentedControl from '@/app/components/base/segmented-control'
|
||||
import { VariableX } from '@/app/components/base/icons/src/vender/workflow'
|
||||
import Input from '@/app/components/base/input'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
|
||||
type VariableOrConstantInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VariableOrConstantInputField = ({
|
||||
className,
|
||||
label,
|
||||
labelOptions,
|
||||
}: VariableOrConstantInputFieldProps) => {
|
||||
const [variableType, setVariableType] = useState('variable')
|
||||
|
||||
const options = [
|
||||
{
|
||||
Icon: VariableX,
|
||||
value: 'variable',
|
||||
},
|
||||
{
|
||||
Icon: RiEditLine,
|
||||
value: 'constant',
|
||||
},
|
||||
]
|
||||
|
||||
const handleVariableOrConstantChange = (value: string) => {
|
||||
setVariableType(value)
|
||||
}
|
||||
|
||||
const handleVariableValueChange = () => {
|
||||
console.log('Variable value changed')
|
||||
}
|
||||
|
||||
const handleConstantValueChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
console.log('Constant value changed:', e.target.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={'variable-or-constant'}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<div className='flex items-center'>
|
||||
<SegmentedControl
|
||||
className='mr-1 shrink-0'
|
||||
value={variableType}
|
||||
onChange={handleVariableOrConstantChange as any}
|
||||
options={options as any}
|
||||
/>
|
||||
{
|
||||
variableType === 'variable' && (
|
||||
<VarReferencePicker
|
||||
className='grow'
|
||||
nodeId=''
|
||||
readonly={false}
|
||||
value={[]}
|
||||
onChange={handleVariableValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
variableType === 'constant' && (
|
||||
<Input
|
||||
className='ml-1'
|
||||
onChange={handleConstantValueChange}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableOrConstantInputField
|
||||
@ -0,0 +1,41 @@
|
||||
import cn from '@/utils/classnames'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import type { LabelProps } from '../label'
|
||||
import Label from '../label'
|
||||
|
||||
type VariableOrConstantInputFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VariableOrConstantInputField = ({
|
||||
className,
|
||||
label,
|
||||
labelOptions,
|
||||
}: VariableOrConstantInputFieldProps) => {
|
||||
const handleVariableValueChange = () => {
|
||||
console.log('Variable value changed')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-y-0.5', className)}>
|
||||
<Label
|
||||
htmlFor={'variable-or-constant'}
|
||||
label={label}
|
||||
{...(labelOptions ?? {})}
|
||||
/>
|
||||
<div className='flex items-center'>
|
||||
<VarReferencePicker
|
||||
className='grow'
|
||||
nodeId=''
|
||||
readonly={false}
|
||||
value={[]}
|
||||
onChange={handleVariableValueChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VariableOrConstantInputField
|
||||
43
web/app/components/base/form/components/form/actions.tsx
Normal file
43
web/app/components/base/form/components/form/actions.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import type { FormType } from '../..'
|
||||
import { useFormContext } from '../..'
|
||||
import Button from '../../../button'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type CustomActionsProps = {
|
||||
form: FormType
|
||||
isSubmitting: boolean
|
||||
canSubmit: boolean
|
||||
}
|
||||
|
||||
type ActionsProps = {
|
||||
CustomActions?: (props: CustomActionsProps) => React.ReactNode | React.JSX.Element
|
||||
}
|
||||
|
||||
const Actions = ({
|
||||
CustomActions,
|
||||
}: ActionsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const form = useFormContext()
|
||||
|
||||
const [isSubmitting, canSubmit] = useStore(form.store, state => [
|
||||
state.isSubmitting,
|
||||
state.canSubmit,
|
||||
])
|
||||
|
||||
if (CustomActions)
|
||||
return CustomActions({ form, isSubmitting, canSubmit })
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
loading={isSubmitting}
|
||||
onClick={() => form.handleSubmit()}
|
||||
>
|
||||
{t('common.operation.submit')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Actions
|
||||
@ -1,25 +0,0 @@
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
import { useFormContext } from '../..'
|
||||
import Button, { type ButtonProps } from '../../../button'
|
||||
|
||||
type SubmitButtonProps = Omit<ButtonProps, 'disabled' | 'loading' | 'onClick'>
|
||||
|
||||
const SubmitButton = ({ ...buttonProps }: SubmitButtonProps) => {
|
||||
const form = useFormContext()
|
||||
|
||||
const [isSubmitting, canSubmit] = useStore(form.store, state => [
|
||||
state.isSubmitting,
|
||||
state.canSubmit,
|
||||
])
|
||||
|
||||
return (
|
||||
<Button
|
||||
disabled={isSubmitting || !canSubmit}
|
||||
loading={isSubmitting}
|
||||
onClick={() => form.handleSubmit()}
|
||||
{...buttonProps}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default SubmitButton
|
||||
197
web/app/components/base/form/form-scenarios/base/field.tsx
Normal file
197
web/app/components/base/form/form-scenarios/base/field.tsx
Normal file
@ -0,0 +1,197 @@
|
||||
import React from 'react'
|
||||
import { type BaseConfiguration, BaseFieldType } from './types'
|
||||
import { withForm } from '../..'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
type BaseFieldProps = {
|
||||
initialData?: Record<string, any>
|
||||
config: BaseConfiguration
|
||||
}
|
||||
|
||||
const BaseField = ({
|
||||
initialData,
|
||||
config,
|
||||
}: BaseFieldProps) => withForm({
|
||||
defaultValues: initialData,
|
||||
render: function Render({
|
||||
form,
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
variable,
|
||||
tooltip,
|
||||
showConditions,
|
||||
max,
|
||||
min,
|
||||
options,
|
||||
required,
|
||||
showOptional,
|
||||
popupProps,
|
||||
allowedFileExtensions,
|
||||
allowedFileTypes,
|
||||
allowedFileUploadMethods,
|
||||
maxLength,
|
||||
unit,
|
||||
} = config
|
||||
|
||||
const isAllConditionsMet = useStore(form.store, (state) => {
|
||||
const fieldValues = state.values
|
||||
if (!showConditions.length) return true
|
||||
return showConditions.every((condition) => {
|
||||
const { variable, value } = condition
|
||||
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
|
||||
return fieldValue === value
|
||||
})
|
||||
})
|
||||
|
||||
if (!isAllConditionsMet)
|
||||
return <></>
|
||||
|
||||
if (type === BaseFieldType.textInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.paragraph) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.TextAreaField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.numberInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
max={max}
|
||||
min={min}
|
||||
unit={unit}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.checkbox) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.CheckboxField
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.select) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.SelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
options={options!}
|
||||
popupProps={popupProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.file) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.FileUploaderField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_extensions: allowedFileExtensions,
|
||||
allowed_file_types: allowedFileTypes,
|
||||
allowed_file_upload_methods: allowedFileUploadMethods,
|
||||
number_limits: 1,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === BaseFieldType.fileList) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.FileUploaderField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_extensions: allowedFileExtensions,
|
||||
allowed_file_types: allowedFileTypes,
|
||||
allowed_file_upload_methods: allowedFileUploadMethods,
|
||||
number_limits: maxLength,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
},
|
||||
})
|
||||
|
||||
export default BaseField
|
||||
63
web/app/components/base/form/form-scenarios/base/index.tsx
Normal file
63
web/app/components/base/form/form-scenarios/base/index.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { useAppForm } from '../..'
|
||||
import BaseField from './field'
|
||||
import type { BaseFormProps } from './types'
|
||||
import { generateZodSchema } from './utils'
|
||||
|
||||
const BaseForm = ({
|
||||
initialData,
|
||||
configurations,
|
||||
onSubmit,
|
||||
CustomActions,
|
||||
}: BaseFormProps) => {
|
||||
const schema = useMemo(() => {
|
||||
const schema = generateZodSchema(configurations)
|
||||
return schema
|
||||
}, [configurations])
|
||||
|
||||
const baseForm = useAppForm({
|
||||
defaultValues: initialData,
|
||||
validators: {
|
||||
onChange: ({ value }) => {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues
|
||||
const firstIssue = issues[0].message
|
||||
return firstIssue
|
||||
}
|
||||
return undefined
|
||||
},
|
||||
},
|
||||
onSubmit: ({ value }) => {
|
||||
onSubmit(value)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<form
|
||||
className='w-full'
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
baseForm.handleSubmit()
|
||||
}}
|
||||
>
|
||||
<div className='flex flex-col gap-4 px-4 py-2'>
|
||||
{configurations.map((config, index) => {
|
||||
const FieldComponent = BaseField({
|
||||
initialData,
|
||||
config,
|
||||
})
|
||||
return <FieldComponent key={index} form={baseForm} />
|
||||
})}
|
||||
</div>
|
||||
<baseForm.AppForm>
|
||||
<baseForm.Actions
|
||||
CustomActions={CustomActions}
|
||||
/>
|
||||
</baseForm.AppForm>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BaseForm)
|
||||
61
web/app/components/base/form/form-scenarios/base/types.ts
Normal file
61
web/app/components/base/form/form-scenarios/base/types.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import type { TransferMethod } from '@/types/app'
|
||||
import type { Option } from '../../../select/pure'
|
||||
import type { CustomActionsProps } from '../../components/form/actions'
|
||||
|
||||
export enum BaseFieldType {
|
||||
textInput = 'text-input',
|
||||
paragraph = 'paragraph',
|
||||
numberInput = 'number-input',
|
||||
checkbox = 'checkbox',
|
||||
select = 'select',
|
||||
file = 'file',
|
||||
fileList = 'file-list',
|
||||
}
|
||||
|
||||
export type ShowCondition = {
|
||||
variable: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export type NumberConfiguration = {
|
||||
max?: number
|
||||
min?: number
|
||||
unit?: string
|
||||
}
|
||||
|
||||
export type SelectConfiguration = {
|
||||
options: Option[] // Options for select field
|
||||
popupProps?: {
|
||||
wrapperClassName?: string
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
title?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type FileConfiguration = {
|
||||
allowedFileTypes: string[]
|
||||
allowedFileExtensions: string[]
|
||||
allowedFileUploadMethods: TransferMethod[]
|
||||
}
|
||||
|
||||
export type BaseConfiguration = {
|
||||
label: string
|
||||
variable: string // Variable name
|
||||
maxLength?: number // Max length for text input
|
||||
placeholder?: string
|
||||
required: boolean
|
||||
showOptional?: boolean // show optional label
|
||||
showConditions: ShowCondition[] // Show this field only when all conditions are met
|
||||
type: BaseFieldType
|
||||
tooltip?: string // Tooltip for this field
|
||||
} & NumberConfiguration
|
||||
& Partial<SelectConfiguration>
|
||||
& Partial<FileConfiguration>
|
||||
|
||||
export type BaseFormProps = {
|
||||
initialData?: Record<string, any>
|
||||
configurations: BaseConfiguration[]
|
||||
CustomActions?: (props: CustomActionsProps) => React.ReactNode
|
||||
onSubmit: (value: Record<string, any>) => void
|
||||
}
|
||||
57
web/app/components/base/form/form-scenarios/base/utils.ts
Normal file
57
web/app/components/base/form/form-scenarios/base/utils.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import type { ZodNumber, ZodSchema, ZodString } from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { type BaseConfiguration, BaseFieldType } from './types'
|
||||
|
||||
export const generateZodSchema = (fields: BaseConfiguration[]) => {
|
||||
const shape: Record<string, ZodSchema> = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
let zodType
|
||||
|
||||
switch (field.type) {
|
||||
case BaseFieldType.textInput:
|
||||
case BaseFieldType.paragraph:
|
||||
zodType = z.string()
|
||||
break
|
||||
case BaseFieldType.numberInput:
|
||||
zodType = z.number()
|
||||
break
|
||||
case BaseFieldType.checkbox:
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case BaseFieldType.select:
|
||||
zodType = z.string()
|
||||
break
|
||||
default:
|
||||
zodType = z.any()
|
||||
break
|
||||
}
|
||||
|
||||
if (field.maxLength) {
|
||||
if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type))
|
||||
zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`)
|
||||
}
|
||||
|
||||
if (field.min) {
|
||||
if ([BaseFieldType.numberInput].includes(field.type))
|
||||
zodType = (zodType as ZodNumber).min(field.min, `${field.label} must be at least ${field.min}`)
|
||||
}
|
||||
|
||||
if (field.max) {
|
||||
if ([BaseFieldType.numberInput].includes(field.type))
|
||||
zodType = (zodType as ZodNumber).max(field.max, `${field.label} exceeds max value of ${field.max}`)
|
||||
}
|
||||
|
||||
if (field.required) {
|
||||
if ([BaseFieldType.textInput, BaseFieldType.paragraph].includes(field.type))
|
||||
zodType = (zodType as ZodString).nonempty(`${field.label} is required`)
|
||||
}
|
||||
else {
|
||||
zodType = zodType.optional().nullable()
|
||||
}
|
||||
|
||||
shape[field.variable] = zodType
|
||||
})
|
||||
|
||||
return z.object(shape)
|
||||
}
|
||||
@ -59,7 +59,7 @@ const DemoForm = () => {
|
||||
)
|
||||
}
|
||||
<form.AppForm>
|
||||
<form.SubmitButton>Submit</form.SubmitButton>
|
||||
<form.Actions />
|
||||
</form.AppForm>
|
||||
</form>
|
||||
)
|
||||
|
||||
@ -0,0 +1,222 @@
|
||||
import React from 'react'
|
||||
import { type InputFieldConfiguration, InputFieldType } from './types'
|
||||
import { withForm } from '../..'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
type InputFieldProps = {
|
||||
initialData?: Record<string, any>
|
||||
config: InputFieldConfiguration
|
||||
}
|
||||
|
||||
const InputField = ({
|
||||
initialData,
|
||||
config,
|
||||
}: InputFieldProps) => withForm({
|
||||
defaultValues: initialData,
|
||||
render: function Render({
|
||||
form,
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
variable,
|
||||
tooltip,
|
||||
showConditions,
|
||||
max,
|
||||
min,
|
||||
required,
|
||||
showOptional,
|
||||
supportFile,
|
||||
description,
|
||||
options,
|
||||
listeners,
|
||||
popupProps,
|
||||
} = config
|
||||
|
||||
const isAllConditionsMet = useStore(form.store, (state) => {
|
||||
const fieldValues = state.values
|
||||
return showConditions.every((condition) => {
|
||||
const { variable, value } = condition
|
||||
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
|
||||
return fieldValue === value
|
||||
})
|
||||
})
|
||||
|
||||
if (!isAllConditionsMet)
|
||||
return <></>
|
||||
|
||||
if (type === InputFieldType.textInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
listeners={listeners}
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberSlider) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberSliderField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
description={description}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.checkbox) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.CheckboxField
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.select) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.SelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
options={options!}
|
||||
popupProps={popupProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.inputTypeSelect) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
listeners={listeners}
|
||||
children={field => (
|
||||
<field.InputTypeSelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
supportFile={!!supportFile}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.uploadMethod) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.UploadMethodField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.fileTypes) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.FileTypesField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.options) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.OptionsField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
},
|
||||
})
|
||||
|
||||
export default InputField
|
||||
@ -0,0 +1,39 @@
|
||||
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
|
||||
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
|
||||
|
||||
export enum InputFieldType {
|
||||
textInput = 'textInput',
|
||||
numberInput = 'numberInput',
|
||||
numberSlider = 'numberSlider',
|
||||
checkbox = 'checkbox',
|
||||
options = 'options',
|
||||
select = 'select',
|
||||
inputTypeSelect = 'inputTypeSelect',
|
||||
uploadMethod = 'uploadMethod',
|
||||
fileTypes = 'fileTypes',
|
||||
}
|
||||
|
||||
export type InputTypeSelectConfiguration = {
|
||||
supportFile: boolean
|
||||
}
|
||||
|
||||
export type NumberSliderConfiguration = {
|
||||
description: string
|
||||
max?: number
|
||||
min?: number
|
||||
}
|
||||
|
||||
export type InputFieldConfiguration = {
|
||||
label: string
|
||||
variable: string // Variable name
|
||||
maxLength?: number // Max length for text input
|
||||
placeholder?: string
|
||||
required: boolean
|
||||
showOptional?: boolean // show optional label
|
||||
showConditions: ShowCondition[] // Show this field only when all conditions are met
|
||||
type: InputFieldType
|
||||
tooltip?: string // Tooltip for this field
|
||||
listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field
|
||||
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
|
||||
& Partial<NumberSliderConfiguration>
|
||||
& Partial<SelectConfiguration>
|
||||
@ -0,0 +1,75 @@
|
||||
import type { ZodSchema, ZodString } from 'zod'
|
||||
import { z } from 'zod'
|
||||
import { type InputFieldConfiguration, InputFieldType } from './types'
|
||||
import { SupportedFileTypes, TransferMethod } from '@/app/components/rag-pipeline/components/panel/input-field/editor/form/schema'
|
||||
|
||||
export const generateZodSchema = (fields: InputFieldConfiguration[]) => {
|
||||
const shape: Record<string, ZodSchema> = {}
|
||||
|
||||
fields.forEach((field) => {
|
||||
let zodType
|
||||
|
||||
switch (field.type) {
|
||||
case InputFieldType.textInput:
|
||||
zodType = z.string()
|
||||
break
|
||||
case InputFieldType.numberInput:
|
||||
zodType = z.number()
|
||||
break
|
||||
case InputFieldType.numberSlider:
|
||||
zodType = z.number()
|
||||
break
|
||||
case InputFieldType.checkbox:
|
||||
zodType = z.boolean()
|
||||
break
|
||||
case InputFieldType.options:
|
||||
zodType = z.array(z.string())
|
||||
break
|
||||
case InputFieldType.select:
|
||||
zodType = z.string()
|
||||
break
|
||||
case InputFieldType.fileTypes:
|
||||
zodType = z.object({
|
||||
allowedFileExtensions: z.string().optional(),
|
||||
allowedFileTypes: z.array(SupportedFileTypes),
|
||||
})
|
||||
break
|
||||
case InputFieldType.inputTypeSelect:
|
||||
zodType = z.string()
|
||||
break
|
||||
case InputFieldType.uploadMethod:
|
||||
zodType = z.array(TransferMethod)
|
||||
break
|
||||
default:
|
||||
zodType = z.any()
|
||||
break
|
||||
}
|
||||
|
||||
if (field.maxLength) {
|
||||
if ([InputFieldType.textInput].includes(field.type))
|
||||
zodType = (zodType as ZodString).max(field.maxLength, `${field.label} exceeds max length of ${field.maxLength}`)
|
||||
}
|
||||
|
||||
if (field.min) {
|
||||
if ([InputFieldType.numberInput].includes(field.type))
|
||||
zodType = (zodType as ZodString).min(field.min, `${field.label} must be at least ${field.min}`)
|
||||
}
|
||||
|
||||
if (field.max) {
|
||||
if ([InputFieldType.numberInput].includes(field.type))
|
||||
zodType = (zodType as ZodString).max(field.max, `${field.label} exceeds max value of ${field.max}`)
|
||||
}
|
||||
|
||||
if (field.required) {
|
||||
if ([InputFieldType.textInput].includes(field.type))
|
||||
zodType = (zodType as ZodString).nonempty(`${field.label} is required`)
|
||||
}
|
||||
else {
|
||||
zodType = zodType.optional()
|
||||
}
|
||||
|
||||
shape[field.variable] = zodType
|
||||
})
|
||||
|
||||
return z.object(shape)
|
||||
}
|
||||
239
web/app/components/base/form/form-scenarios/node-panel/field.tsx
Normal file
239
web/app/components/base/form/form-scenarios/node-panel/field.tsx
Normal file
@ -0,0 +1,239 @@
|
||||
import React from 'react'
|
||||
import { type InputFieldConfiguration, InputFieldType } from './types'
|
||||
import { withForm } from '../..'
|
||||
import { useStore } from '@tanstack/react-form'
|
||||
|
||||
type InputFieldProps = {
|
||||
initialData?: Record<string, any>
|
||||
config: InputFieldConfiguration
|
||||
}
|
||||
|
||||
const NodePanelField = ({
|
||||
initialData,
|
||||
config,
|
||||
}: InputFieldProps) => withForm({
|
||||
defaultValues: initialData,
|
||||
render: function Render({
|
||||
form,
|
||||
}) {
|
||||
const {
|
||||
type,
|
||||
label,
|
||||
placeholder,
|
||||
variable,
|
||||
tooltip,
|
||||
showConditions,
|
||||
max,
|
||||
min,
|
||||
required,
|
||||
showOptional,
|
||||
supportFile,
|
||||
description,
|
||||
options,
|
||||
listeners,
|
||||
popupProps,
|
||||
} = config
|
||||
|
||||
const isAllConditionsMet = useStore(form.store, (state) => {
|
||||
const fieldValues = state.values
|
||||
return showConditions.every((condition) => {
|
||||
const { variable, value } = condition
|
||||
const fieldValue = fieldValues[variable as keyof typeof fieldValues]
|
||||
return fieldValue === value
|
||||
})
|
||||
})
|
||||
|
||||
if (!isAllConditionsMet)
|
||||
return <></>
|
||||
|
||||
if (type === InputFieldType.textInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.TextField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberInput) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.numberSlider) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.NumberSliderField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
description={description}
|
||||
max={max}
|
||||
min={min}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.checkbox) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.CheckboxField
|
||||
label={label}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.select) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.SelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
options={options!}
|
||||
popupProps={popupProps}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.inputTypeSelect) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
listeners={listeners}
|
||||
children={field => (
|
||||
<field.InputTypeSelectField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
supportFile={!!supportFile}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.uploadMethod) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.UploadMethodField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.fileTypes) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.FileTypesField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.options) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.OptionsField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
if (type === InputFieldType.variableOrConstant) {
|
||||
return (
|
||||
<form.AppField
|
||||
name={variable}
|
||||
children={field => (
|
||||
<field.VariableOrConstantInputField
|
||||
label={label}
|
||||
labelOptions={{
|
||||
tooltip,
|
||||
isRequired: required,
|
||||
showOptional,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return <></>
|
||||
},
|
||||
})
|
||||
|
||||
export default NodePanelField
|
||||
@ -0,0 +1,40 @@
|
||||
import type { DeepKeys, FieldListeners } from '@tanstack/react-form'
|
||||
import type { NumberConfiguration, SelectConfiguration, ShowCondition } from '../base/types'
|
||||
|
||||
export enum InputFieldType {
|
||||
textInput = 'textInput',
|
||||
numberInput = 'numberInput',
|
||||
numberSlider = 'numberSlider',
|
||||
checkbox = 'checkbox',
|
||||
options = 'options',
|
||||
select = 'select',
|
||||
inputTypeSelect = 'inputTypeSelect',
|
||||
uploadMethod = 'uploadMethod',
|
||||
fileTypes = 'fileTypes',
|
||||
variableOrConstant = 'variableOrConstant',
|
||||
}
|
||||
|
||||
export type InputTypeSelectConfiguration = {
|
||||
supportFile: boolean
|
||||
}
|
||||
|
||||
export type NumberSliderConfiguration = {
|
||||
description: string
|
||||
max?: number
|
||||
min?: number
|
||||
}
|
||||
|
||||
export type InputFieldConfiguration = {
|
||||
label: string
|
||||
variable: string // Variable name
|
||||
maxLength?: number // Max length for text input
|
||||
placeholder?: string
|
||||
required: boolean
|
||||
showOptional?: boolean // show optional label
|
||||
showConditions: ShowCondition[] // Show this field only when all conditions are met
|
||||
type: InputFieldType
|
||||
tooltip?: string // Tooltip for this field
|
||||
listeners?: FieldListeners<Record<string, any>, DeepKeys<Record<string, any>>> // Listener for this field
|
||||
} & NumberConfiguration & Partial<InputTypeSelectConfiguration>
|
||||
& Partial<NumberSliderConfiguration>
|
||||
& Partial<SelectConfiguration>
|
||||
@ -3,8 +3,16 @@ import TextField from './components/field/text'
|
||||
import NumberInputField from './components/field/number-input'
|
||||
import CheckboxField from './components/field/checkbox'
|
||||
import SelectField from './components/field/select'
|
||||
import CustomSelectField from './components/field/custom-select'
|
||||
import OptionsField from './components/field/options'
|
||||
import SubmitButton from './components/form/submit-button'
|
||||
import Actions from './components/form/actions'
|
||||
import InputTypeSelectField from './components/field/input-type-select'
|
||||
import FileTypesField from './components/field/file-types'
|
||||
import UploadMethodField from './components/field/upload-method'
|
||||
import NumberSliderField from './components/field/number-slider'
|
||||
import VariableOrConstantInputField from './components/field/variable-selector'
|
||||
import TextAreaField from './components/field/text-area'
|
||||
import FileUploaderField from './components/field/file-uploader'
|
||||
|
||||
export const { fieldContext, useFieldContext, formContext, useFormContext }
|
||||
= createFormHookContexts()
|
||||
@ -12,14 +20,24 @@ export const { fieldContext, useFieldContext, formContext, useFormContext }
|
||||
export const { useAppForm, withForm } = createFormHook({
|
||||
fieldComponents: {
|
||||
TextField,
|
||||
TextAreaField,
|
||||
NumberInputField,
|
||||
CheckboxField,
|
||||
SelectField,
|
||||
CustomSelectField,
|
||||
OptionsField,
|
||||
InputTypeSelectField,
|
||||
FileTypesField,
|
||||
UploadMethodField,
|
||||
NumberSliderField,
|
||||
VariableOrConstantInputField,
|
||||
FileUploaderField,
|
||||
},
|
||||
formComponents: {
|
||||
SubmitButton,
|
||||
Actions,
|
||||
},
|
||||
fieldContext,
|
||||
formContext,
|
||||
})
|
||||
|
||||
export type FormType = ReturnType<typeof useFormContext>
|
||||
|
||||
@ -24,7 +24,7 @@ const GA: FC<IGAProps> = ({
|
||||
if (IS_CE_EDITION)
|
||||
return null
|
||||
|
||||
const nonce = process.env.NODE_ENV === 'production' ? (headers() as unknown as UnsafeUnwrappedHeaders).get('x-nonce') : ''
|
||||
const nonce = process.env.NODE_ENV === 'production' ? (headers() as unknown as UnsafeUnwrappedHeaders).get('x-nonce') ?? '' : ''
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -32,7 +32,7 @@ const GA: FC<IGAProps> = ({
|
||||
strategy="beforeInteractive"
|
||||
async
|
||||
src={`https://www.googletagmanager.com/gtag/js?id=${gaIdMaps[gaType]}`}
|
||||
nonce={nonce!}
|
||||
nonce={nonce ?? undefined}
|
||||
></Script>
|
||||
<Script
|
||||
id="ga-init"
|
||||
@ -44,14 +44,14 @@ gtag('js', new Date());
|
||||
gtag('config', '${gaIdMaps[gaType]}');
|
||||
`,
|
||||
}}
|
||||
nonce={nonce!}
|
||||
nonce={nonce ?? undefined}
|
||||
>
|
||||
</Script>
|
||||
{/* Cookie banner */}
|
||||
<Script
|
||||
id="cookieyes"
|
||||
src='https://cdn-cookieyes.com/client_data/2a645945fcae53f8e025a2b1/script.js'
|
||||
nonce={nonce!}
|
||||
nonce={nonce ?? undefined}
|
||||
></Script>
|
||||
</>
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ const IconBase = (
|
||||
ref,
|
||||
...props
|
||||
}: IconBaseProps & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
ref?: React.RefObject<React.RefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => {
|
||||
const { data, className, onClick, style, ...restProps } = props
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
<svg width="126" height="25" viewBox="0 0 126 25" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_4_12262)">
|
||||
<path d="M47.3131 15.0593V9.21609C47.3131 8.64631 47.2037 8.23413 46.9849 7.95531C46.7662 7.6886 46.4259 7.54313 45.9641 7.54313C45.1377 7.54313 44.2991 7.79771 43.4606 8.30687C43.4728 8.39173 43.4728 8.47659 43.4728 8.56145C43.4728 8.64631 43.4728 8.7433 43.4728 8.82816V15.0351H41.7834V9.19184C41.7834 8.62207 41.6741 8.20989 41.4553 7.93106C41.2366 7.66436 40.8963 7.51888 40.4345 7.51888C39.5716 7.51888 38.7452 7.77346 37.9553 8.2705V15.023H36.266V6.4157H37.6757L37.8459 7.27642C38.9396 6.5248 39.9969 6.16112 41.03 6.16112C42.0873 6.16112 42.8043 6.56117 43.1689 7.36128C44.287 6.56117 45.4172 6.16112 46.5353 6.16112C47.313 6.16112 47.9207 6.37933 48.3339 6.82788C48.7593 7.2643 48.9659 7.89469 48.9659 8.70693V15.023H47.3131V15.0593Z" fill="#222225"/>
|
||||
<path d="M55.966 15.0593L55.8323 14.1744C55.4191 14.5259 54.9694 14.7926 54.4833 14.9866C53.985 15.1806 53.4989 15.2775 53.0128 15.2775C52.2107 15.2775 51.5666 15.0472 51.0805 14.5865C50.5944 14.1259 50.3513 13.5197 50.3513 12.7439C50.3513 11.9195 50.643 11.2527 51.2385 10.7678C51.834 10.2708 52.6239 10.0283 53.6205 10.0283C54.2767 10.0283 54.9937 10.1253 55.7594 10.3193V9.20397C55.7594 8.59782 55.6257 8.1614 55.3462 7.91894C55.0667 7.66436 54.5927 7.54313 53.9243 7.54313C52.9642 7.54313 51.9677 7.71285 50.9346 8.06441V6.87637C51.3478 6.65816 51.8461 6.50056 52.4295 6.37933C53.025 6.2581 53.6205 6.19749 54.216 6.19749C55.2976 6.19749 56.0875 6.4157 56.5979 6.86425C57.1084 7.31279 57.3636 7.99168 57.3636 8.91302V15.0593H55.966ZM53.4139 14.0046C54.1795 14.0046 54.9694 13.7137 55.7594 13.1439V11.4103C55.1639 11.2649 54.5684 11.1921 53.9607 11.1921C52.6847 11.1921 52.0406 11.6892 52.0406 12.6711C52.0406 13.0954 52.1621 13.4349 52.393 13.6652C52.6361 13.8834 52.9763 14.0046 53.4139 14.0046Z" fill="#222225"/>
|
||||
<path d="M59.2959 15.0593V6.45207H60.7056L60.8879 7.72497C61.2039 7.38553 61.4956 7.11883 61.7751 6.93698C62.0546 6.75514 62.322 6.60966 62.6137 6.5248C62.8932 6.43994 63.2092 6.39145 63.5373 6.39145C63.756 6.39145 63.987 6.40357 64.23 6.43994V7.96743C63.8654 7.91894 63.5616 7.89469 63.3064 7.89469C62.3949 7.89469 61.6171 8.1614 60.973 8.70693V15.0593H59.2959Z" fill="#222225"/>
|
||||
<path d="M65.2752 15.0593V2.5H66.9644V10.2102L70.6225 6.46419H72.6763L68.5929 10.5496L72.9437 15.0593H70.8169L66.9523 10.986V15.0593H65.2752Z" fill="#222225"/>
|
||||
<path d="M74.4871 11.1194C74.5114 12.0892 74.7545 12.8166 75.2163 13.2773C75.6781 13.7379 76.383 13.9683 77.3552 13.9683C78.2546 13.9683 79.166 13.7985 80.0897 13.447V14.6472C79.2754 15.0836 78.2789 15.3139 77.1 15.3139C75.7024 15.3139 74.6451 14.926 73.9403 14.1622C73.2232 13.3985 72.8708 12.2589 72.8708 10.7557C72.8708 9.31307 73.2232 8.19776 73.9403 7.40978C74.6573 6.62179 75.6538 6.20961 76.9299 6.20961C78.0115 6.20961 78.8379 6.51268 79.4091 7.11883C79.9925 7.72497 80.272 8.5857 80.272 9.68888C80.272 10.2223 80.2234 10.7072 80.1383 11.1315H74.4871V11.1194ZM76.8327 7.47039C76.1156 7.47039 75.5566 7.6886 75.1677 8.10078C74.7667 8.52508 74.5479 9.15547 74.4871 9.99195H78.7285C78.7407 9.90709 78.7407 9.7495 78.7407 9.55553C78.7407 8.86452 78.5827 8.34324 78.2546 7.99168C77.9386 7.65223 77.4646 7.47039 76.8327 7.47039Z" fill="#222225"/>
|
||||
<path d="M86.8589 14.8532C86.2998 15.0715 85.6679 15.1806 84.9752 15.1806C83.3588 15.1806 82.5567 14.3804 82.5567 12.7681V7.77346H80.989V6.6824L82.6053 6.47631L82.8605 4.02749H84.2338V6.43994H86.786V7.76134H84.2338V12.6832C84.2338 13.1075 84.3311 13.3985 84.5134 13.5682C84.6956 13.7379 85.0238 13.8228 85.4734 13.8228C85.9353 13.8228 86.3971 13.7622 86.8467 13.6531V14.8532H86.8589Z" fill="#222225"/>
|
||||
<path d="M88.0134 6.45207H89.4232L89.5811 7.33704C90.444 6.5733 91.4041 6.19749 92.4614 6.19749C93.5673 6.19749 94.4302 6.59754 95.0621 7.39765C95.6941 8.19776 96.0101 9.2767 96.0101 10.6466C96.0101 12.0407 95.6698 13.156 95.0014 13.9925C94.333 14.829 93.4458 15.2533 92.3277 15.2533C91.3069 15.2533 90.4319 14.9139 89.6905 14.2471V18.5628H88.0134V6.45207ZM91.9753 7.55525C91.1732 7.55525 90.4076 7.80983 89.6905 8.33112V13.0712C90.4319 13.6167 91.1732 13.8834 91.9388 13.8834C93.5066 13.8834 94.2844 12.8287 94.2844 10.7315C94.2844 9.65251 94.0899 8.86453 93.7132 8.34324C93.3486 7.82196 92.7652 7.55525 91.9753 7.55525Z" fill="#222225"/>
|
||||
<path d="M100.628 14.9381C100.251 15.0715 99.8504 15.1442 99.4008 15.1442C98.8053 15.1442 98.3435 14.9745 98.0275 14.6229C97.7115 14.2835 97.5535 13.7864 97.5535 13.1439V2.5H99.2428V13.0348C99.2428 13.2894 99.2914 13.4834 99.4008 13.6046C99.5101 13.7258 99.6924 13.7864 99.9355 13.7864C100.166 13.7864 100.397 13.7743 100.628 13.7379V14.9381Z" fill="#222225"/>
|
||||
<path d="M107.142 15.0593L107.009 14.1744C106.595 14.5259 106.146 14.7926 105.66 14.9866C105.161 15.1806 104.675 15.2775 104.189 15.2775C103.387 15.2775 102.743 15.0472 102.257 14.5865C101.771 14.1259 101.528 13.5197 101.528 12.7439C101.528 11.9195 101.819 11.2527 102.415 10.7678C103.01 10.2708 103.8 10.0283 104.797 10.0283C105.453 10.0283 106.17 10.1253 106.936 10.3193V9.20397C106.936 8.59782 106.802 8.1614 106.522 7.91894C106.243 7.66436 105.769 7.54313 105.101 7.54313C104.14 7.54313 103.144 7.71285 102.111 8.06441V6.87637C102.524 6.65816 103.022 6.50056 103.606 6.37933C104.201 6.2581 104.797 6.19749 105.392 6.19749C106.474 6.19749 107.264 6.4157 107.774 6.86425C108.285 7.31279 108.54 7.99168 108.54 8.91302V15.0593H107.142ZM104.59 14.0046C105.356 14.0046 106.146 13.7137 106.936 13.1439V11.4103C106.34 11.2649 105.745 11.1921 105.137 11.1921C103.861 11.1921 103.217 11.6892 103.217 12.6711C103.217 13.0954 103.338 13.4349 103.569 13.6652C103.812 13.8834 104.153 14.0046 104.59 14.0046Z" fill="#222225"/>
|
||||
<path d="M116.488 14.732C115.832 15.0715 115.066 15.2412 114.203 15.2412C112.866 15.2412 111.845 14.8532 111.128 14.0895C110.424 13.3258 110.059 12.2226 110.059 10.7678C110.059 9.32519 110.424 8.22201 111.153 7.43402C111.882 6.64603 112.915 6.2581 114.252 6.2581C115.017 6.2581 115.722 6.4157 116.391 6.73089V7.93106C115.747 7.72497 115.115 7.62799 114.483 7.62799C113.535 7.62799 112.842 7.87045 112.429 8.35536C112.003 8.84028 111.797 9.60402 111.797 10.6587V10.8769C111.797 11.9074 112.016 12.659 112.429 13.1439C112.854 13.6288 113.523 13.8713 114.446 13.8713C115.054 13.8713 115.722 13.7622 116.451 13.5318V14.732H116.488Z" fill="#222225"/>
|
||||
<path d="M118.882 11.1194C118.906 12.0892 119.149 12.8166 119.611 13.2773C120.073 13.7379 120.778 13.9683 121.75 13.9683C122.649 13.9683 123.561 13.7985 124.484 13.447V14.6472C123.67 15.0836 122.674 15.3139 121.495 15.3139C120.097 15.3139 119.04 14.926 118.335 14.1622C117.618 13.3985 117.266 12.2589 117.266 10.7557C117.266 9.31307 117.618 8.19776 118.335 7.40978C119.052 6.62179 120.049 6.20961 121.325 6.20961C122.406 6.20961 123.233 6.51268 123.804 7.11883C124.387 7.72497 124.667 8.5857 124.667 9.68888C124.667 10.2223 124.618 10.7072 124.533 11.1315H118.882V11.1194ZM121.228 7.47039C120.511 7.47039 119.951 7.6886 119.563 8.10078C119.162 8.52508 118.943 9.15547 118.882 9.99195H123.123C123.136 9.90709 123.136 9.7495 123.136 9.55553C123.136 8.86452 122.978 8.34324 122.649 7.99168C122.321 7.65223 121.847 7.47039 121.228 7.47039Z" fill="#222225"/>
|
||||
<path d="M26.9203 15.3139C26.3735 15.3139 25.8387 15.2533 25.3161 15.1321C24.7936 15.0108 24.3925 14.8775 24.113 14.7199C23.9429 14.6229 23.8335 14.5259 23.797 14.4289C23.7484 14.332 23.7363 14.235 23.7363 14.138V13.6167C23.7363 13.3985 23.8092 13.2894 23.9672 13.2894C24.0279 13.2894 24.0887 13.3015 24.1616 13.3258C24.2224 13.35 24.3196 13.3864 24.429 13.4349C24.7814 13.5925 25.1582 13.7137 25.5714 13.7985C25.9846 13.8834 26.3978 13.9198 26.811 13.9198C27.4672 13.9198 27.9655 13.8107 28.3301 13.5803C28.6825 13.35 28.8648 13.0227 28.8648 12.5984C28.8648 12.3074 28.7676 12.065 28.5853 11.871C28.403 11.677 28.0506 11.4952 27.5401 11.3255L26.0332 10.8527C25.2675 10.6102 24.7206 10.2587 24.3682 9.79799C24.0279 9.33732 23.8456 8.84028 23.8456 8.29475C23.8456 7.85832 23.9429 7.47039 24.1251 7.14307C24.3074 6.81575 24.5627 6.5248 24.8665 6.29447C25.1703 6.06413 25.5349 5.88229 25.9481 5.76106C26.3613 5.63983 26.7988 5.57922 27.2606 5.57922C27.4915 5.57922 27.7224 5.59134 27.9655 5.62771C28.1964 5.66408 28.4273 5.70045 28.6339 5.74894C28.8405 5.79743 29.0471 5.84592 29.2294 5.90654C29.4117 5.96715 29.5575 6.02777 29.6669 6.08838C29.8128 6.17324 29.91 6.2581 29.9707 6.34296C30.0315 6.42782 30.0558 6.53693 30.0558 6.6824V7.16732C30.0558 7.38553 29.9829 7.49464 29.8249 7.49464C29.7398 7.49464 29.6061 7.45827 29.4239 7.37341C28.8284 7.1067 28.1599 6.97335 27.4308 6.97335C26.8353 6.97335 26.3735 7.07033 26.0453 7.2643C25.7172 7.45827 25.5592 7.76134 25.5592 8.18564C25.5592 8.47659 25.6564 8.71905 25.863 8.92514C26.0696 9.11911 26.4464 9.31307 27.0054 9.49492L28.4759 9.96771C29.2294 10.2102 29.7641 10.5375 30.0801 10.9618C30.3961 11.3861 30.5662 11.871 30.5662 12.4165C30.5662 12.8651 30.4812 13.2651 30.2989 13.6167C30.1166 13.9683 29.8614 14.2835 29.5454 14.538C29.2294 14.7926 28.8405 14.9866 28.3908 15.1199C27.9412 15.2412 27.4551 15.3139 26.9203 15.3139ZM13.5885 15.0351C13.4062 15.0351 13.2725 14.9987 13.1875 14.9381C13.1024 14.8775 13.0295 14.732 12.9687 14.5259L10.5381 6.51268C10.4774 6.30659 10.4531 6.17324 10.4531 6.1005C10.4531 5.93078 10.5381 5.84592 10.7083 5.84592H11.7291C11.9236 5.84592 12.0573 5.88229 12.1423 5.9429C12.2152 6.00352 12.2882 6.14899 12.3489 6.35508L14.0989 13.2045L15.691 6.35508C15.7396 6.14899 15.8004 6.01564 15.8854 5.9429C15.9705 5.88229 16.1042 5.84592 16.2986 5.84592H17.1372C17.3316 5.84592 17.4653 5.88229 17.5504 5.9429C17.6355 6.00352 17.6962 6.14899 17.7448 6.35508L19.3855 13.3015L21.1841 6.35508C21.2449 6.14899 21.3178 6.01564 21.3907 5.9429C21.4636 5.88229 21.6095 5.84592 21.8039 5.84592H22.764C22.9341 5.84592 23.0192 5.93078 23.0192 6.1005C23.0192 6.14899 23.007 6.19749 23.007 6.2581C22.9948 6.31871 22.9706 6.40357 22.9341 6.51268L20.4306 14.5259C20.3698 14.732 20.2969 14.8654 20.2118 14.9381C20.1268 14.9987 19.9931 15.0351 19.8108 15.0351H18.9236C18.7292 15.0351 18.5955 14.9987 18.5104 14.926C18.4254 14.8532 18.3646 14.7199 18.316 14.5138L16.6996 7.8462L15.0954 14.5138C15.0468 14.7199 14.986 14.8532 14.901 14.926C14.8159 14.9987 14.6822 15.0351 14.4878 15.0351H13.5885ZM5.23942 13.9077C5.57971 13.9077 5.93214 13.847 6.30889 13.7137C6.68563 13.5925 7.01376 13.3621 7.29328 13.0469C7.46342 12.853 7.58495 12.6347 7.64572 12.3802C7.70648 12.1377 7.74294 11.8346 7.74294 11.4831V11.0466C7.43911 10.9739 7.12314 10.9133 6.78285 10.8769C6.45472 10.8406 6.12659 10.8163 5.81061 10.8163C5.11789 10.8163 4.60747 10.9497 4.26718 11.2285C3.9269 11.5073 3.75676 11.9074 3.75676 12.4287C3.75676 12.9136 3.87829 13.2773 4.1335 13.5197C4.41302 13.7864 4.76546 13.9077 5.23942 13.9077ZM9.45651 12.3317C9.45651 12.7196 9.49297 13.0227 9.57804 13.253C9.65096 13.4834 9.77249 13.7258 9.91832 13.9925C9.96694 14.0774 9.99124 14.1501 9.99124 14.2228C9.99124 14.332 9.93048 14.4289 9.79679 14.5259L9.12838 14.9866C9.03116 15.0472 8.94608 15.0715 8.86101 15.0715C8.75164 15.0715 8.65441 15.023 8.55719 14.926C8.41135 14.7805 8.28982 14.6229 8.18045 14.4411C8.08322 14.2713 7.97385 14.0653 7.86447 13.8349C7.05022 14.7926 6.02937 15.2654 4.81407 15.2654C3.93905 15.2654 3.24633 15.023 2.74806 14.5259C2.23763 14.0289 1.98242 13.3621 1.98242 12.5378C1.98242 11.6528 2.2984 10.9497 2.93036 10.4041C3.56231 9.87073 4.41302 9.5919 5.47033 9.5919C5.82277 9.5919 6.18736 9.61614 6.5641 9.67676C6.94084 9.72525 7.34189 9.81011 7.75509 9.90709V9.14335C7.75509 8.35536 7.5971 7.80983 7.26897 7.48251C6.94084 7.16732 6.3818 6.9976 5.57971 6.9976C5.21512 6.9976 4.83838 7.04609 4.46163 7.13095C4.08489 7.21581 3.70815 7.33704 3.34356 7.48251C3.17342 7.55525 3.05189 7.60374 2.97897 7.61587C2.90605 7.62799 2.85744 7.64011 2.82098 7.64011C2.67514 7.64011 2.60222 7.531 2.60222 7.31279V6.80363C2.60222 6.63391 2.62653 6.51268 2.67514 6.43994C2.72375 6.36721 2.82098 6.29447 2.96681 6.22173C3.3314 6.03989 3.76891 5.88229 4.27934 5.74894C4.78976 5.61559 5.32449 5.55497 5.89568 5.55497C7.13529 5.55497 8.03461 5.8338 8.61796 6.40357C9.18915 6.96123 9.48082 7.80983 9.48082 8.94938V12.3317H9.45651Z" fill="#222225"/>
|
||||
<path d="M28.889 20.3449C25.474 22.8665 20.5156 24.2 16.2499 24.2C10.2707 24.2 4.88689 21.9936 0.803479 18.3204C0.487501 18.0294 0.76702 17.6415 1.15592 17.8597C5.5553 20.4177 10.9877 21.9451 16.6145 21.9451C20.4062 21.9451 24.5747 21.1572 28.4029 19.5448C28.9741 19.3023 29.4481 19.9327 28.889 20.3449Z" fill="#FF9900"/>
|
||||
<path d="M30.3109 18.7205C29.8734 18.1628 27.4185 18.4537 26.3126 18.5871C25.9845 18.6235 25.9237 18.3325 26.2275 18.1264C28.1842 16.7565 31.3925 17.1566 31.7693 17.6051C32.146 18.0658 31.6721 21.2784 29.837 22.8059C29.5574 23.0362 29.2901 22.915 29.4116 22.5998C29.8248 21.5815 30.7484 19.2902 30.3109 18.7205Z" fill="#FF9900"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_4_12262">
|
||||
<rect width="124.5" height="24" fill="white" transform="translate(0.666748 0.5)"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,29 @@
|
||||
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M10.5996 0.5C11.7113 0.5 12.5755 0.500118 13.2676 0.556641C13.9655 0.61366 14.5329 0.730378 15.043 0.990234L15.3525 1.16406C16.0576 1.59644 16.6322 2.21605 17.0098 2.95703L17.1006 3.15137C17.2979 3.61108 17.3935 4.12184 17.4434 4.73242C17.4999 5.42447 17.5 6.28869 17.5 7.40039V10.5996C17.5 11.7113 17.4999 12.5755 17.4434 13.2676C17.3935 13.8782 17.2979 14.3889 17.1006 14.8486L17.0098 15.043C16.6322 15.7839 16.0576 16.4036 15.3525 16.8359L15.043 17.0098C14.5329 17.2696 13.9655 17.3863 13.2676 17.4434C12.5755 17.4999 11.7113 17.5 10.5996 17.5H7.40039C6.28869 17.5 5.42447 17.4999 4.73242 17.4434C4.12184 17.3935 3.61108 17.2979 3.15137 17.1006L2.95703 17.0098C2.21605 16.6322 1.59644 16.0576 1.16406 15.3525L0.990234 15.043C0.730378 14.5329 0.61366 13.9655 0.556641 13.2676C0.500118 12.5755 0.5 11.7113 0.5 10.5996V7.40039C0.5 6.28869 0.500118 5.42447 0.556641 4.73242C0.61366 4.03453 0.730378 3.46707 0.990234 2.95703L1.16406 2.64746C1.59644 1.94243 2.21605 1.36778 2.95703 0.990234L3.15137 0.899414C3.61108 0.702129 4.12184 0.606527 4.73242 0.556641C5.42447 0.500118 6.28869 0.5 7.40039 0.5H10.5996Z" stroke="white"/>
|
||||
<path d="M1 7.4C1 5.15979 1 4.03969 1.43597 3.18404C1.81947 2.43139 2.43139 1.81947 3.18404 1.43597C4.03969 1 5.15979 1 7.4 1H10.6C12.8402 1 13.9603 1 14.816 1.43597C15.5686 1.81947 16.1805 2.43139 16.564 3.18404C17 4.03969 17 5.15979 17 7.4V10.6C17 12.8402 17 13.9603 16.564 14.816C16.1805 15.5686 15.5686 16.1805 14.816 16.564C13.9603 17 12.8402 17 10.6 17H7.4C5.15979 17 4.03969 17 3.18404 16.564C2.43139 16.1805 1.81947 15.5686 1.43597 14.816C1 13.9603 1 12.8402 1 10.6V7.4Z" fill="#828DAD"/>
|
||||
<path d="M1 7.4C1 5.15979 1 4.03969 1.43597 3.18404C1.81947 2.43139 2.43139 1.81947 3.18404 1.43597C4.03969 1 5.15979 1 7.4 1H10.6C12.8402 1 13.9603 1 14.816 1.43597C15.5686 1.81947 16.1805 2.43139 16.564 3.18404C17 4.03969 17 5.15979 17 7.4V10.6C17 12.8402 17 13.9603 16.564 14.816C16.1805 15.5686 15.5686 16.1805 14.816 16.564C13.9603 17 12.8402 17 10.6 17H7.4C5.15979 17 4.03969 17 3.18404 16.564C2.43139 16.1805 1.81947 15.5686 1.43597 14.816C1 13.9603 1 12.8402 1 10.6V7.4Z" fill="url(#paint0_linear_5617_78238)"/>
|
||||
<path d="M7.40039 1.5H10.5996C11.728 1.5 12.5446 1.50029 13.1865 1.55273C13.7434 1.59824 14.1352 1.68127 14.4561 1.81934L14.5889 1.88184C15.1651 2.17543 15.6471 2.62172 15.9834 3.16992L16.1182 3.41113C16.2942 3.75672 16.3953 4.17741 16.4473 4.81348C16.4997 5.4554 16.5 6.27204 16.5 7.40039V10.5996C16.5 11.728 16.4997 12.5446 16.4473 13.1865C16.4018 13.7434 16.3187 14.1352 16.1807 14.4561L16.1182 14.5889C15.8246 15.1651 15.3783 15.6471 14.8301 15.9834L14.5889 16.1182C14.2433 16.2942 13.8226 16.3953 13.1865 16.4473C12.5446 16.4997 11.728 16.5 10.5996 16.5H7.40039C6.27204 16.5 5.4554 16.4997 4.81348 16.4473C4.2566 16.4018 3.8648 16.3187 3.54395 16.1807L3.41113 16.1182C2.83494 15.8246 2.35287 15.3783 2.0166 14.8301L1.88184 14.5889C1.70575 14.2433 1.60471 13.8226 1.55273 13.1865C1.50029 12.5446 1.5 11.728 1.5 10.5996V7.40039C1.5 6.27204 1.50029 5.4554 1.55273 4.81348C1.59824 4.2566 1.68127 3.8648 1.81934 3.54395L1.88184 3.41113C2.17543 2.83494 2.62172 2.35287 3.16992 2.0166L3.41113 1.88184C3.75672 1.70575 4.17741 1.60471 4.81348 1.55273C5.4554 1.50029 6.27204 1.5 7.40039 1.5Z" stroke="#101828" stroke-opacity="0.08"/>
|
||||
<g filter="url(#filter0_d_5617_78238)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M9 5.5C7.067 5.5 5.5 7.067 5.5 9C5.5 10.933 7.067 12.5 9 12.5C10.2949 12.5 11.4261 11.7971 12.032 10.7496C12.1703 10.5106 12.4762 10.4289 12.7152 10.5672C12.9542 10.7055 13.0359 11.0113 12.8977 11.2504C12.1204 12.5941 10.6662 13.5 9 13.5C6.68372 13.5 4.77619 11.75 4.52746 9.5H4C3.72386 9.5 3.5 9.27614 3.5 9C3.5 8.72386 3.72386 8.5 4 8.5H4.52746C4.77619 6.25002 6.68372 4.5 9 4.5C10.6662 4.5 12.1204 5.40588 12.8977 6.74964C13.0359 6.98867 12.9542 7.29454 12.7152 7.43281C12.4762 7.57107 12.1703 7.48939 12.032 7.25036C11.4261 6.20291 10.2949 5.5 9 5.5ZM9 7.5C8.17157 7.5 7.5 8.17157 7.5 9C7.5 9.82841 8.17158 10.5 9 10.5C9.82841 10.5 10.5 9.82841 10.5 9C10.5 8.17158 9.82841 7.5 9 7.5ZM6.5 9C6.5 7.61929 7.61929 6.5 9 6.5C10.2095 6.5 11.2184 7.35888 11.45 8.5H14C14.2761 8.5 14.5 8.72386 14.5 9C14.5 9.27614 14.2761 9.5 14 9.5H11.45C11.2183 10.6411 10.2095 11.5 9 11.5C7.61928 11.5 6.5 10.3807 6.5 9Z" fill="url(#paint1_linear_5617_78238)" shape-rendering="crispEdges"/>
|
||||
</g>
|
||||
<defs>
|
||||
<filter id="filter0_d_5617_78238" x="3" y="4.25" width="12" height="10" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
|
||||
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
|
||||
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
|
||||
<feOffset dy="0.25"/>
|
||||
<feGaussianBlur stdDeviation="0.25"/>
|
||||
<feComposite in2="hardAlpha" operator="out"/>
|
||||
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.2 0"/>
|
||||
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_5617_78238"/>
|
||||
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_5617_78238" result="shape"/>
|
||||
</filter>
|
||||
<linearGradient id="paint0_linear_5617_78238" x1="1" y1="1" x2="17" y2="17" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white" stop-opacity="0.12"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.08"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear_5617_78238" x1="9" y1="4.5" x2="9" y2="13.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0.9"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user