dify
This commit is contained in:
464
dify/web/app/components/app-sidebar/app-info.tsx
Normal file
464
dify/web/app/components/app-sidebar/app-info.tsx
Normal file
@@ -0,0 +1,464 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEditLine,
|
||||
RiEqualizer2Line,
|
||||
RiExchange2Line,
|
||||
RiFileCopy2Line,
|
||||
RiFileDownloadLine,
|
||||
RiFileUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
|
||||
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import type { EnvironmentVariable } from '@/app/components/workflow/types'
|
||||
import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
|
||||
import type { Operation } from './app-operations'
|
||||
import AppOperations from './app-operations'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
|
||||
ssr: false,
|
||||
})
|
||||
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
|
||||
ssr: false,
|
||||
})
|
||||
|
||||
export type IAppInfoProps = {
|
||||
expand: boolean
|
||||
onlyShowDetail?: boolean
|
||||
openState?: boolean
|
||||
onDetailExpand?: (expand: boolean) => void
|
||||
}
|
||||
|
||||
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const { replace } = useRouter()
|
||||
const { onPlanInfoChanged } = useProviderContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const [open, setOpen] = useState(openState)
|
||||
const [showEditModal, setShowEditModal] = useState(false)
|
||||
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
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,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
}) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const app = await updateAppInfo({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
description,
|
||||
use_icon_as_answer_icon,
|
||||
max_active_requests,
|
||||
})
|
||||
setShowEditModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.editDone'),
|
||||
})
|
||||
setAppDetail(app)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.editFailed') })
|
||||
}
|
||||
}, [appDetail, notify, setAppDetail, t])
|
||||
|
||||
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const newApp = await copyApp({
|
||||
appID: appDetail.id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode: appDetail.mode,
|
||||
})
|
||||
setShowDuplicateModal(false)
|
||||
notify({
|
||||
type: 'success',
|
||||
message: t('app.newApp.appCreated'),
|
||||
})
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
onPlanInfoChanged()
|
||||
getRedirection(true, newApp, replace)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onExport = async (include = false) => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const { data } = await exportAppConfig({
|
||||
appID: appDetail.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 = `${appDetail.name}.yml`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const exportCheck = async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
|
||||
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')
|
||||
if (list.length === 0) {
|
||||
onExport()
|
||||
return
|
||||
}
|
||||
setSecretEnvList(list)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.exportFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
await deleteApp(appDetail.id)
|
||||
notify({ type: 'success', message: t('app.appDeleted') })
|
||||
onPlanInfoChanged()
|
||||
setAppDetail()
|
||||
replace('/apps')
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: `${t('app.appDeleteFailed')}${'message' in e ? `: ${e.message}` : ''}`,
|
||||
})
|
||||
}
|
||||
setShowConfirmDelete(false)
|
||||
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
|
||||
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
|
||||
if (!appDetail)
|
||||
return null
|
||||
|
||||
const primaryOperations = [
|
||||
{
|
||||
id: 'edit',
|
||||
title: t('app.editApp'),
|
||||
icon: <RiEditLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowEditModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'duplicate',
|
||||
title: t('app.duplicate'),
|
||||
icon: <RiFileCopy2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowDuplicateModal(true)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'export',
|
||||
title: t('app.export'),
|
||||
icon: <RiFileDownloadLine />,
|
||||
onClick: exportCheck,
|
||||
},
|
||||
]
|
||||
|
||||
const secondaryOperations: Operation[] = [
|
||||
// Import DSL (conditional)
|
||||
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) ? [{
|
||||
id: 'import',
|
||||
title: t('workflow.common.importDSL'),
|
||||
icon: <RiFileUploadLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowImportDSLModal(true)
|
||||
},
|
||||
}] : [],
|
||||
// Divider
|
||||
{
|
||||
id: 'divider-1',
|
||||
title: '',
|
||||
icon: <></>,
|
||||
onClick: () => { /* divider has no action */ },
|
||||
type: 'divider' as const,
|
||||
},
|
||||
// Delete operation
|
||||
{
|
||||
id: 'delete',
|
||||
title: t('common.operation.delete'),
|
||||
icon: <RiDeleteBinLine />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowConfirmDelete(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// Keep the switch operation separate as it's not part of the main operations
|
||||
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) ? {
|
||||
id: 'switch',
|
||||
title: t('app.switch'),
|
||||
icon: <RiExchange2Line />,
|
||||
onClick: () => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
setShowSwitchModal(true)
|
||||
},
|
||||
} : null
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!onlyShowDetail && (
|
||||
<button type="button"
|
||||
onClick={() => {
|
||||
if (isCurrentWorkspaceEditor)
|
||||
setOpen(v => !v)
|
||||
}}
|
||||
className='block w-full'
|
||||
>
|
||||
<div className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className='ml-auto flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='flex h-5 w-5 items-center justify-center rounded-md p-0.5'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>
|
||||
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced')
|
||||
: appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent')
|
||||
: appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot')
|
||||
: appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion')
|
||||
: t('app.types.workflow')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
<ContentDialog
|
||||
show={onlyShowDetail ? openState : open}
|
||||
onClose={() => {
|
||||
setOpen(false)
|
||||
onDetailExpand?.(false)
|
||||
}}
|
||||
className='absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0'
|
||||
>
|
||||
<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'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<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 === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* description */}
|
||||
{appDetail.description && (
|
||||
<div className='system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary'>{appDetail.description}</div>
|
||||
)}
|
||||
{/* operations */}
|
||||
<AppOperations
|
||||
gap={4}
|
||||
primaryOperations={primaryOperations}
|
||||
secondaryOperations={secondaryOperations}
|
||||
/>
|
||||
</div>
|
||||
<CardView
|
||||
appId={appDetail.id}
|
||||
isInPanel={true}
|
||||
className='flex flex-1 flex-col gap-2 overflow-auto px-2 py-1'
|
||||
/>
|
||||
{/* Switch operation (if available) */}
|
||||
{switchOperation && (
|
||||
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2'>
|
||||
<Button
|
||||
size={'medium'}
|
||||
variant={'ghost'}
|
||||
className='gap-0.5'
|
||||
onClick={switchOperation.onClick}
|
||||
>
|
||||
{switchOperation.icon}
|
||||
<span className='system-sm-medium text-text-tertiary'>{switchOperation.title}</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</ContentDialog>
|
||||
{showSwitchModal && (
|
||||
<SwitchAppModal
|
||||
inAppDetail
|
||||
show={showSwitchModal}
|
||||
appDetail={appDetail}
|
||||
onClose={() => setShowSwitchModal(false)}
|
||||
onSuccess={() => setShowSwitchModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showEditModal && (
|
||||
<CreateAppModal
|
||||
isEditModal
|
||||
appName={appDetail.name}
|
||||
appIconType={appDetail.icon_type}
|
||||
appIcon={appDetail.icon}
|
||||
appIconBackground={appDetail.icon_background}
|
||||
appIconUrl={appDetail.icon_url}
|
||||
appDescription={appDetail.description}
|
||||
appMode={appDetail.mode}
|
||||
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
|
||||
max_active_requests={appDetail.max_active_requests ?? null}
|
||||
show={showEditModal}
|
||||
onConfirm={onEdit}
|
||||
onHide={() => setShowEditModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showDuplicateModal && (
|
||||
<DuplicateAppModal
|
||||
appName={appDetail.name}
|
||||
icon_type={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
icon_background={appDetail.icon_background}
|
||||
icon_url={appDetail.icon_url}
|
||||
show={showDuplicateModal}
|
||||
onConfirm={onCopy}
|
||||
onHide={() => setShowDuplicateModal(false)}
|
||||
/>
|
||||
)}
|
||||
{showConfirmDelete && (
|
||||
<Confirm
|
||||
title={t('app.deleteAppConfirmTitle')}
|
||||
content={t('app.deleteAppConfirmContent')}
|
||||
isShow={showConfirmDelete}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setShowConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
{showImportDSLModal && (
|
||||
<UpdateDSLModal
|
||||
onCancel={() => setShowImportDSLModal(false)}
|
||||
onBackup={exportCheck}
|
||||
/>
|
||||
)}
|
||||
{secretEnvList.length > 0 && (
|
||||
<DSLExportConfirmModal
|
||||
envList={secretEnvList}
|
||||
onConfirm={onExport}
|
||||
onClose={() => setSecretEnvList([])}
|
||||
/>
|
||||
)}
|
||||
{showExportWarning && (
|
||||
<Confirm
|
||||
type="info"
|
||||
isShow={showExportWarning}
|
||||
title={t('workflow.sidebar.exportWarning')}
|
||||
content={t('workflow.sidebar.exportWarningDesc')}
|
||||
onConfirm={handleConfirmExport}
|
||||
onCancel={() => setShowExportWarning(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppInfo)
|
||||
214
dify/web/app/components/app-sidebar/app-operations.tsx
Normal file
214
dify/web/app/components/app-sidebar/app-operations.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { JSX } from 'react'
|
||||
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
||||
import { RiMoreLine } from '@remixicon/react'
|
||||
|
||||
export type Operation = {
|
||||
id: string
|
||||
title: string
|
||||
icon: JSX.Element
|
||||
onClick: () => void
|
||||
type?: 'divider'
|
||||
}
|
||||
|
||||
type AppOperationsProps = {
|
||||
gap: number
|
||||
operations?: Operation[]
|
||||
primaryOperations?: Operation[]
|
||||
secondaryOperations?: Operation[]
|
||||
}
|
||||
|
||||
const EMPTY_OPERATIONS: Operation[] = []
|
||||
|
||||
const AppOperations = ({
|
||||
operations,
|
||||
primaryOperations,
|
||||
secondaryOperations,
|
||||
gap,
|
||||
}: AppOperationsProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([])
|
||||
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
|
||||
const [showMore, setShowMore] = useState(false)
|
||||
const navRef = useRef<HTMLDivElement>(null)
|
||||
const handleTriggerMore = useCallback(() => {
|
||||
setShowMore(true)
|
||||
}, [setShowMore])
|
||||
|
||||
const primaryOps = useMemo(() => {
|
||||
if (operations)
|
||||
return operations
|
||||
if (primaryOperations)
|
||||
return primaryOperations
|
||||
return EMPTY_OPERATIONS
|
||||
}, [operations, primaryOperations])
|
||||
|
||||
const secondaryOps = useMemo(() => {
|
||||
if (operations)
|
||||
return EMPTY_OPERATIONS
|
||||
if (secondaryOperations)
|
||||
return secondaryOperations
|
||||
return EMPTY_OPERATIONS
|
||||
}, [operations, secondaryOperations])
|
||||
const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider')
|
||||
|
||||
useEffect(() => {
|
||||
const applyState = (visible: Operation[], overflow: Operation[]) => {
|
||||
const combinedMore = [...overflow, ...secondaryOps]
|
||||
if (!overflow.length && combinedMore[0]?.type === 'divider')
|
||||
combinedMore.shift()
|
||||
setVisibleOperations(visible)
|
||||
setMoreOperations(combinedMore)
|
||||
}
|
||||
|
||||
const inline = primaryOps.filter(operation => operation.type !== 'divider')
|
||||
|
||||
if (!inline.length) {
|
||||
applyState([], [])
|
||||
return
|
||||
}
|
||||
|
||||
const navElement = navRef.current
|
||||
const moreElement = document.getElementById('more-measure')
|
||||
|
||||
if (!navElement || !moreElement)
|
||||
return
|
||||
|
||||
let width = 0
|
||||
const containerWidth = navElement.clientWidth
|
||||
const moreWidth = moreElement.clientWidth
|
||||
|
||||
if (containerWidth === 0 || moreWidth === 0)
|
||||
return
|
||||
|
||||
const updatedEntries: Record<string, boolean> = inline.reduce((pre, cur) => {
|
||||
pre[cur.id] = false
|
||||
return pre
|
||||
}, {} as Record<string, boolean>)
|
||||
const childrens = Array.from(navElement.children).slice(0, -1)
|
||||
for (let i = 0; i < childrens.length; i++) {
|
||||
const child = childrens[i] as HTMLElement
|
||||
const id = child.dataset.targetid
|
||||
if (!id) break
|
||||
const childWidth = child.clientWidth
|
||||
|
||||
if (width + gap + childWidth + moreWidth <= containerWidth) {
|
||||
updatedEntries[id] = true
|
||||
width += gap + childWidth
|
||||
}
|
||||
else {
|
||||
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
|
||||
updatedEntries[id] = true
|
||||
else
|
||||
updatedEntries[id] = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
const visible = inline.filter(item => updatedEntries[item.id])
|
||||
const overflow = inline.filter(item => !updatedEntries[item.id])
|
||||
|
||||
applyState(visible, overflow)
|
||||
}, [gap, primaryOps, secondaryOps])
|
||||
|
||||
const shouldShowMoreButton = moreOperations.length > 0
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
ref={navRef}
|
||||
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden"
|
||||
style={{ gap }}
|
||||
>
|
||||
{inlineOperations.map(operation => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
data-targetid={operation.id}
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
id="more-measure"
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{t('common.operation.more')}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}>
|
||||
{visibleOpreations.map(operation => (
|
||||
<Button
|
||||
key={operation.id}
|
||||
data-targetid={operation.id}
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
onClick={operation.onClick}
|
||||
>
|
||||
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{operation.title}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
{shouldShowMoreButton && (
|
||||
<PortalToFollowElem
|
||||
open={showMore}
|
||||
onOpenChange={setShowMore}
|
||||
placement="bottom-end"
|
||||
offset={{ mainAxis: 4 }}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
|
||||
<Button
|
||||
size={'small'}
|
||||
variant={'secondary'}
|
||||
className="gap-[1px]"
|
||||
>
|
||||
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
|
||||
<span className="system-xs-medium text-components-button-secondary-text">
|
||||
{t('common.operation.more')}
|
||||
</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className="z-[30]">
|
||||
<div className="flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
|
||||
{moreOperations.map(item => item.type === 'divider'
|
||||
? (
|
||||
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
|
||||
)
|
||||
: (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
|
||||
<span className="system-md-regular text-text-secondary">{item.title}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppOperations
|
||||
126
dify/web/app/components/app-sidebar/app-sidebar-dropdown.tsx
Normal file
126
dify/web/app/components/app-sidebar/app-sidebar-dropdown.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
import React, { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import {
|
||||
RiEqualizer2Line,
|
||||
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 AppInfo from './app-info'
|
||||
import NavLink from './navLink'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import type { NavIcon } from './navLink'
|
||||
import cn from '@/utils/classnames'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
type Props = {
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
}>
|
||||
}
|
||||
|
||||
const AppSidebarDropdown = ({ navigation }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const [detailExpand, setDetailExpand] = useState(false)
|
||||
|
||||
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])
|
||||
|
||||
if (!appDetail)
|
||||
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={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<RiMenuLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[1000]'>
|
||||
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
|
||||
<div className='p-2'>
|
||||
<div
|
||||
className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}
|
||||
onClick={() => {
|
||||
setDetailExpand(true)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between self-stretch'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className='flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-4'>
|
||||
<Divider bgStyle='gradient' />
|
||||
</div>
|
||||
<nav className='space-y-0.5 px-3 pb-6 pt-4'>
|
||||
{navigation.map((item, index) => {
|
||||
return (
|
||||
<NavLink key={index} mode='expand' iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</div>
|
||||
<div className='z-20'>
|
||||
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default AppSidebarDropdown
|
||||
96
dify/web/app/components/app-sidebar/basic.tsx
Normal file
96
dify/web/app/components/app-sidebar/basic.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import {
|
||||
ApiAggregate,
|
||||
WindowCursor,
|
||||
} from '@/app/components/base/icons/src/vender/workflow'
|
||||
|
||||
export type IAppBasicProps = {
|
||||
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
|
||||
icon?: string
|
||||
icon_background?: string | null
|
||||
isExternal?: boolean
|
||||
name: string
|
||||
type: string | React.ReactNode
|
||||
hoverTip?: string
|
||||
textStyle?: { main?: string; extra?: string }
|
||||
isExtraInLine?: boolean
|
||||
mode?: string
|
||||
hideType?: boolean
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
const NotionSvg = <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_6294_13848)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z" fill="white" />
|
||||
<path d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z" fill="black" />
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z" fill="black" />
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_6294_13848">
|
||||
<rect width="24" height="24" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
const ICON_MAP = {
|
||||
app: <AppIcon className='border !border-[rgba(0,0,0,0.05)]' />,
|
||||
api: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
|
||||
<ApiAggregate className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>,
|
||||
dataset: <AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />,
|
||||
webapp: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
|
||||
<WindowCursor className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>,
|
||||
notion: <AppIcon innerIcon={NotionSvg} className='!border-[0.5px] !border-indigo-100 !bg-white' />,
|
||||
}
|
||||
|
||||
export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app', hideType }: IAppBasicProps) {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="flex grow items-center">
|
||||
{icon && icon_background && iconType === 'app' && (
|
||||
<div className='mr-2 shrink-0'>
|
||||
<AppIcon icon={icon} background={icon_background} />
|
||||
</div>
|
||||
)}
|
||||
{iconType !== 'app'
|
||||
&& <div className='mr-2 shrink-0'>
|
||||
{ICON_MAP[iconType]}
|
||||
</div>
|
||||
|
||||
}
|
||||
{mode === 'expand' && <div className="group w-full">
|
||||
<div className={`system-md-semibold flex flex-row items-center text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}>
|
||||
<div className="min-w-0 overflow-hidden text-ellipsis break-normal">
|
||||
{name}
|
||||
</div>
|
||||
{hoverTip
|
||||
&& <Tooltip
|
||||
popupContent={
|
||||
<div className='w-[240px]'>
|
||||
{hoverTip}
|
||||
</div>
|
||||
}
|
||||
popupClassName='ml-1'
|
||||
triggerClassName='w-4 h-4 ml-1'
|
||||
position='top'
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{!hideType && isExtraInLine && (
|
||||
<div className="system-2xs-medium-uppercase flex text-text-tertiary">{type}</div>
|
||||
)}
|
||||
{!hideType && !isExtraInLine && (
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{isExternal ? t('dataset.externalTag') : type}</div>
|
||||
)}
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
BIN
dify/web/app/components/app-sidebar/completion.png
Normal file
BIN
dify/web/app/components/app-sidebar/completion.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
152
dify/web/app/components/app-sidebar/dataset-info/dropdown.tsx
Normal file
152
dify/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
dify/web/app/components/app-sidebar/dataset-info/index.tsx
Normal file
91
dify/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)
|
||||
@@ -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)
|
||||
56
dify/web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
56
dify/web/app/components/app-sidebar/dataset-info/menu.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
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'
|
||||
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
|
||||
|
||||
type MenuProps = {
|
||||
showDelete: boolean
|
||||
openRenameModal: () => void
|
||||
handleExportPipeline: () => void
|
||||
detectIsUsedByApp: () => void
|
||||
}
|
||||
|
||||
const Menu = ({
|
||||
showDelete,
|
||||
openRenameModal,
|
||||
handleExportPipeline,
|
||||
detectIsUsedByApp,
|
||||
}: MenuProps) => {
|
||||
const { t } = useTranslation()
|
||||
const runtimeMode = useDatasetDetailContextWithSelector(state => state.dataset?.runtime_mode)
|
||||
|
||||
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}
|
||||
/>
|
||||
{runtimeMode === 'rag_pipeline' && (
|
||||
<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
dify/web/app/components/app-sidebar/dataset-sidebar-dropdown.tsx
Normal file
164
dify/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
|
||||
BIN
dify/web/app/components/app-sidebar/expert.png
Normal file
BIN
dify/web/app/components/app-sidebar/expert.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
156
dify/web/app/components/app-sidebar/index.tsx
Normal file
156
dify/web/app/components/app-sidebar/index.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import NavLink from './navLink'
|
||||
import type { NavIcon } from './navLink'
|
||||
import AppInfo from './app-info'
|
||||
import DatasetInfo from './dataset-info'
|
||||
import AppSidebarDropdown from './app-sidebar-dropdown'
|
||||
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'
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
icon: NavIcon
|
||||
selectedIcon: NavIcon
|
||||
disabled?: boolean
|
||||
}>
|
||||
extraInfo?: (modeState: string) => React.ReactNode
|
||||
}
|
||||
|
||||
const AppDetailNav = ({
|
||||
navigation,
|
||||
extraInfo,
|
||||
iconType = 'app',
|
||||
}: IAppDetailNavProps) => {
|
||||
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
|
||||
appSidebarExpand: state.appSidebarExpand,
|
||||
setAppSidebarExpand: state.setAppSidebarExpand,
|
||||
})))
|
||||
const sidebarRef = React.useRef<HTMLDivElement>(null)
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const expand = appSidebarExpand === 'expand'
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
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()
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v?.type === 'workflow-canvas-maximize')
|
||||
setHideHeader(v.payload)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (appSidebarExpand) {
|
||||
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
|
||||
setAppSidebarExpand(appSidebarExpand)
|
||||
}
|
||||
}, [appSidebarExpand, setAppSidebarExpand])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
|
||||
e.preventDefault()
|
||||
handleToggle()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
if (inWorkflowCanvas && hideHeader) {
|
||||
return (
|
||||
<div className='flex w-0 shrink-0'>
|
||||
<AppSidebarDropdown navigation={navigation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isPipelineCanvas && hideHeader) {
|
||||
return (
|
||||
<div className='flex w-0 shrink-0'>
|
||||
<DatasetSidebarDropdown navigation={navigation} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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={cn(
|
||||
'shrink-0',
|
||||
expand ? 'p-2' : 'p-1',
|
||||
)}
|
||||
>
|
||||
{iconType === 'app' && (
|
||||
<AppInfo expand={expand} />
|
||||
)}
|
||||
{iconType !== 'app' && (
|
||||
<DatasetInfo expand={expand} />
|
||||
)}
|
||||
</div>
|
||||
<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={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}
|
||||
disabled={!!item.disabled}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(AppDetailNav)
|
||||
266
dify/web/app/components/app-sidebar/navLink.spec.tsx
Normal file
266
dify/web/app/components/app-sidebar/navLink.spec.tsx
Normal file
@@ -0,0 +1,266 @@
|
||||
import React from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
import NavLink from './navLink'
|
||||
import type { NavLinkProps } from './navLink'
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegment: () => 'overview',
|
||||
}))
|
||||
|
||||
// Mock Next.js Link component
|
||||
jest.mock('next/link', () => {
|
||||
return function MockLink({ children, href, className, title }: any) {
|
||||
return (
|
||||
<a href={href} className={className} title={title} data-testid="nav-link">
|
||||
{children}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Mock RemixIcon components
|
||||
const MockIcon = ({ className }: { className?: string }) => (
|
||||
<svg className={className} data-testid="nav-icon" />
|
||||
)
|
||||
|
||||
describe('NavLink Animation and Layout Issues', () => {
|
||||
const mockProps: NavLinkProps = {
|
||||
name: 'Orchestrate',
|
||||
href: '/app/123/workflow',
|
||||
iconMap: {
|
||||
selected: MockIcon,
|
||||
normal: MockIcon,
|
||||
},
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock getComputedStyle for transition testing
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
value: jest.fn((element) => {
|
||||
const isExpanded = element.getAttribute('data-mode') === 'expand'
|
||||
return {
|
||||
transition: 'all 0.3s ease',
|
||||
opacity: isExpanded ? '1' : '0',
|
||||
width: isExpanded ? 'auto' : '0px',
|
||||
overflow: 'hidden',
|
||||
paddingLeft: isExpanded ? '12px' : '10px', // px-3 vs px-2.5
|
||||
paddingRight: isExpanded ? '12px' : '10px',
|
||||
}
|
||||
}),
|
||||
writable: true,
|
||||
})
|
||||
})
|
||||
|
||||
describe('Text Squeeze Animation Issue', () => {
|
||||
it('should show text squeeze effect when switching from collapse to expand', async () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// In collapse mode, text should be in DOM but hidden via CSS
|
||||
const textElement = screen.getByText('Orchestrate')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('max-w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
|
||||
// Icon should still be present
|
||||
expect(screen.getByTestId('nav-icon')).toBeInTheDocument()
|
||||
|
||||
// Check consistent padding in collapse mode
|
||||
const linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// Switch to expand mode - should have smooth text transition
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Text should now be visible with opacity animation
|
||||
expect(screen.getByText('Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// Check padding remains consistent - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// 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')
|
||||
|
||||
// The fix provides:
|
||||
// - Opacity transition from 0 to 1
|
||||
// - Max-width transition from 0 to none (prevents squashing)
|
||||
// - No layout shift from consistent padding
|
||||
})
|
||||
|
||||
it('should maintain icon position consistency using wrapper div', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
const iconElement = screen.getByTestId('nav-icon')
|
||||
const iconWrapper = iconElement.parentElement
|
||||
|
||||
// Icon wrapper should have -ml-1 micro-adjustment in collapse mode for centering
|
||||
expect(iconWrapper).toHaveClass('-ml-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// In expand mode, wrapper should not have the micro-adjustment
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
expect(expandedIconWrapper).not.toHaveClass('-ml-1')
|
||||
|
||||
// Icon itself maintains consistent classes - no margin changes
|
||||
expect(iconElement).toHaveClass('h-4')
|
||||
expect(iconElement).toHaveClass('w-4')
|
||||
expect(iconElement).toHaveClass('shrink-0')
|
||||
|
||||
// This wrapper approach eliminates the icon margin shift issue
|
||||
})
|
||||
|
||||
it('should provide smooth text transition with max-width animation', () => {
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
// 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('max-w-0')
|
||||
expect(collapsedText).toHaveClass('overflow-hidden')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// 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')
|
||||
|
||||
// Fixed: Always present in DOM with smooth CSS transitions
|
||||
// instead of abrupt conditional rendering
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
// Consistent padding in collapsed state
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
rerender(<NavLink {...mockProps} mode="expand" />)
|
||||
|
||||
// Same padding in expanded state - no layout shift
|
||||
expect(linkElement).toHaveClass('pl-3')
|
||||
expect(linkElement).toHaveClass('pr-1')
|
||||
|
||||
// This consistency eliminates the layout shift issue
|
||||
})
|
||||
|
||||
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: 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" />)
|
||||
|
||||
const expandedIconWrapper = screen.getByTestId('nav-icon').parentElement
|
||||
|
||||
// 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')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Active State Handling', () => {
|
||||
it('should handle active state correctly in both modes', () => {
|
||||
// Test non-active state
|
||||
const { rerender } = render(<NavLink {...mockProps} mode="collapse" />)
|
||||
|
||||
let linkElement = screen.getByTestId('nav-link')
|
||||
expect(linkElement).not.toHaveClass('bg-components-menu-item-bg-active')
|
||||
|
||||
// Test with active state (when href matches current segment)
|
||||
const activeProps = {
|
||||
...mockProps,
|
||||
href: '/app/123/overview', // matches mocked segment
|
||||
}
|
||||
|
||||
rerender(<NavLink {...activeProps} mode="expand" />)
|
||||
|
||||
linkElement = screen.getByTestId('nav-link')
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
105
dify/web/app/components/app-sidebar/navLink.tsx
Normal file
105
dify/web/app/components/app-sidebar/navLink.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'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
|
||||
|
||||
export type NavLinkProps = {
|
||||
name: string
|
||||
href: string
|
||||
iconMap: {
|
||||
selected: NavIcon
|
||||
normal: NavIcon
|
||||
}
|
||||
mode?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const NavLink = ({
|
||||
name,
|
||||
href,
|
||||
iconMap,
|
||||
mode = 'expand',
|
||||
disabled = false,
|
||||
}: NavLinkProps) => {
|
||||
const segment = useSelectedLayoutSegment()
|
||||
const formattedSegment = (() => {
|
||||
let res = segment?.toLowerCase()
|
||||
// logs and annotations use the same nav
|
||||
if (res === 'annotations')
|
||||
res = 'logs'
|
||||
|
||||
return res
|
||||
})()
|
||||
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
|
||||
? '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 : ''}
|
||||
>
|
||||
{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>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(NavLink)
|
||||
@@ -0,0 +1,297 @@
|
||||
import React from 'react'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Simple Mock Components that reproduce the exact UI issues
|
||||
const MockNavLink = ({ name, mode }: { name: string; mode: string }) => {
|
||||
return (
|
||||
<a
|
||||
className={`
|
||||
group flex h-9 items-center rounded-md py-2 text-sm font-normal
|
||||
${mode === 'expand' ? 'px-3' : 'px-2.5'}
|
||||
`}
|
||||
data-testid={`nav-link-${name}`}
|
||||
data-mode={mode}
|
||||
>
|
||||
{/* Icon with inconsistent margin - reproduces issue #2 */}
|
||||
<svg
|
||||
className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}
|
||||
data-testid={`nav-icon-${name}`}
|
||||
/>
|
||||
{/* Text that appears/disappears abruptly - reproduces issue #2 */}
|
||||
{mode === 'expand' && <span data-testid={`nav-text-${name}`}>{name}</span>}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
const MockSidebarToggleButton = ({ expand, onToggle }: { expand: boolean; onToggle: () => void }) => {
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
|
||||
${expand ? 'w-[216px]' : 'w-14'}
|
||||
`}
|
||||
data-testid="sidebar-container"
|
||||
>
|
||||
{/* Top section with variable padding - reproduces issue #1 */}
|
||||
<div className={`shrink-0 ${expand ? 'p-2' : 'p-1'}`} data-testid="top-section">
|
||||
App Info Area
|
||||
</div>
|
||||
|
||||
{/* Navigation section - reproduces issue #2 */}
|
||||
<nav className={`grow space-y-1 ${expand ? 'p-4' : 'px-2.5 py-4'}`} data-testid="navigation">
|
||||
<MockNavLink name="Orchestrate" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="API Access" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Logs & Annotations" mode={expand ? 'expand' : 'collapse'} />
|
||||
<MockNavLink name="Monitoring" mode={expand ? 'expand' : 'collapse'} />
|
||||
</nav>
|
||||
|
||||
{/* Toggle button section with consistent padding - issue #1 FIXED */}
|
||||
<div
|
||||
className="shrink-0 px-4 py-3"
|
||||
data-testid="toggle-section"
|
||||
>
|
||||
<button type="button"
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center'
|
||||
onClick={onToggle}
|
||||
data-testid="toggle-button"
|
||||
>
|
||||
{expand ? '→' : '←'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const MockAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
return (
|
||||
<div data-testid="app-info" data-expand={expand}>
|
||||
<button type="button" className='block w-full'>
|
||||
{/* Container with layout mode switching - reproduces issue #3 */}
|
||||
<div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}>
|
||||
{/* Icon container with justify-between to flex-col switch - reproduces issue #3 */}
|
||||
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`} data-testid="icon-container">
|
||||
{/* Icon with size changes - reproduces issue #3 */}
|
||||
<div
|
||||
data-testid="app-icon"
|
||||
data-size={expand ? 'large' : 'small'}
|
||||
style={{
|
||||
width: expand ? '40px' : '24px',
|
||||
height: expand ? '40px' : '24px',
|
||||
backgroundColor: '#000',
|
||||
transition: 'all 0.3s ease', // This broad transition causes bounce
|
||||
}}
|
||||
>
|
||||
Icon
|
||||
</div>
|
||||
<div className='flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
⚙️
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Text that appears/disappears conditionally */}
|
||||
{expand && (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate text-text-secondary'>Test App</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>chatflow</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Sidebar Animation Issues Reproduction', () => {
|
||||
beforeEach(() => {
|
||||
// Mock getBoundingClientRect for position testing
|
||||
Element.prototype.getBoundingClientRect = jest.fn(() => ({
|
||||
width: 200,
|
||||
height: 40,
|
||||
x: 10,
|
||||
y: 10,
|
||||
left: 10,
|
||||
right: 210,
|
||||
top: 10,
|
||||
bottom: 50,
|
||||
toJSON: jest.fn(),
|
||||
}))
|
||||
})
|
||||
|
||||
describe('Issue #1: Toggle Button Position Movement - FIXED', () => {
|
||||
it('should verify consistent padding prevents button position shift', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />)
|
||||
|
||||
// Check collapsed state padding
|
||||
const toggleSection = screen.getByTestId('toggle-section')
|
||||
expect(toggleSection).toHaveClass('px-4') // Consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
|
||||
// Check expanded state padding - should be the same
|
||||
expect(toggleSection).toHaveClass('px-4') // Same consistent padding
|
||||
expect(toggleSection).not.toHaveClass('px-5')
|
||||
expect(toggleSection).not.toHaveClass('px-6')
|
||||
|
||||
// THE FIX: px-4 in both states prevents position movement
|
||||
console.log('✅ Issue #1 FIXED: Toggle button now has consistent padding')
|
||||
console.log(' - Before: px-4 (collapsed) vs px-6 (expanded) - 8px difference')
|
||||
console.log(' - After: px-4 (both states) - 0px difference')
|
||||
console.log(' - Result: No button position movement during transition')
|
||||
})
|
||||
|
||||
it('should verify sidebar width animation is working correctly', () => {
|
||||
const handleToggle = jest.fn()
|
||||
const { rerender } = render(<MockSidebarToggleButton expand={false} onToggle={handleToggle} />)
|
||||
|
||||
const container = screen.getByTestId('sidebar-container')
|
||||
|
||||
// Collapsed state
|
||||
expect(container).toHaveClass('w-14')
|
||||
expect(container).toHaveClass('transition-all')
|
||||
|
||||
// Expanded state
|
||||
rerender(<MockSidebarToggleButton expand={true} onToggle={handleToggle} />)
|
||||
expect(container).toHaveClass('w-[216px]')
|
||||
|
||||
console.log('✅ Sidebar width transition is properly configured')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #2: Navigation Text Squeeze Animation', () => {
|
||||
it('should reproduce text squeeze effect from padding and margin changes', () => {
|
||||
const { rerender } = render(<MockNavLink name="Orchestrate" mode="collapse" />)
|
||||
|
||||
const link = screen.getByTestId('nav-link-Orchestrate')
|
||||
const icon = screen.getByTestId('nav-icon-Orchestrate')
|
||||
|
||||
// Collapsed state checks
|
||||
expect(link).toHaveClass('px-2.5') // 10px padding
|
||||
expect(icon).toHaveClass('mr-0') // No margin
|
||||
expect(screen.queryByTestId('nav-text-Orchestrate')).not.toBeInTheDocument()
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<MockNavLink name="Orchestrate" mode="expand" />)
|
||||
|
||||
// Expanded state checks
|
||||
expect(link).toHaveClass('px-3') // 12px padding (+2px)
|
||||
expect(icon).toHaveClass('mr-2') // 8px margin (+8px)
|
||||
expect(screen.getByTestId('nav-text-Orchestrate')).toBeInTheDocument()
|
||||
|
||||
// THE BUG: Multiple simultaneous changes create squeeze effect
|
||||
console.log('🐛 Issue #2 Reproduced: Text squeeze effect from multiple layout changes')
|
||||
console.log(' - Link padding: px-2.5 → px-3 (+2px)')
|
||||
console.log(' - Icon margin: mr-0 → mr-2 (+8px)')
|
||||
console.log(' - Text appears: none → visible (abrupt)')
|
||||
console.log(' - Result: Text appears with squeeze effect due to layout shifts')
|
||||
})
|
||||
|
||||
it('should document the abrupt text rendering issue', () => {
|
||||
const { rerender } = render(<MockNavLink name="API Access" mode="collapse" />)
|
||||
|
||||
// Text completely absent
|
||||
expect(screen.queryByTestId('nav-text-API Access')).not.toBeInTheDocument()
|
||||
|
||||
rerender(<MockNavLink name="API Access" mode="expand" />)
|
||||
|
||||
// Text suddenly appears - no transition
|
||||
expect(screen.getByTestId('nav-text-API Access')).toBeInTheDocument()
|
||||
|
||||
console.log('🐛 Issue #2 Detail: Conditional rendering {mode === "expand" && name}')
|
||||
console.log(' - Problem: Text appears/disappears abruptly without transition')
|
||||
console.log(' - Should use: opacity or width transition for smooth appearance')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Issue #3: App Icon Bounce Animation', () => {
|
||||
it('should reproduce icon bounce from layout mode switching', () => {
|
||||
const { rerender } = render(<MockAppInfo expand={true} />)
|
||||
|
||||
const iconContainer = screen.getByTestId('icon-container')
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
|
||||
// Expanded state layout
|
||||
expect(iconContainer).toHaveClass('justify-between')
|
||||
expect(iconContainer).not.toHaveClass('flex-col')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'large')
|
||||
|
||||
// Switch to collapsed state
|
||||
rerender(<MockAppInfo expand={false} />)
|
||||
|
||||
// Collapsed state layout - completely different layout mode
|
||||
expect(iconContainer).toHaveClass('flex-col')
|
||||
expect(iconContainer).toHaveClass('gap-1')
|
||||
expect(iconContainer).not.toHaveClass('justify-between')
|
||||
expect(appIcon).toHaveAttribute('data-size', 'small')
|
||||
|
||||
// THE BUG: Layout mode switch causes icon to "bounce"
|
||||
console.log('🐛 Issue #3 Reproduced: Icon bounce from layout mode switching')
|
||||
console.log(' - Layout change: justify-between → flex-col gap-1')
|
||||
console.log(' - Icon size: large (40px) → small (24px)')
|
||||
console.log(' - Transition: transition-all causes excessive animation')
|
||||
console.log(' - Result: Icon appears to bounce to right then back during collapse')
|
||||
})
|
||||
|
||||
it('should identify the problematic transition-all property', () => {
|
||||
render(<MockAppInfo expand={true} />)
|
||||
|
||||
const appIcon = screen.getByTestId('app-icon')
|
||||
const computedStyle = window.getComputedStyle(appIcon)
|
||||
|
||||
// The problematic broad transition
|
||||
expect(computedStyle.transition).toContain('all')
|
||||
|
||||
console.log('🐛 Issue #3 Detail: transition-all affects ALL CSS properties')
|
||||
console.log(' - Problem: Animates layout properties that should not transition')
|
||||
console.log(' - Solution: Use specific transition properties instead of "all"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Interactive Toggle Test', () => {
|
||||
it('should demonstrate all issues in a single interactive test', () => {
|
||||
let expanded = false
|
||||
const handleToggle = () => {
|
||||
expanded = !expanded
|
||||
}
|
||||
|
||||
const { rerender } = render(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const toggleButton = screen.getByTestId('toggle-button')
|
||||
|
||||
// Initial state verification
|
||||
expect(expanded).toBe(false)
|
||||
console.log('🔄 Starting interactive test - all issues will be reproduced')
|
||||
|
||||
// Simulate toggle click
|
||||
fireEvent.click(toggleButton)
|
||||
expanded = true
|
||||
rerender(
|
||||
<div data-testid="complete-sidebar">
|
||||
<MockSidebarToggleButton expand={expanded} onToggle={handleToggle} />
|
||||
<MockAppInfo expand={expanded} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
console.log('✨ All three issues successfully reproduced in interactive test:')
|
||||
console.log(' 1. Toggle button position movement (padding inconsistency)')
|
||||
console.log(' 2. Navigation text squeeze effect (multiple layout changes)')
|
||||
console.log(' 3. App icon bounce animation (layout mode switching)')
|
||||
})
|
||||
})
|
||||
})
|
||||
11
dify/web/app/components/app-sidebar/style.module.css
Normal file
11
dify/web/app/components/app-sidebar/style.module.css
Normal file
@@ -0,0 +1,11 @@
|
||||
.sidebar {
|
||||
border-right: 1px solid #F3F4F6;
|
||||
}
|
||||
|
||||
.completionPic {
|
||||
background-image: url('./completion.png')
|
||||
}
|
||||
|
||||
.expertPic {
|
||||
background-image: url('./expert.png')
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Text Squeeze Fix Verification Test
|
||||
* This test verifies that the CSS-based text rendering fixes work correctly
|
||||
*/
|
||||
|
||||
import React from 'react'
|
||||
import { render } from '@testing-library/react'
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Next.js navigation
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSelectedLayoutSegment: () => 'overview',
|
||||
}))
|
||||
|
||||
// Mock classnames utility
|
||||
jest.mock('@/utils/classnames', () => ({
|
||||
__esModule: true,
|
||||
default: (...classes: any[]) => classes.filter(Boolean).join(' '),
|
||||
}))
|
||||
|
||||
// Simplified NavLink component to test the fix
|
||||
const TestNavLink = ({ mode }: { mode: 'expand' | 'collapse' }) => {
|
||||
const name = 'Orchestrate'
|
||||
|
||||
return (
|
||||
<div className="nav-link-container">
|
||||
<div className={`flex h-9 items-center rounded-md py-2 text-sm font-normal ${
|
||||
mode === 'expand' ? 'px-3' : 'px-2.5'
|
||||
}`}>
|
||||
<div className={`h-4 w-4 shrink-0 ${mode === 'expand' ? 'mr-2' : 'mr-0'}`}>
|
||||
Icon
|
||||
</div>
|
||||
<span
|
||||
className={`whitespace-nowrap transition-all duration-200 ease-in-out ${
|
||||
mode === 'expand'
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="nav-text"
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Simplified AppInfo component to test the fix
|
||||
const TestAppInfo = ({ expand }: { expand: boolean }) => {
|
||||
const appDetail = {
|
||||
name: 'Test ChatBot App',
|
||||
mode: 'chat' as const,
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="app-info-container">
|
||||
<div className={`flex rounded-lg ${expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1'}`}>
|
||||
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
|
||||
<div className="app-icon">AppIcon</div>
|
||||
<div className="dashboard-icon">Dashboard</div>
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-1 transition-all duration-200 ease-in-out ${
|
||||
expand
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0'
|
||||
}`}
|
||||
data-testid="app-text-container"
|
||||
>
|
||||
<div className='flex w-full'>
|
||||
<div
|
||||
className='system-md-semibold truncate whitespace-nowrap text-text-secondary'
|
||||
data-testid="app-name"
|
||||
>
|
||||
{appDetail.name}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'
|
||||
data-testid="app-type"
|
||||
>
|
||||
ChatBot
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
describe('Text Squeeze Fix Verification', () => {
|
||||
describe('NavLink Text Rendering Fix', () => {
|
||||
it('should keep text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
// In collapsed state, text should be in DOM but hidden
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toBeInTheDocument()
|
||||
expect(textElement).toHaveClass('opacity-0')
|
||||
expect(textElement).toHaveClass('w-0')
|
||||
expect(textElement).toHaveClass('overflow-hidden')
|
||||
expect(textElement).toHaveClass('pointer-events-none')
|
||||
expect(textElement).toHaveClass('whitespace-nowrap')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
|
||||
console.log('✅ NavLink Collapsed State:')
|
||||
console.log(' - Text is in DOM but visually hidden')
|
||||
console.log(' - Uses opacity-0 and w-0 for hiding')
|
||||
console.log(' - Has whitespace-nowrap to prevent wrapping')
|
||||
console.log(' - Has transition-all for smooth animation')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestNavLink mode="expand" />)
|
||||
|
||||
const expandedText = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(expandedText).toBeInTheDocument()
|
||||
expect(expandedText).toHaveClass('opacity-100')
|
||||
expect(expandedText).toHaveClass('w-auto')
|
||||
expect(expandedText).not.toHaveClass('pointer-events-none')
|
||||
|
||||
console.log('✅ NavLink Expanded State:')
|
||||
console.log(' - Text is visible with opacity-100')
|
||||
console.log(' - Uses w-auto for natural width')
|
||||
console.log(' - No layout jumps during transition')
|
||||
|
||||
console.log('🎯 NavLink Fix Result: Text squeeze effect ELIMINATED')
|
||||
})
|
||||
|
||||
it('should verify smooth transition properties', () => {
|
||||
const { container } = render(<TestNavLink mode="collapse" />)
|
||||
|
||||
const textElement = container.querySelector('[data-testid="nav-text"]')
|
||||
expect(textElement).toHaveClass('transition-all')
|
||||
expect(textElement).toHaveClass('duration-200')
|
||||
expect(textElement).toHaveClass('ease-in-out')
|
||||
|
||||
console.log('✅ Transition Properties Verified:')
|
||||
console.log(' - transition-all: Smooth property changes')
|
||||
console.log(' - duration-200: 200ms transition time')
|
||||
console.log(' - ease-in-out: Smooth easing function')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AppInfo Text Rendering Fix', () => {
|
||||
it('should keep app text in DOM and use CSS transitions', () => {
|
||||
const { container, rerender } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
// In collapsed state, text container should be in DOM but hidden
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toBeInTheDocument()
|
||||
expect(textContainer).toHaveClass('opacity-0')
|
||||
expect(textContainer).toHaveClass('w-0')
|
||||
expect(textContainer).toHaveClass('overflow-hidden')
|
||||
expect(textContainer).toHaveClass('pointer-events-none')
|
||||
|
||||
// Text elements should still be in DOM
|
||||
const appName = container.querySelector('[data-testid="app-name"]')
|
||||
const appType = container.querySelector('[data-testid="app-type"]')
|
||||
expect(appName).toBeInTheDocument()
|
||||
expect(appType).toBeInTheDocument()
|
||||
expect(appName).toHaveClass('whitespace-nowrap')
|
||||
expect(appType).toHaveClass('whitespace-nowrap')
|
||||
|
||||
console.log('✅ AppInfo Collapsed State:')
|
||||
console.log(' - Text container is in DOM but visually hidden')
|
||||
console.log(' - App name and type elements always present')
|
||||
console.log(' - Uses whitespace-nowrap to prevent wrapping')
|
||||
|
||||
// Switch to expanded state
|
||||
rerender(<TestAppInfo expand={true} />)
|
||||
|
||||
const expandedContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(expandedContainer).toBeInTheDocument()
|
||||
expect(expandedContainer).toHaveClass('opacity-100')
|
||||
expect(expandedContainer).toHaveClass('w-auto')
|
||||
expect(expandedContainer).not.toHaveClass('pointer-events-none')
|
||||
|
||||
console.log('✅ AppInfo Expanded State:')
|
||||
console.log(' - Text container is visible with opacity-100')
|
||||
console.log(' - Uses w-auto for natural width')
|
||||
console.log(' - No layout jumps during transition')
|
||||
|
||||
console.log('🎯 AppInfo Fix Result: Text squeeze effect ELIMINATED')
|
||||
})
|
||||
|
||||
it('should verify transition properties on text container', () => {
|
||||
const { container } = render(<TestAppInfo expand={false} />)
|
||||
|
||||
const textContainer = container.querySelector('[data-testid="app-text-container"]')
|
||||
expect(textContainer).toHaveClass('transition-all')
|
||||
expect(textContainer).toHaveClass('duration-200')
|
||||
expect(textContainer).toHaveClass('ease-in-out')
|
||||
|
||||
console.log('✅ AppInfo Transition Properties Verified:')
|
||||
console.log(' - Container has smooth CSS transitions')
|
||||
console.log(' - Same 200ms duration as NavLink for consistency')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Fix Strategy Comparison', () => {
|
||||
it('should document the fix strategy differences', () => {
|
||||
console.log('\n📋 TEXT SQUEEZE FIX STRATEGY COMPARISON')
|
||||
console.log('='.repeat(60))
|
||||
|
||||
console.log('\n❌ BEFORE (Problematic):')
|
||||
console.log(' NavLink: {mode === "expand" && name}')
|
||||
console.log(' AppInfo: {expand && (<div>...</div>)}')
|
||||
console.log(' Problem: Conditional rendering causes abrupt appearance')
|
||||
console.log(' Result: Text "squeezes" from center during layout changes')
|
||||
|
||||
console.log('\n✅ AFTER (Fixed):')
|
||||
console.log(' NavLink: <span className="opacity-0 w-0">{name}</span>')
|
||||
console.log(' AppInfo: <div className="opacity-0 w-0">...</div>')
|
||||
console.log(' Solution: CSS controls visibility, element always in DOM')
|
||||
console.log(' Result: Smooth opacity and width transitions')
|
||||
|
||||
console.log('\n🎯 KEY FIX PRINCIPLES:')
|
||||
console.log(' 1. ✅ Always keep text elements in DOM')
|
||||
console.log(' 2. ✅ Use opacity for show/hide transitions')
|
||||
console.log(' 3. ✅ Use width (w-0/w-auto) for layout control')
|
||||
console.log(' 4. ✅ Add whitespace-nowrap to prevent wrapping')
|
||||
console.log(' 5. ✅ Use pointer-events-none when hidden')
|
||||
console.log(' 6. ✅ Add overflow-hidden for clean hiding')
|
||||
|
||||
console.log('\n🚀 BENEFITS:')
|
||||
console.log(' - No more abrupt text appearance')
|
||||
console.log(' - Smooth 200ms transitions')
|
||||
console.log(' - No layout jumps or shifts')
|
||||
console.log(' - Consistent animation timing')
|
||||
console.log(' - Better user experience')
|
||||
|
||||
// Always pass documentation test
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
71
dify/web/app/components/app-sidebar/toggle-button.tsx
Normal file
71
dify/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)
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
Answer = 'answer',
|
||||
}
|
||||
type Props = {
|
||||
type: EditItemType
|
||||
content: string
|
||||
onChange: (content: string) => void
|
||||
}
|
||||
|
||||
const EditItem: FC<Props> = ({
|
||||
type,
|
||||
content,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const avatar = type === EditItemType.Query ? <User className='h-6 w-6' /> : <Robot className='h-6 w-6' />
|
||||
const name = type === EditItemType.Query ? t('appAnnotation.addModal.queryName') : t('appAnnotation.addModal.answerName')
|
||||
const placeholder = type === EditItemType.Query ? t('appAnnotation.addModal.queryPlaceholder') : t('appAnnotation.addModal.answerPlaceholder')
|
||||
|
||||
return (
|
||||
<div className='flex' onClick={e => e.stopPropagation()}>
|
||||
<div className='mr-3 shrink-0'>
|
||||
{avatar}
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-xs-semibold mb-1 text-text-primary'>{name}</div>
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(EditItem)
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import EditItem, { EditItemType } from './edit-item'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onAdd: (payload: AnnotationItemBasic) => void
|
||||
}
|
||||
|
||||
const AddAnnotationModal: FC<Props> = ({
|
||||
isShow,
|
||||
onHide,
|
||||
onAdd,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const [question, setQuestion] = useState('')
|
||||
const [answer, setAnswer] = useState('')
|
||||
const [isCreateNext, setIsCreateNext] = useState(false)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
|
||||
const isValid = (payload: AnnotationItemBasic) => {
|
||||
if (!payload.question)
|
||||
return t('appAnnotation.errorMessage.queryRequired')
|
||||
|
||||
if (!payload.answer)
|
||||
return t('appAnnotation.errorMessage.answerRequired')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
const payload = {
|
||||
question,
|
||||
answer,
|
||||
}
|
||||
if (isValid(payload) !== true) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: isValid(payload) as string,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setIsSaving(true)
|
||||
try {
|
||||
await onAdd(payload)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
setIsSaving(false)
|
||||
|
||||
if (isCreateNext) {
|
||||
setQuestion('')
|
||||
setAnswer('')
|
||||
}
|
||||
else {
|
||||
onHide()
|
||||
}
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<Drawer
|
||||
isShow={isShow}
|
||||
onHide={onHide}
|
||||
maxWidthClassName='!max-w-[480px]'
|
||||
title={t('appAnnotation.addModal.title') as string}
|
||||
body={(
|
||||
<div className='space-y-6 p-6 pb-4'>
|
||||
<EditItem
|
||||
type={EditItemType.Query}
|
||||
content={question}
|
||||
onChange={setQuestion}
|
||||
/>
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content={answer}
|
||||
onChange={setAnswer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
foot={
|
||||
(
|
||||
<div>
|
||||
{isAnnotationFull && (
|
||||
<div className='mb-4 mt-6 px-6'>
|
||||
<AnnotationFull />
|
||||
</div>
|
||||
)}
|
||||
<div className='system-sm-medium flex h-16 items-center justify-between rounded-bl-xl rounded-br-xl border-t border-divider-subtle bg-background-section-burn px-4 text-text-tertiary'>
|
||||
<div
|
||||
className='flex items-center space-x-2'
|
||||
>
|
||||
<Checkbox checked={isCreateNext} onCheck={() => setIsCreateNext(!isCreateNext)} />
|
||||
<div>{t('appAnnotation.addModal.createNext')}</div>
|
||||
</div>
|
||||
<div className='mt-2 flex space-x-2'>
|
||||
<Button className='h-7 text-xs' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button className='h-7 text-xs' variant='primary' onClick={handleSave} loading={isSaving} disabled={isAnnotationFull}>{t('common.operation.add')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
>
|
||||
</Drawer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AddAnnotationModal)
|
||||
79
dify/web/app/components/app/annotation/batch-action.tsx
Normal file
79
dify/web/app/components/app/annotation/batch-action.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React, { type FC } from 'react'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import classNames from '@/utils/classnames'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
const i18nPrefix = 'appAnnotation.batchAction'
|
||||
|
||||
type IBatchActionProps = {
|
||||
className?: string
|
||||
selectedIds: string[]
|
||||
onBatchDelete: () => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const BatchAction: FC<IBatchActionProps> = ({
|
||||
className,
|
||||
selectedIds,
|
||||
onBatchDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowDeleteConfirm, {
|
||||
setTrue: showDeleteConfirm,
|
||||
setFalse: hideDeleteConfirm,
|
||||
}] = useBoolean(false)
|
||||
const [isDeleting, {
|
||||
setTrue: setIsDeleting,
|
||||
setFalse: setIsNotDeleting,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setIsDeleting()
|
||||
await onBatchDelete()
|
||||
hideDeleteConfirm()
|
||||
setIsNotDeleting()
|
||||
}
|
||||
return (
|
||||
<div className={classNames('pointer-events-none flex w-full justify-center', className)}>
|
||||
<div className='pointer-events-auto flex items-center gap-x-1 rounded-[10px] border border-components-actionbar-border-accent bg-components-actionbar-bg-accent p-1 shadow-xl shadow-shadow-shadow-5 backdrop-blur-[5px]'>
|
||||
<div className='inline-flex items-center gap-x-2 py-1 pl-2 pr-3'>
|
||||
<span className='flex h-5 w-5 items-center justify-center rounded-md bg-text-accent px-1 py-0.5 text-xs font-medium text-text-primary-on-surface'>
|
||||
{selectedIds.length}
|
||||
</span>
|
||||
<span className='text-[13px] font-semibold leading-[16px] text-text-accent'>{t(`${i18nPrefix}.selected`)}</span>
|
||||
</div>
|
||||
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
|
||||
<div className='flex cursor-pointer items-center gap-x-0.5 px-3 py-2' onClick={showDeleteConfirm}>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-components-button-destructive-ghost-text' />
|
||||
<button type='button' className='px-0.5 text-[13px] font-medium leading-[16px] text-components-button-destructive-ghost-text' >
|
||||
{t('common.operation.delete')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Divider type='vertical' className='mx-0.5 h-3.5 bg-divider-regular' />
|
||||
<button type='button' className='px-3.5 py-2 text-[13px] font-medium leading-[16px] text-components-button-ghost-text' onClick={onCancel}>
|
||||
{t('common.operation.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{
|
||||
isShowDeleteConfirm && (
|
||||
<Confirm
|
||||
isShow
|
||||
title={t('appAnnotation.list.delete.title')}
|
||||
confirmText={t('common.operation.delete')}
|
||||
onConfirm={handleBatchDelete}
|
||||
onCancel={hideDeleteConfirm}
|
||||
isLoading={isDeleting}
|
||||
isDisabled={isDeleting}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(BatchAction)
|
||||
@@ -0,0 +1,73 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import {
|
||||
useCSVDownloader,
|
||||
} from 'react-papaparse'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { Download02 as DownloadIcon } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
|
||||
const CSV_TEMPLATE_QA_EN = [
|
||||
['question', 'answer'],
|
||||
['question1', 'answer1'],
|
||||
['question2', 'answer2'],
|
||||
]
|
||||
const CSV_TEMPLATE_QA_CN = [
|
||||
['问题', '答案'],
|
||||
['问题 1', '答案 1'],
|
||||
['问题 2', '答案 2'],
|
||||
]
|
||||
|
||||
const CSVDownload: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { locale } = useContext(I18n)
|
||||
const { CSVDownloader, Type } = useCSVDownloader()
|
||||
|
||||
const getTemplate = () => {
|
||||
return locale !== LanguagesSupported[1] ? CSV_TEMPLATE_QA_EN : CSV_TEMPLATE_QA_CN
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='mt-6'>
|
||||
<div className='system-sm-medium text-text-primary'>{t('share.generation.csvStructureTitle')}</div>
|
||||
<div className='mt-2 max-h-[500px] overflow-auto'>
|
||||
<table className='w-full table-fixed border-separate border-spacing-0 rounded-lg border border-divider-regular text-xs'>
|
||||
<thead className='text-text-tertiary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-regular pl-3 pr-2'>{t('appAnnotation.batchModal.question')}</td>
|
||||
<td className='h-9 border-b border-divider-regular pl-3 pr-2'>{t('appAnnotation.batchModal.answer')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className='text-text-secondary'>
|
||||
<tr>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.question')} 1</td>
|
||||
<td className='h-9 border-b border-divider-subtle pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.answer')} 1</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.question')} 2</td>
|
||||
<td className='h-9 pl-3 pr-2 text-[13px]'>{t('appAnnotation.batchModal.answer')} 2</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<CSVDownloader
|
||||
className="mt-2 block cursor-pointer"
|
||||
type={Type.Link}
|
||||
filename={`template-${locale}`}
|
||||
bom={true}
|
||||
data={getTemplate()}
|
||||
>
|
||||
<div className='system-xs-medium flex h-[18px] items-center space-x-1 text-text-accent'>
|
||||
<DownloadIcon className='mr-1 h-3 w-3' />
|
||||
{t('appAnnotation.batchModal.template')}
|
||||
</div>
|
||||
</CSVDownloader>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(CSVDownload)
|
||||
@@ -0,0 +1,128 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Csv as CSVIcon } from '@/app/components/base/icons/src/public/files'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type Props = {
|
||||
file: File | undefined
|
||||
updateFile: (file?: File) => void
|
||||
}
|
||||
|
||||
const CSVUploader: FC<Props> = ({
|
||||
file,
|
||||
updateFile,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [dragging, setDragging] = useState(false)
|
||||
const dropRef = useRef<HTMLDivElement>(null)
|
||||
const dragRef = useRef<HTMLDivElement>(null)
|
||||
const fileUploader = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleDragEnter = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target !== dragRef.current)
|
||||
setDragging(true)
|
||||
}
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
}
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
if (e.target === dragRef.current)
|
||||
setDragging(false)
|
||||
}
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
setDragging(false)
|
||||
if (!e.dataTransfer)
|
||||
return
|
||||
const files = [...e.dataTransfer.files]
|
||||
if (files.length > 1) {
|
||||
notify({ type: 'error', message: t('datasetCreation.stepOne.uploader.validation.count') })
|
||||
return
|
||||
}
|
||||
updateFile(files[0])
|
||||
}
|
||||
const selectHandle = () => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.click()
|
||||
}
|
||||
const removeFile = () => {
|
||||
if (fileUploader.current)
|
||||
fileUploader.current.value = ''
|
||||
updateFile()
|
||||
}
|
||||
const fileChangeHandle = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const currentFile = e.target.files?.[0]
|
||||
updateFile(currentFile)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
dropRef.current?.addEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.addEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.addEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.addEventListener('drop', handleDrop)
|
||||
return () => {
|
||||
dropRef.current?.removeEventListener('dragenter', handleDragEnter)
|
||||
dropRef.current?.removeEventListener('dragover', handleDragOver)
|
||||
dropRef.current?.removeEventListener('dragleave', handleDragLeave)
|
||||
dropRef.current?.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mt-6'>
|
||||
<input
|
||||
ref={fileUploader}
|
||||
style={{ display: 'none' }}
|
||||
type="file"
|
||||
id="fileUploader"
|
||||
accept='.csv'
|
||||
onChange={fileChangeHandle}
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
{!file && (
|
||||
<div className={cn('system-sm-regular flex h-20 items-center rounded-xl border border-dashed border-components-dropzone-border bg-components-dropzone-bg', dragging && 'border border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
|
||||
<div className='flex w-full items-center justify-center space-x-2'>
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className='text-text-tertiary'>
|
||||
{t('appAnnotation.batchModal.csvUploadTitle')}
|
||||
<span className='cursor-pointer text-text-accent' onClick={selectHandle}>{t('appAnnotation.batchModal.browse')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
</div>
|
||||
)}
|
||||
{file && (
|
||||
<div className={cn('group flex h-20 items-center rounded-xl border border-components-panel-border bg-components-panel-bg px-6 text-sm font-normal', 'hover:border-components-panel-bg-blur hover:bg-components-panel-bg-blur')}>
|
||||
<CSVIcon className="shrink-0" />
|
||||
<div className='ml-2 flex w-0 grow'>
|
||||
<span className='max-w-[calc(100%_-_30px)] overflow-hidden text-ellipsis whitespace-nowrap text-text-primary'>{file.name.replace(/.csv$/, '')}</span>
|
||||
<span className='shrink-0 text-text-tertiary'>.csv</span>
|
||||
</div>
|
||||
<div className='hidden items-center group-hover:flex'>
|
||||
<Button variant='secondary' onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
|
||||
<div className='mx-2 h-4 w-px bg-divider-regular' />
|
||||
<div className='cursor-pointer p-2' onClick={removeFile}>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(CSVUploader)
|
||||
@@ -0,0 +1,124 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import CSVUploader from './csv-uploader'
|
||||
import CSVDownloader from './csv-downloader'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { annotationBatchImport, checkAnnotationBatchImportProgress } from '@/service/annotation'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export enum ProcessStatus {
|
||||
WAITING = 'waiting',
|
||||
PROCESSING = 'processing',
|
||||
COMPLETED = 'completed',
|
||||
ERROR = 'error',
|
||||
}
|
||||
|
||||
export type IBatchModalProps = {
|
||||
appId: string
|
||||
isShow: boolean
|
||||
onCancel: () => void
|
||||
onAdded: () => void
|
||||
}
|
||||
|
||||
const BatchModal: FC<IBatchModalProps> = ({
|
||||
appId,
|
||||
isShow,
|
||||
onCancel,
|
||||
onAdded,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const [currentCSV, setCurrentCSV] = useState<File>()
|
||||
const handleFile = (file?: File) => setCurrentCSV(file)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow)
|
||||
setCurrentCSV(undefined)
|
||||
}, [isShow])
|
||||
|
||||
const [importStatus, setImportStatus] = useState<ProcessStatus | string>()
|
||||
const notify = Toast.notify
|
||||
const checkProcess = async (jobID: string) => {
|
||||
try {
|
||||
const res = await checkAnnotationBatchImportProgress({ jobID, appId })
|
||||
setImportStatus(res.job_status)
|
||||
if (res.job_status === ProcessStatus.WAITING || res.job_status === ProcessStatus.PROCESSING)
|
||||
setTimeout(() => checkProcess(res.job_id), 2500)
|
||||
if (res.job_status === ProcessStatus.ERROR)
|
||||
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}` })
|
||||
if (res.job_status === ProcessStatus.COMPLETED) {
|
||||
notify({ type: 'success', message: `${t('appAnnotation.batchModal.completed')}` })
|
||||
onAdded()
|
||||
onCancel()
|
||||
}
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}${'message' in e ? `: ${e.message}` : ''}` })
|
||||
}
|
||||
}
|
||||
|
||||
const runBatch = async (csv: File) => {
|
||||
const formData = new FormData()
|
||||
formData.append('file', csv)
|
||||
try {
|
||||
const res = await annotationBatchImport({
|
||||
url: `/apps/${appId}/annotations/batch-import`,
|
||||
body: formData,
|
||||
})
|
||||
setImportStatus(res.job_status)
|
||||
checkProcess(res.job_id)
|
||||
}
|
||||
catch (e: any) {
|
||||
notify({ type: 'error', message: `${t('appAnnotation.batchModal.runError')}${'message' in e ? `: ${e.message}` : ''}` })
|
||||
}
|
||||
}
|
||||
|
||||
const handleSend = () => {
|
||||
if (!currentCSV)
|
||||
return
|
||||
runBatch(currentCSV)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal isShow={isShow} onClose={noop} className='!max-w-[520px] !rounded-xl px-8 py-6'>
|
||||
<div className='system-xl-medium relative pb-1 text-text-primary'>{t('appAnnotation.batchModal.title')}</div>
|
||||
<div className='absolute right-4 top-4 cursor-pointer p-2' onClick={onCancel}>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<CSVUploader
|
||||
file={currentCSV}
|
||||
updateFile={handleFile}
|
||||
/>
|
||||
<CSVDownloader />
|
||||
|
||||
{isAnnotationFull && (
|
||||
<div className='mt-4'>
|
||||
<AnnotationFull />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className='mt-[28px] flex justify-end pt-6'>
|
||||
<Button className='system-sm-medium mr-2 text-text-tertiary' onClick={onCancel}>
|
||||
{t('appAnnotation.batchModal.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSend}
|
||||
disabled={isAnnotationFull || !currentCSV}
|
||||
loading={importStatus === ProcessStatus.PROCESSING || importStatus === ProcessStatus.WAITING}
|
||||
>
|
||||
{t('appAnnotation.batchModal.run')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default React.memo(BatchModal)
|
||||
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const ClearAllAnnotationsConfirmModal: FC<Props> = ({
|
||||
isShow,
|
||||
onHide,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Confirm
|
||||
isShow={isShow}
|
||||
onCancel={onHide}
|
||||
onConfirm={onConfirm}
|
||||
type='danger'
|
||||
title={t('appAnnotation.table.header.clearAllConfirm')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ClearAllAnnotationsConfirmModal)
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
Answer = 'answer',
|
||||
}
|
||||
type Props = {
|
||||
type: EditItemType
|
||||
content: string
|
||||
readonly?: boolean
|
||||
onSave: (content: string) => Promise<void>
|
||||
}
|
||||
|
||||
export const EditTitle: FC<{ className?: string; title: string }> = ({ className, title }) => (
|
||||
<div className={cn(className, 'system-xs-medium flex h-[18px] items-center text-text-tertiary')}>
|
||||
<RiEditFill className='mr-1 h-3.5 w-3.5' />
|
||||
<div>{title}</div>
|
||||
<div
|
||||
className='ml-2 h-px grow'
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, rgba(0, 0, 0, 0.05) -1.65%, rgba(0, 0, 0, 0.00) 100%)',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
const EditItem: FC<Props> = ({
|
||||
type,
|
||||
readonly,
|
||||
content,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [newContent, setNewContent] = useState('')
|
||||
const showNewContent = newContent && newContent !== content
|
||||
const avatar = type === EditItemType.Query ? <User className='h-6 w-6' /> : <Robot className='h-6 w-6' />
|
||||
const name = type === EditItemType.Query ? t('appAnnotation.editModal.queryName') : t('appAnnotation.editModal.answerName')
|
||||
const editTitle = type === EditItemType.Query ? t('appAnnotation.editModal.yourQuery') : t('appAnnotation.editModal.yourAnswer')
|
||||
const placeholder = type === EditItemType.Query ? t('appAnnotation.editModal.queryPlaceholder') : t('appAnnotation.editModal.answerPlaceholder')
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
|
||||
// Reset newContent when content prop changes
|
||||
useEffect(() => {
|
||||
setNewContent('')
|
||||
}, [content])
|
||||
|
||||
const handleSave = async () => {
|
||||
await onSave(newContent)
|
||||
setIsEdit(false)
|
||||
}
|
||||
|
||||
const handleCancel = () => {
|
||||
setNewContent('')
|
||||
setIsEdit(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='flex' onClick={e => e.stopPropagation()}>
|
||||
<div className='mr-3 shrink-0'>
|
||||
{avatar}
|
||||
</div>
|
||||
<div className='grow'>
|
||||
<div className='system-xs-semibold mb-1 text-text-primary'>{name}</div>
|
||||
<div className='system-sm-regular text-text-primary'>{content}</div>
|
||||
{!isEdit
|
||||
? (
|
||||
<div>
|
||||
{showNewContent && (
|
||||
<div className='mt-3'>
|
||||
<EditTitle title={editTitle} />
|
||||
<div className='system-sm-regular mt-1 text-text-primary'>{newContent}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className='mt-2 flex items-center'>
|
||||
{!readonly && (
|
||||
<div
|
||||
className='system-xs-medium flex cursor-pointer items-center space-x-1 text-text-accent'
|
||||
onClick={() => {
|
||||
setIsEdit(true)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className='mr-1 h-3.5 w-3.5' />
|
||||
<div>{t('common.operation.edit')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showNewContent && (
|
||||
<div className='system-xs-medium ml-2 flex items-center text-text-tertiary'>
|
||||
<div className='mr-2'>·</div>
|
||||
<div
|
||||
className='flex cursor-pointer items-center space-x-1'
|
||||
onClick={() => {
|
||||
setNewContent(content)
|
||||
onSave(content)
|
||||
}}
|
||||
>
|
||||
<div className='h-3.5 w-3.5'>
|
||||
<RiDeleteBinLine className='h-3.5 w-3.5' />
|
||||
</div>
|
||||
<div>{t('common.operation.delete')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='mt-3'>
|
||||
<EditTitle title={editTitle} />
|
||||
<Textarea
|
||||
value={newContent}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
<div className='mt-2 flex space-x-2'>
|
||||
<Button size='small' variant='primary' onClick={handleSave}>{t('common.operation.save')}</Button>
|
||||
<Button size='small' onClick={handleCancel}>{t('common.operation.cancel')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(EditItem)
|
||||
@@ -0,0 +1,146 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EditItem, { EditItemType } from './edit-item'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import { addAnnotation, editAnnotation } from '@/service/annotation'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AnnotationFull from '@/app/components/billing/annotation-full'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
appId: string
|
||||
messageId?: string
|
||||
annotationId?: string
|
||||
query: string
|
||||
answer: string
|
||||
onEdited: (editedQuery: string, editedAnswer: string) => void
|
||||
onAdded: (annotationId: string, authorName: string, editedQuery: string, editedAnswer: string) => void
|
||||
createdAt?: number
|
||||
onRemove: () => void
|
||||
onlyEditResponse?: boolean
|
||||
}
|
||||
|
||||
const EditAnnotationModal: FC<Props> = ({
|
||||
isShow,
|
||||
onHide,
|
||||
query,
|
||||
answer,
|
||||
onEdited,
|
||||
onAdded,
|
||||
appId,
|
||||
messageId,
|
||||
annotationId,
|
||||
createdAt,
|
||||
onRemove,
|
||||
onlyEditResponse,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAdd = !annotationId
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const handleSave = async (type: EditItemType, editedContent: string) => {
|
||||
let postQuery = query
|
||||
let postAnswer = answer
|
||||
if (type === EditItemType.Query)
|
||||
postQuery = editedContent
|
||||
else
|
||||
postAnswer = editedContent
|
||||
if (!isAdd) {
|
||||
await editAnnotation(appId, annotationId, {
|
||||
message_id: messageId,
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
})
|
||||
onEdited(postQuery, postAnswer)
|
||||
}
|
||||
else {
|
||||
const res: any = await addAnnotation(appId, {
|
||||
question: postQuery,
|
||||
answer: postAnswer,
|
||||
message_id: messageId,
|
||||
})
|
||||
onAdded(res.id, res.account?.name, postQuery, postAnswer)
|
||||
}
|
||||
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess') as string,
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Drawer
|
||||
isShow={isShow}
|
||||
onHide={onHide}
|
||||
maxWidthClassName='!max-w-[480px]'
|
||||
title={t('appAnnotation.editModal.title') as string}
|
||||
body={(
|
||||
<div>
|
||||
<div className='space-y-6 p-6 pb-4'>
|
||||
<EditItem
|
||||
type={EditItemType.Query}
|
||||
content={query}
|
||||
readonly={(isAdd && isAnnotationFull) || onlyEditResponse}
|
||||
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
|
||||
/>
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content={answer}
|
||||
readonly={isAdd && isAnnotationFull}
|
||||
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
|
||||
/>
|
||||
<Confirm
|
||||
isShow={showModal}
|
||||
onCancel={() => setShowModal(false)}
|
||||
onConfirm={() => {
|
||||
onRemove()
|
||||
setShowModal(false)
|
||||
onHide()
|
||||
}}
|
||||
title={t('appDebug.feature.annotation.removeConfirm')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
foot={
|
||||
<div>
|
||||
{isAnnotationFull && (
|
||||
<div className='mb-4 mt-6 px-6'>
|
||||
<AnnotationFull />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{
|
||||
annotationId
|
||||
? (
|
||||
<div className='system-sm-medium flex h-16 items-center justify-between rounded-bl-xl rounded-br-xl border-t border-divider-subtle bg-background-section-burn px-4 text-text-tertiary'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center space-x-2 pl-3'
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<MessageCheckRemove />
|
||||
<div>{t('appAnnotation.editModal.removeThisCache')}</div>
|
||||
</div>
|
||||
{createdAt && <div>{t('appAnnotation.editModal.createdAt')} {formatTime(createdAt, t('appLog.dateTimeFormat') as string)}</div>}
|
||||
</div>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(EditAnnotationModal)
|
||||
26
dify/web/app/components/app/annotation/empty-element.tsx
Normal file
26
dify/web/app/components/app/annotation/empty-element.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
import type { FC, SVGProps } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ThreeDotsIcon = ({ className }: SVGProps<SVGElement>) => {
|
||||
return <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className={className ?? ''}>
|
||||
<path d="M5 6.5V5M8.93934 7.56066L10 6.5M10.0103 11.5H11.5103" stroke="#374151" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
}
|
||||
|
||||
const EmptyElement: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex h-full items-center justify-center'>
|
||||
<div className='box-border h-fit w-[560px] rounded-2xl bg-background-section-burn px-5 py-4'>
|
||||
<span className='system-md-semibold text-text-secondary'>{t('appAnnotation.noData.title')}<ThreeDotsIcon className='relative -left-1.5 -top-3 inline' /></span>
|
||||
<div className='system-sm-regular mt-2 text-text-tertiary'>
|
||||
{t('appAnnotation.noData.description')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(EmptyElement)
|
||||
48
dify/web/app/components/app/annotation/filter.tsx
Normal file
48
dify/web/app/components/app/annotation/filter.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import useSWR from 'swr'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { fetchAnnotationsCount } from '@/service/log'
|
||||
|
||||
export type QueryParam = {
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
type IFilterProps = {
|
||||
appId: string
|
||||
queryParams: QueryParam
|
||||
setQueryParams: (v: QueryParam) => void
|
||||
children: React.JSX.Element
|
||||
}
|
||||
|
||||
const Filter: FC<IFilterProps> = ({
|
||||
appId,
|
||||
queryParams,
|
||||
setQueryParams,
|
||||
children,
|
||||
}) => {
|
||||
// TODO: change fetch list api
|
||||
const { data } = useSWR({ url: `/apps/${appId}/annotations/count` }, fetchAnnotationsCount)
|
||||
const { t } = useTranslation()
|
||||
if (!data)
|
||||
return null
|
||||
return (
|
||||
<div className='mb-2 flex flex-row flex-wrap items-center justify-between gap-2'>
|
||||
<Input
|
||||
wrapperClassName='w-[200px]'
|
||||
showLeftIcon
|
||||
showClearIcon
|
||||
value={queryParams.keyword}
|
||||
placeholder={t('common.operation.search')!}
|
||||
onChange={(e) => {
|
||||
setQueryParams({ ...queryParams, keyword: e.target.value })
|
||||
}}
|
||||
onClear={() => setQueryParams({ ...queryParams, keyword: '' })}
|
||||
/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Filter)
|
||||
211
dify/web/app/components/app/annotation/header-opts/index.tsx
Normal file
211
dify/web/app/components/app/annotation/header-opts/index.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { Fragment, useEffect, useState } from 'react'
|
||||
import ClearAllAnnotationsConfirmModal from '../clear-all-annotations-confirm-modal'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiDeleteBinLine,
|
||||
RiMoreFill,
|
||||
} from '@remixicon/react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import {
|
||||
useCSVDownloader,
|
||||
} from 'react-papaparse'
|
||||
import { Menu, MenuButton, MenuItems, Transition } from '@headlessui/react'
|
||||
import Button from '../../../base/button'
|
||||
import AddAnnotationModal from '../add-annotation-modal'
|
||||
import type { AnnotationItemBasic } from '../type'
|
||||
import BatchAddModal from '../batch-add-annotation-modal'
|
||||
import cn from '@/utils/classnames'
|
||||
import CustomPopover from '@/app/components/base/popover'
|
||||
import { FileDownload02, FilePlus02 } from '@/app/components/base/icons/src/vender/line/files'
|
||||
import { ChevronRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
|
||||
import I18n from '@/context/i18n'
|
||||
import { fetchExportAnnotationList } from '@/service/annotation'
|
||||
import { clearAllAnnotations } from '@/service/annotation'
|
||||
import { LanguagesSupported } from '@/i18n-config/language'
|
||||
|
||||
const CSV_HEADER_QA_EN = ['Question', 'Answer']
|
||||
const CSV_HEADER_QA_CN = ['问题', '答案']
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
onAdd: (payload: AnnotationItemBasic) => void
|
||||
onAdded: () => void
|
||||
controlUpdateList: number
|
||||
}
|
||||
|
||||
const HeaderOptions: FC<Props> = ({
|
||||
appId,
|
||||
onAdd,
|
||||
onAdded,
|
||||
controlUpdateList,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const { CSVDownloader, Type } = useCSVDownloader()
|
||||
const [list, setList] = useState<AnnotationItemBasic[]>([])
|
||||
const annotationUnavailable = list.length === 0
|
||||
|
||||
const listTransformer = (list: AnnotationItemBasic[]) => list.map(
|
||||
(item: AnnotationItemBasic) => {
|
||||
const dataString = `{"messages": [{"role": "system", "content": ""}, {"role": "user", "content": ${JSON.stringify(item.question)}}, {"role": "assistant", "content": ${JSON.stringify(item.answer)}}]}`
|
||||
return dataString
|
||||
},
|
||||
)
|
||||
|
||||
const JSONLOutput = () => {
|
||||
const a = document.createElement('a')
|
||||
const content = listTransformer(list).join('\n')
|
||||
const file = new Blob([content], { type: 'application/jsonl' })
|
||||
const url = URL.createObjectURL(file)
|
||||
a.href = url
|
||||
a.download = `annotations-${locale}.jsonl`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const fetchList = async () => {
|
||||
const { data }: any = await fetchExportAnnotationList(appId)
|
||||
setList(data as AnnotationItemBasic[])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchList()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
if (controlUpdateList)
|
||||
fetchList()
|
||||
}, [controlUpdateList])
|
||||
|
||||
const [showBulkImportModal, setShowBulkImportModal] = useState(false)
|
||||
const [showClearConfirm, setShowClearConfirm] = useState(false)
|
||||
const handleClearAll = () => {
|
||||
setShowClearConfirm(true)
|
||||
}
|
||||
const handleConfirmed = async () => {
|
||||
try {
|
||||
await clearAllAnnotations(appId)
|
||||
onAdded()
|
||||
}
|
||||
catch (e) {
|
||||
console.error(`failed to clear all annotations, ${e}`)
|
||||
}
|
||||
finally {
|
||||
setShowClearConfirm(false)
|
||||
}
|
||||
}
|
||||
const Operations = () => {
|
||||
return (
|
||||
<div className="w-full py-1">
|
||||
<button type="button" className='mx-1 flex h-9 w-[calc(100%_-_8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50' onClick={() => {
|
||||
setShowBulkImportModal(true)
|
||||
}}>
|
||||
<FilePlus02 className='h-4 w-4 text-text-tertiary' />
|
||||
<span className='system-sm-regular grow text-left text-text-secondary'>{t('appAnnotation.table.header.bulkImport')}</span>
|
||||
</button>
|
||||
<Menu as="div" className="relative h-full w-full">
|
||||
<MenuButton className='mx-1 flex h-9 w-[calc(100%_-_8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50'>
|
||||
<FileDownload02 className='h-4 w-4 text-text-tertiary' />
|
||||
<span className='system-sm-regular grow text-left text-text-secondary'>{t('appAnnotation.table.header.bulkExport')}</span>
|
||||
<ChevronRight className='h-[14px] w-[14px] shrink-0 text-text-tertiary' />
|
||||
</MenuButton>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems
|
||||
className={cn(
|
||||
'absolute left-1 top-[1px] z-10 min-w-[100px] origin-top-right -translate-x-full rounded-xl border-[0.5px] border-components-panel-on-panel-item-bg bg-components-panel-bg py-1 shadow-xs',
|
||||
)}
|
||||
>
|
||||
<CSVDownloader
|
||||
type={Type.Link}
|
||||
filename={`annotations-${locale}`}
|
||||
bom={true}
|
||||
data={[
|
||||
locale !== LanguagesSupported[1] ? CSV_HEADER_QA_EN : CSV_HEADER_QA_CN,
|
||||
...list.map(item => [item.question, item.answer]),
|
||||
]}
|
||||
>
|
||||
<button type="button" disabled={annotationUnavailable} className='mx-1 flex h-9 w-[calc(100%_-_8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50'>
|
||||
<span className='system-sm-regular grow text-left text-text-secondary'>CSV</span>
|
||||
</button>
|
||||
</CSVDownloader>
|
||||
<button type="button" disabled={annotationUnavailable} className={cn('mx-1 flex h-9 w-[calc(100%_-_8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 hover:bg-components-panel-on-panel-item-bg-hover disabled:opacity-50', '!border-0')} onClick={JSONLOutput}>
|
||||
<span className='system-sm-regular grow text-left text-text-secondary'>JSONL</span>
|
||||
</button>
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
<button type="button"
|
||||
onClick={handleClearAll}
|
||||
className='mx-1 flex h-9 w-[calc(100%_-_8px)] cursor-pointer items-center space-x-2 rounded-lg px-3 py-2 text-red-600 hover:bg-red-50 disabled:opacity-50'
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
<span className='system-sm-regular grow text-left'>
|
||||
{t('appAnnotation.table.header.clearAll')}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const [showAddModal, setShowAddModal] = React.useState(false)
|
||||
|
||||
return (
|
||||
<div className='flex space-x-2'>
|
||||
<Button variant='primary' onClick={() => setShowAddModal(true)}>
|
||||
<RiAddLine className='mr-0.5 h-4 w-4' />
|
||||
<div>{t('appAnnotation.table.header.addAnnotation')}</div>
|
||||
</Button>
|
||||
<CustomPopover
|
||||
htmlContent={<Operations />}
|
||||
position="br"
|
||||
trigger="click"
|
||||
btnElement={
|
||||
<RiMoreFill className='h-4 w-4' />
|
||||
}
|
||||
btnClassName='btn btn-secondary btn-medium w-8 p-0'
|
||||
className={'!z-20 h-fit !w-[155px]'}
|
||||
popupClassName='!w-full !overflow-visible'
|
||||
manualClose
|
||||
/>
|
||||
{showAddModal && (
|
||||
<AddAnnotationModal
|
||||
isShow={showAddModal}
|
||||
onHide={() => setShowAddModal(false)}
|
||||
onAdd={onAdd}
|
||||
/>
|
||||
)}
|
||||
|
||||
{
|
||||
showBulkImportModal && (
|
||||
<BatchAddModal
|
||||
appId={appId}
|
||||
isShow={showBulkImportModal}
|
||||
onCancel={() => setShowBulkImportModal(false)}
|
||||
onAdded={onAdded}
|
||||
/>
|
||||
)
|
||||
}
|
||||
{
|
||||
showClearConfirm && (
|
||||
<ClearAllAnnotationsConfirmModal
|
||||
isShow={showClearConfirm}
|
||||
onHide={() => setShowClearConfirm(false)}
|
||||
onConfirm={handleConfirmed}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(HeaderOptions)
|
||||
277
dify/web/app/components/app/annotation/index.tsx
Normal file
277
dify/web/app/components/app/annotation/index.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { RiEqualizer2Line } from '@remixicon/react'
|
||||
import Toast from '../../base/toast'
|
||||
import Filter from './filter'
|
||||
import type { QueryParam } from './filter'
|
||||
import List from './list'
|
||||
import EmptyElement from './empty-element'
|
||||
import HeaderOpts from './header-opts'
|
||||
import { AnnotationEnableStatus, type AnnotationItem, type AnnotationItemBasic, JobStatus } from './type'
|
||||
import ViewAnnotationModal from './view-annotation-modal'
|
||||
import { MessageFast } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { addAnnotation, delAnnotation, fetchAnnotationConfig as doFetchAnnotationConfig, editAnnotation, fetchAnnotationList, queryAnnotationJobStatus, updateAnnotationScore, updateAnnotationStatus } from '@/service/annotation'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import ConfigParamModal from '@/app/components/base/features/new-feature-panel/annotation-reply/config-param-modal'
|
||||
import type { AnnotationReplyConfig } from '@/models/debug'
|
||||
import { sleep } from '@/utils'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import AnnotationFullModal from '@/app/components/billing/annotation-full/modal'
|
||||
import { type App, AppModeEnum } from '@/types/app'
|
||||
import cn from '@/utils/classnames'
|
||||
import { delAnnotations } from '@/service/annotation'
|
||||
|
||||
type Props = {
|
||||
appDetail: App
|
||||
}
|
||||
|
||||
const Annotation: FC<Props> = (props) => {
|
||||
const { appDetail } = props
|
||||
const { t } = useTranslation()
|
||||
const [isShowEdit, setIsShowEdit] = useState(false)
|
||||
const [annotationConfig, setAnnotationConfig] = useState<AnnotationReplyConfig | null>(null)
|
||||
const [isChatApp] = useState(appDetail.mode !== AppModeEnum.COMPLETION)
|
||||
const [controlRefreshSwitch, setControlRefreshSwitch] = useState(() => Date.now())
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({})
|
||||
const [currPage, setCurrPage] = useState(0)
|
||||
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
|
||||
const [list, setList] = useState<AnnotationItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [controlUpdateList, setControlUpdateList] = useState(() => Date.now())
|
||||
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
|
||||
const [isShowViewModal, setIsShowViewModal] = useState(false)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
|
||||
const fetchAnnotationConfig = async () => {
|
||||
const res = await doFetchAnnotationConfig(appDetail.id)
|
||||
setAnnotationConfig(res as AnnotationReplyConfig)
|
||||
return (res as AnnotationReplyConfig).id
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatApp) fetchAnnotationConfig()
|
||||
}, [])
|
||||
|
||||
const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
|
||||
while (true) {
|
||||
const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId)
|
||||
if (res.job_status === JobStatus.completed) break
|
||||
await sleep(2000)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchList = async (page = 1) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { data, total }: any = await fetchAnnotationList(appDetail.id, {
|
||||
page,
|
||||
limit,
|
||||
keyword: debouncedQueryParams.keyword || '',
|
||||
})
|
||||
setList(data as AnnotationItem[])
|
||||
setTotal(total)
|
||||
}
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchList(currPage + 1)
|
||||
}, [currPage, limit, debouncedQueryParams])
|
||||
|
||||
const handleAdd = async (payload: AnnotationItemBasic) => {
|
||||
await addAnnotation(appDetail.id, payload)
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
await delAnnotation(appDetail.id, id)
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
try {
|
||||
await delAnnotations(appDetail.id, selectedIds)
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
setSelectedIds([])
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: e.message || t('common.api.actionFailed') })
|
||||
}
|
||||
}
|
||||
|
||||
const handleView = (item: AnnotationItem) => {
|
||||
setCurrItem(item)
|
||||
setIsShowViewModal(true)
|
||||
}
|
||||
|
||||
const handleSave = async (question: string, answer: string) => {
|
||||
if (!currItem) return
|
||||
await editAnnotation(appDetail.id, currItem.id, { question, answer })
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShowEdit) setControlRefreshSwitch(Date.now())
|
||||
}, [isShowEdit])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<p className='system-sm-regular text-text-tertiary'>{t('appLog.description')}</p>
|
||||
<div className='relative flex h-full flex-1 flex-col py-4'>
|
||||
<Filter appId={appDetail.id} queryParams={queryParams} setQueryParams={setQueryParams}>
|
||||
<div className='flex items-center space-x-2'>
|
||||
{isChatApp && (
|
||||
<>
|
||||
<div className={cn(!annotationConfig?.enabled && 'pr-2', 'flex h-7 items-center space-x-1 rounded-lg border border-components-panel-border bg-components-panel-bg-blur pl-2')}>
|
||||
<MessageFast className='h-4 w-4 text-util-colors-indigo-indigo-600' />
|
||||
<div className='system-sm-medium text-text-primary'>{t('appAnnotation.name')}</div>
|
||||
<Switch
|
||||
key={controlRefreshSwitch}
|
||||
defaultValue={annotationConfig?.enabled}
|
||||
size='md'
|
||||
onChange={async (value) => {
|
||||
if (value) {
|
||||
if (isAnnotationFull) {
|
||||
setIsShowAnnotationFullModal(true)
|
||||
setControlRefreshSwitch(Date.now())
|
||||
return
|
||||
}
|
||||
setIsShowEdit(true)
|
||||
}
|
||||
else {
|
||||
const { job_id: jobId }: any = await updateAnnotationStatus(appDetail.id, AnnotationEnableStatus.disable, annotationConfig?.embedding_model, annotationConfig?.score_threshold)
|
||||
await ensureJobCompleted(jobId, AnnotationEnableStatus.disable)
|
||||
await fetchAnnotationConfig()
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
}
|
||||
}}
|
||||
></Switch>
|
||||
{annotationConfig?.enabled && (
|
||||
<div className='flex items-center pl-1.5'>
|
||||
<div className='mr-1 h-3.5 w-[1px] shrink-0 bg-divider-subtle'></div>
|
||||
<ActionButton onClick={() => setIsShowEdit(true)}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='mx-3 h-3.5 w-[1px] shrink-0 bg-divider-regular'></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<HeaderOpts
|
||||
appId={appDetail.id}
|
||||
controlUpdateList={controlUpdateList}
|
||||
onAdd={handleAdd}
|
||||
onAdded={() => {
|
||||
fetchList()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Filter>
|
||||
{isLoading
|
||||
? <Loading type='app' />
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: total > 0
|
||||
? <List
|
||||
list={list}
|
||||
onRemove={handleRemove}
|
||||
onView={handleView}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={setSelectedIds}
|
||||
onBatchDelete={handleBatchDelete}
|
||||
onCancel={() => setSelectedIds([])}
|
||||
/>
|
||||
: <div className='flex h-full grow items-center justify-center'><EmptyElement /></div>
|
||||
}
|
||||
{/* Show Pagination only if the total is more than the limit */}
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? <Pagination
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
limit={limit}
|
||||
onLimitChange={setLimit}
|
||||
/>
|
||||
: null}
|
||||
|
||||
{isShowViewModal && (
|
||||
<ViewAnnotationModal
|
||||
appId={appDetail.id}
|
||||
isShow={isShowViewModal}
|
||||
onHide={() => setIsShowViewModal(false)}
|
||||
onRemove={async () => {
|
||||
await handleRemove((currItem as AnnotationItem)?.id)
|
||||
}}
|
||||
item={currItem as AnnotationItem}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
)}
|
||||
{isShowEdit && (
|
||||
<ConfigParamModal
|
||||
appId={appDetail.id}
|
||||
isShow
|
||||
isInit={!annotationConfig?.enabled}
|
||||
onHide={() => {
|
||||
setIsShowEdit(false)
|
||||
}}
|
||||
onSave={async (embeddingModel, score) => {
|
||||
if (
|
||||
embeddingModel.embedding_model_name !== annotationConfig?.embedding_model?.embedding_model_name
|
||||
|| embeddingModel.embedding_provider_name !== annotationConfig?.embedding_model?.embedding_provider_name
|
||||
) {
|
||||
const { job_id: jobId }: any = await updateAnnotationStatus(appDetail.id, AnnotationEnableStatus.enable, embeddingModel, score)
|
||||
await ensureJobCompleted(jobId, AnnotationEnableStatus.enable)
|
||||
}
|
||||
const annotationId = await fetchAnnotationConfig()
|
||||
if (score !== annotationConfig?.score_threshold)
|
||||
await updateAnnotationScore(appDetail.id, annotationId, score)
|
||||
|
||||
await fetchAnnotationConfig()
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
setIsShowEdit(false)
|
||||
}}
|
||||
annotationConfig={annotationConfig!}
|
||||
/>
|
||||
)}
|
||||
{
|
||||
isShowAnnotationFullModal && (
|
||||
<AnnotationFullModal
|
||||
show={isShowAnnotationFullModal}
|
||||
onHide={() => setIsShowAnnotationFullModal(false)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Annotation)
|
||||
150
dify/web/app/components/app/annotation/list.tsx
Normal file
150
dify/web/app/components/app/annotation/list.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiDeleteBinLine, RiEditLine } from '@remixicon/react'
|
||||
import type { AnnotationItem } from './type'
|
||||
import RemoveAnnotationConfirmModal from './remove-annotation-confirm-modal'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import cn from '@/utils/classnames'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import BatchAction from './batch-action'
|
||||
|
||||
type Props = {
|
||||
list: AnnotationItem[]
|
||||
onView: (item: AnnotationItem) => void
|
||||
onRemove: (id: string) => void
|
||||
selectedIds: string[]
|
||||
onSelectedIdsChange: (selectedIds: string[]) => void
|
||||
onBatchDelete: () => Promise<void>
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const List: FC<Props> = ({
|
||||
list,
|
||||
onView,
|
||||
onRemove,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
onBatchDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const [currId, setCurrId] = React.useState<string | null>(null)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = React.useState(false)
|
||||
|
||||
const isAllSelected = useMemo(() => {
|
||||
return list.length > 0 && list.every(item => selectedIds.includes(item.id))
|
||||
}, [list, selectedIds])
|
||||
|
||||
const isSomeSelected = useMemo(() => {
|
||||
return list.some(item => selectedIds.includes(item.id))
|
||||
}, [list, selectedIds])
|
||||
|
||||
const handleSelectAll = useCallback(() => {
|
||||
const currentPageIds = list.map(item => item.id)
|
||||
const otherPageIds = selectedIds.filter(id => !currentPageIds.includes(id))
|
||||
|
||||
if (isAllSelected)
|
||||
onSelectedIdsChange(otherPageIds)
|
||||
else
|
||||
onSelectedIdsChange([...otherPageIds, ...currentPageIds])
|
||||
}, [isAllSelected, list, selectedIds, onSelectedIdsChange])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='relative mt-2 grow overflow-x-auto'>
|
||||
<table className={cn('w-full min-w-[440px] border-collapse border-0')}>
|
||||
<thead className='system-xs-medium-uppercase text-text-tertiary'>
|
||||
<tr>
|
||||
<td className='w-12 whitespace-nowrap rounded-l-lg bg-background-section-burn px-2'>
|
||||
<Checkbox
|
||||
className='mr-2'
|
||||
checked={isAllSelected}
|
||||
indeterminate={!isAllSelected && isSomeSelected}
|
||||
onCheck={handleSelectAll}
|
||||
/>
|
||||
</td>
|
||||
<td className='w-5 whitespace-nowrap bg-background-section-burn pl-2 pr-1'>{t('appAnnotation.table.header.question')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.answer')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.createdAt')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.hits')}</td>
|
||||
<td className='w-[96px] whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.table.header.actions')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="system-sm-regular text-text-secondary">
|
||||
{list.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className='cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover'
|
||||
onClick={
|
||||
() => {
|
||||
onView(item)
|
||||
}
|
||||
}
|
||||
>
|
||||
<td className='w-12 px-2' onClick={e => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
className='mr-2'
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheck={() => {
|
||||
if (selectedIds.includes(item.id))
|
||||
onSelectedIdsChange(selectedIds.filter(id => id !== item.id))
|
||||
else
|
||||
onSelectedIdsChange([...selectedIds, item.id])
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td
|
||||
className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2'
|
||||
title={item.question}
|
||||
>{item.question}</td>
|
||||
<td
|
||||
className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2'
|
||||
title={item.answer}
|
||||
>{item.answer}</td>
|
||||
<td className='p-3 pr-2'>{formatTime(item.created_at, t('appLog.dateTimeFormat') as string)}</td>
|
||||
<td className='p-3 pr-2'>{item.hit_count}</td>
|
||||
<td className='w-[96px] p-3 pr-2' onClick={e => e.stopPropagation()}>
|
||||
{/* Actions */}
|
||||
<div className='flex space-x-1 text-text-tertiary'>
|
||||
<ActionButton onClick={() => onView(item)}>
|
||||
<RiEditLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
<ActionButton
|
||||
onClick={() => {
|
||||
setCurrId(item.id)
|
||||
setShowConfirmDelete(true)
|
||||
}}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<RemoveAnnotationConfirmModal
|
||||
isShow={showConfirmDelete}
|
||||
onHide={() => setShowConfirmDelete(false)}
|
||||
onRemove={() => {
|
||||
onRemove(currId as string)
|
||||
setShowConfirmDelete(false)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<BatchAction
|
||||
className='absolute bottom-20 left-0 z-20'
|
||||
selectedIds={selectedIds}
|
||||
onBatchDelete={onBatchDelete}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(List)
|
||||
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
const RemoveAnnotationConfirmModal: FC<Props> = ({
|
||||
isShow,
|
||||
onHide,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Confirm
|
||||
isShow={isShow}
|
||||
onCancel={onHide}
|
||||
onConfirm={onRemove}
|
||||
title={t('appDebug.feature.annotation.removeConfirm')}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(RemoveAnnotationConfirmModal)
|
||||
39
dify/web/app/components/app/annotation/type.ts
Normal file
39
dify/web/app/components/app/annotation/type.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
export type AnnotationItemBasic = {
|
||||
message_id?: string
|
||||
question: string
|
||||
answer: string
|
||||
}
|
||||
|
||||
export type AnnotationItem = {
|
||||
id: string
|
||||
question: string
|
||||
answer: string
|
||||
created_at: number
|
||||
hit_count: number
|
||||
}
|
||||
|
||||
export type HitHistoryItem = {
|
||||
id: string
|
||||
question: string
|
||||
match: string
|
||||
response: string
|
||||
source: string
|
||||
score: number
|
||||
created_at: number
|
||||
}
|
||||
|
||||
export type EmbeddingModelConfig = {
|
||||
embedding_provider_name: string
|
||||
embedding_model_name: string
|
||||
}
|
||||
|
||||
export enum AnnotationEnableStatus {
|
||||
enable = 'enable',
|
||||
disable = 'disable',
|
||||
}
|
||||
|
||||
export enum JobStatus {
|
||||
waiting = 'waiting',
|
||||
processing = 'processing',
|
||||
completed = 'completed',
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ClockFastForward } from '@/app/components/base/icons/src/vender/line/time'
|
||||
|
||||
const HitHistoryNoData: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='mx-auto mt-20 w-[480px] space-y-2 rounded-2xl bg-background-section-burn p-5'>
|
||||
<div className='inline-block rounded-lg border border-divider-subtle p-3'>
|
||||
<ClockFastForward className='h-5 w-5 text-text-tertiary' />
|
||||
</div>
|
||||
<div className='system-sm-regular text-text-tertiary'>{t('appAnnotation.viewModal.noHitHistory')}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(HitHistoryNoData)
|
||||
@@ -0,0 +1,237 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import EditItem, { EditItemType } from '../edit-annotation-modal/edit-item'
|
||||
import type { AnnotationItem, HitHistoryItem } from '../type'
|
||||
import HitHistoryNoData from './hit-history-no-data'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import Drawer from '@/app/components/base/drawer-plus'
|
||||
import Pagination from '@/app/components/base/pagination'
|
||||
import { MessageCheckRemove } from '@/app/components/base/icons/src/vender/line/communication'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import { fetchHitHistoryList } from '@/service/annotation'
|
||||
import { APP_PAGE_LIMIT } from '@/config'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
appId: string
|
||||
isShow: boolean
|
||||
onHide: () => void
|
||||
item: AnnotationItem
|
||||
onSave: (editedQuery: string, editedAnswer: string) => Promise<void>
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
enum TabType {
|
||||
annotation = 'annotation',
|
||||
hitHistory = 'hitHistory',
|
||||
}
|
||||
|
||||
const ViewAnnotationModal: FC<Props> = ({
|
||||
appId,
|
||||
isShow,
|
||||
onHide,
|
||||
item,
|
||||
onSave,
|
||||
onRemove,
|
||||
}) => {
|
||||
const { id, question, answer, created_at: createdAt } = item
|
||||
const [newQuestion, setNewQuery] = useState(question)
|
||||
const [newAnswer, setNewAnswer] = useState(answer)
|
||||
const { t } = useTranslation()
|
||||
const { formatTime } = useTimestamp()
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [hitHistoryList, setHitHistoryList] = useState<HitHistoryItem[]>([])
|
||||
|
||||
// Update local state when item prop changes (e.g., when modal is reopened with updated data)
|
||||
useEffect(() => {
|
||||
setNewQuery(question)
|
||||
setNewAnswer(answer)
|
||||
setCurrPage(0)
|
||||
setTotal(0)
|
||||
setHitHistoryList([])
|
||||
}, [question, answer, id])
|
||||
|
||||
const fetchHitHistory = async (page = 1) => {
|
||||
try {
|
||||
const { data, total }: any = await fetchHitHistoryList(appId, id, {
|
||||
page,
|
||||
limit: 10,
|
||||
})
|
||||
setHitHistoryList(data as HitHistoryItem[])
|
||||
setTotal(total)
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchHitHistory(currPage + 1)
|
||||
}, [currPage])
|
||||
|
||||
// Fetch hit history when item changes
|
||||
useEffect(() => {
|
||||
if (isShow && id)
|
||||
fetchHitHistory(1)
|
||||
}, [id, isShow])
|
||||
|
||||
const tabs = [
|
||||
{ value: TabType.annotation, text: t('appAnnotation.viewModal.annotatedResponse') },
|
||||
{
|
||||
value: TabType.hitHistory,
|
||||
text: (
|
||||
hitHistoryList.length > 0
|
||||
? (
|
||||
<div className='flex items-center space-x-1'>
|
||||
<div>{t('appAnnotation.viewModal.hitHistory')}</div>
|
||||
<Badge
|
||||
text={`${total} ${t(`appAnnotation.viewModal.hit${hitHistoryList.length > 1 ? 's' : ''}`)}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: t('appAnnotation.viewModal.hitHistory')
|
||||
),
|
||||
},
|
||||
]
|
||||
const [activeTab, setActiveTab] = useState(TabType.annotation)
|
||||
const handleSave = async (type: EditItemType, editedContent: string) => {
|
||||
try {
|
||||
if (type === EditItemType.Query) {
|
||||
await onSave(editedContent, newAnswer)
|
||||
setNewQuery(editedContent)
|
||||
}
|
||||
else {
|
||||
await onSave(newQuestion, editedContent)
|
||||
setNewAnswer(editedContent)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
// If save fails, don't update local state
|
||||
console.error('Failed to save annotation:', error)
|
||||
}
|
||||
}
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
const annotationTab = (
|
||||
<>
|
||||
<EditItem
|
||||
type={EditItemType.Query}
|
||||
content={question}
|
||||
onSave={editedContent => handleSave(EditItemType.Query, editedContent)}
|
||||
/>
|
||||
<EditItem
|
||||
type={EditItemType.Answer}
|
||||
content={answer}
|
||||
onSave={editedContent => handleSave(EditItemType.Answer, editedContent)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
const hitHistoryTab = total === 0
|
||||
? (<HitHistoryNoData />)
|
||||
: (
|
||||
<div>
|
||||
<table className={cn('w-full min-w-[440px] border-collapse border-0')} >
|
||||
<thead className="system-xs-medium-uppercase text-text-tertiary">
|
||||
<tr>
|
||||
<td className='w-5 whitespace-nowrap rounded-l-lg bg-background-section-burn pl-2 pr-1'>{t('appAnnotation.hitHistoryTable.query')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.hitHistoryTable.match')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.hitHistoryTable.response')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.hitHistoryTable.source')}</td>
|
||||
<td className='whitespace-nowrap bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.hitHistoryTable.score')}</td>
|
||||
<td className='w-[160px] whitespace-nowrap rounded-r-lg bg-background-section-burn py-1.5 pl-3'>{t('appAnnotation.hitHistoryTable.time')}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="system-sm-regular text-text-secondary">
|
||||
{hitHistoryList.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className={'cursor-pointer border-b border-divider-subtle hover:bg-background-default-hover'}
|
||||
>
|
||||
<td
|
||||
className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2'
|
||||
title={item.question}
|
||||
>{item.question}</td>
|
||||
<td
|
||||
className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2'
|
||||
title={item.match}
|
||||
>{item.match}</td>
|
||||
<td
|
||||
className='max-w-[250px] overflow-hidden text-ellipsis whitespace-nowrap p-3 pr-2'
|
||||
title={item.response}
|
||||
>{item.response}</td>
|
||||
<td className='p-3 pr-2'>{item.source}</td>
|
||||
<td className='p-3 pr-2'>{item.score ? item.score.toFixed(2) : '-'}</td>
|
||||
<td className='p-3 pr-2'>{formatTime(item.created_at, t('appLog.dateTimeFormat') as string)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(total && total > APP_PAGE_LIMIT)
|
||||
? <Pagination
|
||||
className='px-0'
|
||||
current={currPage}
|
||||
onChange={setCurrPage}
|
||||
total={total}
|
||||
/>
|
||||
: null}
|
||||
</div>
|
||||
|
||||
)
|
||||
return (
|
||||
<div>
|
||||
<Drawer
|
||||
isShow={isShow}
|
||||
onHide={onHide}
|
||||
maxWidthClassName='!max-w-[800px]'
|
||||
title={
|
||||
<TabSlider
|
||||
className='relative top-[9px] shrink-0'
|
||||
value={activeTab}
|
||||
onChange={v => setActiveTab(v as TabType)}
|
||||
options={tabs}
|
||||
noBorderBottom
|
||||
itemClassName='!pb-3.5'
|
||||
/>
|
||||
}
|
||||
body={(
|
||||
<div>
|
||||
<div className='space-y-6 p-6 pb-4'>
|
||||
{activeTab === TabType.annotation ? annotationTab : hitHistoryTab}
|
||||
</div>
|
||||
<Confirm
|
||||
isShow={showModal}
|
||||
onCancel={() => setShowModal(false)}
|
||||
onConfirm={async () => {
|
||||
await onRemove()
|
||||
setShowModal(false)
|
||||
onHide()
|
||||
}}
|
||||
title={t('appDebug.feature.annotation.removeConfirm')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
foot={id
|
||||
? (
|
||||
<div className='system-sm-medium flex h-16 items-center justify-between rounded-bl-xl rounded-br-xl border-t border-divider-subtle bg-background-section-burn px-4 text-text-tertiary'>
|
||||
<div
|
||||
className='flex cursor-pointer items-center space-x-2 pl-3'
|
||||
onClick={() => setShowModal(true)}
|
||||
>
|
||||
<MessageCheckRemove />
|
||||
<div>{t('appAnnotation.editModal.removeThisCache')}</div>
|
||||
</div>
|
||||
<div>{t('appAnnotation.editModal.createdAt')} {formatTime(createdAt, t('appLog.dateTimeFormat') as string)}</div>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default React.memo(ViewAnnotationModal)
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const AccessControlDialog = ({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => {
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" open={true} className="relative z-[99]" onClose={() => null}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-background-overlay" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
|
||||
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
|
||||
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
|
||||
</div>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition >
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessControlDialog
|
||||
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>
|
||||
|
||||
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu }))
|
||||
if (currentMenu !== type) {
|
||||
return <div
|
||||
className="cursor-pointer rounded-[10px] border-[1px]
|
||||
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||
onClick={() => setCurrentMenu(type)} >
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
@@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { FloatingOverlay } from '@floating-ui/react'
|
||||
import Avatar from '../../base/avatar'
|
||||
import Button from '../../base/button'
|
||||
import Checkbox from '../../base/checkbox'
|
||||
import Input from '../../base/input'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import Loading from '../../base/loading'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
|
||||
export default function AddMemberOrGroupDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setKeyword(e.target.value)
|
||||
}
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, anchorRef, data])
|
||||
|
||||
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<Button variant='ghost-accent' size='small' className='flex shrink-0 items-center gap-x-0.5' onClick={() => setOpen(!open)}>
|
||||
<RiAddCircleFill className='h-4 w-4' />
|
||||
<span>{t('common.operation.add')}</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
{open && <FloatingOverlay />}
|
||||
<PortalToFollowElemContent className='z-[100]'>
|
||||
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
|
||||
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||
</div>
|
||||
{
|
||||
isLoading
|
||||
? <div className='p-1'><Loading /></div>
|
||||
: (data?.pages?.length ?? 0) > 0
|
||||
? <>
|
||||
<div className='flex h-7 items-center px-2 py-0.5'>
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{renderGroupOrMember(data?.pages ?? [])}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
</div>
|
||||
<div ref={anchorRef} className='h-0'> </div>
|
||||
</>
|
||||
: <div className='flex h-7 items-center justify-center px-2 py-0.5'>
|
||||
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
}
|
||||
|
||||
type GroupOrMemberData = { subjects: Subject[]; currPage: number }[]
|
||||
function renderGroupOrMember(data: GroupOrMemberData) {
|
||||
return data?.map((page) => {
|
||||
return <div key={`search_group_member_page_${page.currPage}`}>
|
||||
{page.subjects?.map((item, index) => {
|
||||
if (item.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
|
||||
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
|
||||
})}
|
||||
</div>
|
||||
}) ?? null
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
|
||||
const handleReset = useCallback(() => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}, [setSelectedGroupsForBreadcrumb])
|
||||
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
|
||||
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||
<span>/</span>
|
||||
<span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (!isChecked) {
|
||||
const newGroups = [...specificGroups, group]
|
||||
setSpecificGroups(newGroups)
|
||||
}
|
||||
else {
|
||||
const newGroups = specificGroups.filter(g => g.id !== group.id)
|
||||
setSpecificGroups(newGroups)
|
||||
}
|
||||
}, [specificGroups, setSpecificGroups, group, isChecked])
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
|
||||
return <BaseItem>
|
||||
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||
<div className='item-center flex grow'>
|
||||
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />
|
||||
</div>
|
||||
</div>
|
||||
<p className='system-sm-medium mr-1 text-text-secondary'>{group.name}</p>
|
||||
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||
</div>
|
||||
<Button size="small" disabled={isChecked} variant='ghost-accent'
|
||||
className='flex shrink-0 items-center justify-between px-1.5 py-1' onClick={handleExpandClick}>
|
||||
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
||||
<RiArrowRightSLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (!isChecked) {
|
||||
const newMembers = [...specificMembers, member]
|
||||
setSpecificMembers(newMembers)
|
||||
}
|
||||
else {
|
||||
const newMembers = specificMembers.filter(m => m.id !== member.id)
|
||||
setSpecificMembers(newMembers)
|
||||
}
|
||||
}, [specificMembers, setSpecificMembers, member, isChecked])
|
||||
return <BaseItem className='pr-3'>
|
||||
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||
<div className='flex grow items-center'>
|
||||
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<p className='system-sm-medium mr-1 text-text-secondary'>{member.name}</p>
|
||||
{currentUser.email === member.email && <p className='system-xs-regular text-text-tertiary'>({t('common.you')})</p>}
|
||||
</div>
|
||||
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className }: BaseItemProps) {
|
||||
return <div className={classNames('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
110
dify/web/app/components/app/app-access-control/index.tsx
Normal file
110
dify/web/app/components/app/app-access-control/index.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import { Description as DialogDescription, DialogTitle } from '@headlessui/react'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Button from '../../base/button'
|
||||
import Toast from '../../base/toast'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import type { App } from '@/types/app'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
await updateAccessMode(submitData)
|
||||
Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') })
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return <AccessControlDialog show onClose={onClose}>
|
||||
<div className='flex flex-col gap-y-3'>
|
||||
<div className='pb-3 pl-6 pr-14 pt-6'>
|
||||
<DialogTitle className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</DialogTitle>
|
||||
<DialogDescription className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</DialogDescription>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 px-6 pb-3'>
|
||||
<div className='leading-6'>
|
||||
<p className='system-sm-medium text-text-tertiary'>{t('app.accessControlDialog.accessLabel')}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiBuildingLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.external')}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className='flex items-center gap-x-2 p-3'>
|
||||
<RiGlobalLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant='primary' onClick={handleConfirm}>{t('common.operation.confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlDialog>
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Avatar from '../../base/avatar'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import Loading from '../../base/loading'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
useEffect(() => {
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return <div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className='flex items-center gap-x-1 p-3'>
|
||||
<div className='flex grow items-center gap-x-1'>
|
||||
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<AddMemberOrGroupDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-1 pb-1'>
|
||||
<div className='flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2'>
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className='px-2 pb-1.5 pt-5'><p className='system-xs-regular text-center text-text-tertiary'>{t('app.accessControlDialog.noGroupsOrMembers')}</p></div>
|
||||
return <>
|
||||
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}</p>
|
||||
<div className='flex flex-row flex-wrap gap-1'>
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}</p>
|
||||
<div className='flex flex-row flex-wrap gap-1'>
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return <BaseItem icon={<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />}
|
||||
onRemove={handleRemoveGroup}>
|
||||
<p className='system-xs-regular text-text-primary'>{group.name}</p>
|
||||
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return <BaseItem icon={<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}>
|
||||
<p className='system-xs-regular text-text-primary'>{member.name}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
return <div className='group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs'>
|
||||
<div className='h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={onRemove}>
|
||||
<RiCloseCircleFill className='h-[14px] w-[14px] text-text-quaternary' />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
return <Tooltip asChild={false} popupContent={t('app.accessControlDialog.webAppSSONotEnabledTip')}>
|
||||
<RiAlertFill className='h-4 w-4 shrink-0 text-text-warning-secondary' />
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce } from 'immer'
|
||||
import type { AppPublisherProps } from '@/app/components/app/app-publisher'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import AppPublisher from '@/app/components/app/app-publisher'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { Resolution } from '@/types/app'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
type Props = Omit<AppPublisherProps, 'onPublish'> & {
|
||||
onPublish?: (modelAndParameter?: ModelAndParameter, features?: any) => Promise<any> | any
|
||||
publishedConfig?: any
|
||||
resetAppConfig?: () => void
|
||||
}
|
||||
|
||||
const FeaturesWrappedAppPublisher = (props: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const features = useFeatures(s => s.features)
|
||||
const featuresStore = useFeaturesStore()
|
||||
const [restoreConfirmOpen, setRestoreConfirmOpen] = useState(false)
|
||||
const { more_like_this, opening_statement, suggested_questions, sensitive_word_avoidance, speech_to_text, text_to_speech, suggested_questions_after_answer, retriever_resource, annotation_reply, file_upload, resetAppConfig } = props.publishedConfig.modelConfig
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
resetAppConfig?.()
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.moreLikeThis = more_like_this || { enabled: false }
|
||||
draft.opening = {
|
||||
enabled: !!opening_statement,
|
||||
opening_statement: opening_statement || '',
|
||||
suggested_questions: suggested_questions || [],
|
||||
}
|
||||
draft.moderation = sensitive_word_avoidance || { enabled: false }
|
||||
draft.speech2text = speech_to_text || { enabled: false }
|
||||
draft.text2speech = text_to_speech || { enabled: false }
|
||||
draft.suggested = suggested_questions_after_answer || { enabled: false }
|
||||
draft.citation = retriever_resource || { enabled: false }
|
||||
draft.annotationReply = annotation_reply || { enabled: false }
|
||||
draft.file = {
|
||||
image: {
|
||||
detail: file_upload?.image?.detail || Resolution.high,
|
||||
enabled: !!file_upload?.image?.enabled,
|
||||
number_limits: file_upload?.image?.number_limits || 3,
|
||||
transfer_methods: file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(file_upload?.enabled || file_upload?.image?.enabled),
|
||||
allowed_file_types: file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: file_upload?.allowed_file_upload_methods || file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: file_upload?.number_limits || file_upload?.image?.number_limits || 3,
|
||||
} as FileUpload
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
setRestoreConfirmOpen(false)
|
||||
}, [featuresStore, props])
|
||||
|
||||
const handlePublish = useCallback((modelAndParameter?: ModelAndParameter) => {
|
||||
return props.onPublish?.(modelAndParameter, features)
|
||||
}, [features, props])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppPublisher {...{
|
||||
...props,
|
||||
onPublish: handlePublish,
|
||||
onRestore: () => setRestoreConfirmOpen(true),
|
||||
}} />
|
||||
{restoreConfirmOpen && (
|
||||
<Confirm
|
||||
title={t('appDebug.resetConfig.title')}
|
||||
content={t('appDebug.resetConfig.message')}
|
||||
isShow={restoreConfirmOpen}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setRestoreConfirmOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FeaturesWrappedAppPublisher
|
||||
482
dify/web/app/components/app/app-publisher/index.tsx
Normal file
482
dify/web/app/components/app/app-publisher/index.tsx
Normal file
@@ -0,0 +1,482 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiBuildingLine,
|
||||
RiGlobalLine,
|
||||
RiLockLine,
|
||||
RiPlanetLine,
|
||||
RiPlayCircleLine,
|
||||
RiPlayList2Line,
|
||||
RiTerminalBoxLine,
|
||||
RiVerifiedBadgeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import Divider from '../../base/divider'
|
||||
import Loading from '../../base/loading'
|
||||
import Toast from '../../base/toast'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils'
|
||||
import AccessControl from '../app-access-control'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import EmbeddedModal from '@/app/components/app/overview/embedded'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useFormatTimeFromNow } from '@/hooks/use-format-time-from-now'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { fetchAppDetailDirect } from '@/service/apps'
|
||||
import { fetchInstalledAppList } from '@/service/explore'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { basePath } from '@/utils/var'
|
||||
import UpgradeBtn from '@/app/components/billing/upgrade-btn'
|
||||
|
||||
const ACCESS_MODE_MAP: Record<AccessMode, { label: string, icon: React.ElementType }> = {
|
||||
[AccessMode.ORGANIZATION]: {
|
||||
label: 'organization',
|
||||
icon: RiBuildingLine,
|
||||
},
|
||||
[AccessMode.SPECIFIC_GROUPS_MEMBERS]: {
|
||||
label: 'specific',
|
||||
icon: RiLockLine,
|
||||
},
|
||||
[AccessMode.PUBLIC]: {
|
||||
label: 'anyone',
|
||||
icon: RiGlobalLine,
|
||||
},
|
||||
[AccessMode.EXTERNAL_MEMBERS]: {
|
||||
label: 'external',
|
||||
icon: RiVerifiedBadgeLine,
|
||||
},
|
||||
}
|
||||
|
||||
const AccessModeDisplay: React.FC<{ mode?: AccessMode }> = ({ mode }) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
if (!mode || !ACCESS_MODE_MAP[mode])
|
||||
return null
|
||||
|
||||
const { icon: Icon, label } = ACCESS_MODE_MAP[mode]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Icon className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<div className='grow truncate'>
|
||||
<span className='system-sm-medium text-text-secondary'>{t(`app.accessControlDialog.accessItems.${label}`)}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
publishDisabled?: boolean
|
||||
publishedAt?: number
|
||||
/** only needed in workflow / chatflow mode */
|
||||
draftUpdatedAt?: number
|
||||
debugWithMultipleModel?: boolean
|
||||
multipleModelConfigs?: ModelAndParameter[]
|
||||
/** modelAndParameter is passed when debugWithMultipleModel is true */
|
||||
onPublish?: (params?: any) => Promise<any> | any
|
||||
onRestore?: () => Promise<any> | any
|
||||
onToggle?: (state: boolean) => void
|
||||
crossAxisOffset?: number
|
||||
toolPublished?: boolean
|
||||
inputs?: InputVar[]
|
||||
onRefreshData?: () => void
|
||||
workflowToolAvailable?: boolean
|
||||
missingStartNode?: boolean
|
||||
hasTriggerNode?: boolean // Whether workflow currently contains any trigger nodes (used to hide missing-start CTA when triggers exist).
|
||||
startNodeLimitExceeded?: boolean
|
||||
}
|
||||
|
||||
const PUBLISH_SHORTCUT = ['ctrl', '⇧', 'P']
|
||||
|
||||
const AppPublisher = ({
|
||||
disabled = false,
|
||||
publishDisabled = false,
|
||||
publishedAt,
|
||||
draftUpdatedAt,
|
||||
debugWithMultipleModel = false,
|
||||
multipleModelConfigs = [],
|
||||
onPublish,
|
||||
onRestore,
|
||||
onToggle,
|
||||
crossAxisOffset = 0,
|
||||
toolPublished,
|
||||
inputs,
|
||||
onRefreshData,
|
||||
workflowToolAvailable = true,
|
||||
missingStartNode = false,
|
||||
hasTriggerNode = false,
|
||||
startNodeLimitExceeded = false,
|
||||
}: AppPublisherProps) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { formatTimeFromNow } = useFormatTimeFromNow()
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
|
||||
const appMode = (appDetail?.mode !== AppModeEnum.COMPLETION && appDetail?.mode !== AppModeEnum.WORKFLOW) ? AppModeEnum.CHAT : appDetail.mode
|
||||
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
||||
const isChatApp = [AppModeEnum.CHAT, AppModeEnum.AGENT_CHAT, AppModeEnum.COMPLETION].includes(appDetail?.mode || AppModeEnum.CHAT)
|
||||
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
|
||||
const noAccessPermission = useMemo(() => systemFeatures.webapp_auth.enabled && appDetail && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result, [systemFeatures, appDetail, userCanAccessApp])
|
||||
const disabledFunctionButton = useMemo(() => (!publishedAt || missingStartNode || noAccessPermission), [publishedAt, missingStartNode, noAccessPermission])
|
||||
|
||||
const disabledFunctionTooltip = useMemo(() => {
|
||||
if (!publishedAt)
|
||||
return t('app.notPublishedYet')
|
||||
if (missingStartNode)
|
||||
return t('app.noUserInputNode')
|
||||
if (noAccessPermission)
|
||||
return t('app.noAccessPermission')
|
||||
}, [missingStartNode, noAccessPermission, publishedAt])
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
refetch()
|
||||
}, [open, appDetail, refetch, systemFeatures])
|
||||
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const handlePublish = useCallback(async (params?: ModelAndParameter | PublishWorkflowParams) => {
|
||||
try {
|
||||
await onPublish?.(params)
|
||||
setPublished(true)
|
||||
}
|
||||
catch {
|
||||
setPublished(false)
|
||||
}
|
||||
}, [onPublish])
|
||||
|
||||
const handleRestore = useCallback(async () => {
|
||||
try {
|
||||
await onRestore?.()
|
||||
setOpen(false)
|
||||
}
|
||||
catch { }
|
||||
}, [onRestore])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
const state = !open
|
||||
|
||||
if (disabled) {
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
onToggle?.(state)
|
||||
setOpen(state)
|
||||
|
||||
if (state)
|
||||
setPublished(false)
|
||||
}, [disabled, onToggle, open])
|
||||
|
||||
const handleOpenInExplore = useCallback(async () => {
|
||||
try {
|
||||
const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {}
|
||||
if (installed_apps?.length > 0)
|
||||
window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank')
|
||||
else
|
||||
throw new Error('No app found in Explore')
|
||||
}
|
||||
catch (e: any) {
|
||||
Toast.notify({ type: 'error', message: `${e.message || e}` })
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(async () => {
|
||||
if (!appDetail)
|
||||
return
|
||||
try {
|
||||
const res = await fetchAppDetailDirect({ url: '/apps', id: appDetail.id })
|
||||
setAppDetail(res)
|
||||
}
|
||||
finally {
|
||||
setShowAppAccessControl(false)
|
||||
}
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
e.preventDefault()
|
||||
if (publishDisabled || published)
|
||||
return
|
||||
handlePublish()
|
||||
}, { exactMatch: true, useCapture: true })
|
||||
|
||||
const hasPublishedVersion = !!publishedAt
|
||||
const workflowToolDisabled = !hasPublishedVersion || !workflowToolAvailable
|
||||
const workflowToolMessage = workflowToolDisabled ? t('workflow.common.workflowAsToolDisabledHint') : undefined
|
||||
const showStartNodeLimitHint = Boolean(startNodeLimitExceeded)
|
||||
const upgradeHighlightStyle = useMemo(() => ({
|
||||
background: 'linear-gradient(97deg, var(--components-input-border-active-prompt-1, rgba(11, 165, 236, 0.95)) -3.64%, var(--components-input-border-active-prompt-2, rgba(21, 90, 239, 0.95)) 45.14%)',
|
||||
WebkitBackgroundClip: 'text',
|
||||
backgroundClip: 'text',
|
||||
WebkitTextFillColor: 'transparent',
|
||||
}), [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: crossAxisOffset,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={handleTrigger}>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='py-2 pl-3 pr-2'
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('workflow.common.publish')}
|
||||
<RiArrowDownSLine className='h-4 w-4 text-components-button-primary-text' />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[11]'>
|
||||
<div className='w-[320px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl shadow-shadow-shadow-5'>
|
||||
<div className='p-4 pt-3'>
|
||||
<div className='system-xs-medium-uppercase flex h-6 items-center text-text-tertiary'>
|
||||
{publishedAt ? t('workflow.common.latestPublished') : t('workflow.common.currentDraftUnpublished')}
|
||||
</div>
|
||||
{publishedAt
|
||||
? (
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='system-sm-medium flex items-center text-text-secondary'>
|
||||
{t('workflow.common.publishedAt')} {formatTimeFromNow(publishedAt)}
|
||||
</div>
|
||||
{isChatApp && <Button
|
||||
variant='secondary-accent'
|
||||
size='small'
|
||||
onClick={handleRestore}
|
||||
disabled={published}
|
||||
>
|
||||
{t('workflow.common.restore')}
|
||||
</Button>}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className='system-sm-medium flex items-center text-text-secondary'>
|
||||
{t('workflow.common.autoSaved')} · {Boolean(draftUpdatedAt) && formatTimeFromNow(draftUpdatedAt!)}
|
||||
</div>
|
||||
)}
|
||||
{debugWithMultipleModel
|
||||
? (
|
||||
<PublishWithMultipleModel
|
||||
multipleModelConfigs={multipleModelConfigs}
|
||||
onSelect={item => handlePublish(item)}
|
||||
// textGenerationModelList={textGenerationModelList}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<Button
|
||||
variant='primary'
|
||||
className='mt-3 w-full'
|
||||
onClick={() => handlePublish()}
|
||||
disabled={publishDisabled || published}
|
||||
>
|
||||
{
|
||||
published
|
||||
? t('workflow.common.published')
|
||||
: (
|
||||
<div className='flex gap-1'>
|
||||
<span>{t('workflow.common.publishUpdate')}</span>
|
||||
<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'>
|
||||
{getKeyboardKeyNameBySystem(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</Button>
|
||||
{showStartNodeLimitHint && (
|
||||
<div className='mt-3 flex flex-col items-stretch'>
|
||||
<p
|
||||
className='text-sm font-semibold leading-5 text-transparent'
|
||||
style={upgradeHighlightStyle}
|
||||
>
|
||||
<span className='block'>{t('workflow.publishLimit.startNodeTitlePrefix')}</span>
|
||||
<span className='block'>{t('workflow.publishLimit.startNodeTitleSuffix')}</span>
|
||||
</p>
|
||||
<p className='mt-1 text-xs leading-4 text-text-secondary'>
|
||||
{t('workflow.publishLimit.startNodeDesc')}
|
||||
</p>
|
||||
<UpgradeBtn
|
||||
isShort
|
||||
className='mb-[12px] mt-[9px] h-[32px] w-[93px] self-start'
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
|
||||
? <div className='py-2'><Loading /></div>
|
||||
: <>
|
||||
<Divider className='my-0' />
|
||||
{systemFeatures.webapp_auth.enabled && <div className='p-4 pt-3'>
|
||||
<div className='flex h-6 items-center'>
|
||||
<p className='system-xs-medium text-text-tertiary'>{t('app.publishApp.title')}</p>
|
||||
</div>
|
||||
<div className='flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent'
|
||||
onClick={() => {
|
||||
setShowAppAccessControl(true)
|
||||
}}>
|
||||
<div className='flex grow items-center gap-x-1.5 overflow-hidden pr-1'>
|
||||
<AccessModeDisplay mode={appDetail?.access_mode} />
|
||||
</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
|
||||
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular mt-1 text-text-warning'>{t('app.publishApp.notSetDesc')}</p>}
|
||||
</div>}
|
||||
{
|
||||
// Hide run/batch run app buttons when there is a trigger node.
|
||||
!hasTriggerNode && (
|
||||
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={disabledFunctionButton}
|
||||
link={appURL}
|
||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.runApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW || appDetail?.mode === AppModeEnum.COMPLETION
|
||||
? (
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={disabledFunctionButton}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.batchRunApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.embedIntoSite')}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName='flex' disabled={!disabledFunctionButton} popupContent={disabledFunctionTooltip} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
onClick={() => {
|
||||
if (publishedAt)
|
||||
handleOpenInExplore()
|
||||
}}
|
||||
disabled={disabledFunctionButton}
|
||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.openInExplore')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<Tooltip triggerClassName='flex' disabled={!!publishedAt && !missingStartNode} popupContent={!publishedAt ? t('app.notPublishedYet') : t('app.noUserInputNode')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={!publishedAt || missingStartNode}
|
||||
link='./develop'
|
||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.accessAPIReference')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === AppModeEnum.WORKFLOW && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={workflowToolDisabled}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
disabledReason={workflowToolMessage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
<EmbeddedModal
|
||||
siteInfo={appDetail?.site}
|
||||
isShow={embeddingModalOpen}
|
||||
onClose={() => setEmbeddingModalOpen(false)}
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
|
||||
</PortalToFollowElem >
|
||||
</>)
|
||||
}
|
||||
|
||||
export default memo(AppPublisher)
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { FC } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import ModelIcon from '../../header/account-setting/model-provider-page/model-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import type { Model, ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
|
||||
type PublishWithMultipleModelProps = {
|
||||
multipleModelConfigs: ModelAndParameter[]
|
||||
// textGenerationModelList?: Model[]
|
||||
onSelect: (v: ModelAndParameter) => void
|
||||
}
|
||||
const PublishWithMultipleModel: FC<PublishWithMultipleModelProps> = ({
|
||||
multipleModelConfigs,
|
||||
// textGenerationModelList = [],
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const { textGenerationModelList } = useProviderContext()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
const validModelConfigs: (ModelAndParameter & { modelItem: ModelItem; providerItem: Model })[] = []
|
||||
|
||||
multipleModelConfigs.forEach((item) => {
|
||||
const provider = textGenerationModelList.find(model => model.provider === item.provider)
|
||||
|
||||
if (provider) {
|
||||
const model = provider.models.find(model => model.model === item.model)
|
||||
|
||||
if (model) {
|
||||
validModelConfigs.push({
|
||||
id: item.id,
|
||||
model: item.model,
|
||||
provider: item.provider,
|
||||
modelItem: model,
|
||||
providerItem: provider,
|
||||
parameters: item.parameters,
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const handleToggle = () => {
|
||||
if (validModelConfigs.length)
|
||||
setOpen(v => !v)
|
||||
}
|
||||
|
||||
const handleSelect = (item: ModelAndParameter) => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
>
|
||||
<PortalToFollowElemTrigger className='w-full' onClick={handleToggle}>
|
||||
<Button
|
||||
variant='primary'
|
||||
disabled={!validModelConfigs.length}
|
||||
className='mt-3 w-full'
|
||||
>
|
||||
{t('appDebug.operation.applyConfig')}
|
||||
<RiArrowDownSLine className='ml-0.5 h-3 w-3' />
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-50 mt-1 w-[288px]'>
|
||||
<div className='rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
|
||||
<div className='flex h-[22px] items-center px-3 text-xs font-medium text-text-tertiary'>
|
||||
{t('appDebug.publishAs')}
|
||||
</div>
|
||||
{
|
||||
validModelConfigs.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg px-3 text-sm text-text-tertiary hover:bg-state-base-hover'
|
||||
onClick={() => handleSelect(item)}
|
||||
>
|
||||
<span className='min-w-[18px] italic'>#{index + 1}</span>
|
||||
<ModelIcon modelName={item.model} provider={item.providerItem} className='ml-2' />
|
||||
<div
|
||||
className='ml-1 truncate text-text-secondary'
|
||||
title={item.modelItem.label[language]}
|
||||
>
|
||||
{item.modelItem.label[language]}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default PublishWithMultipleModel
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { HTMLProps, PropsWithChildren } from 'react'
|
||||
import { RiArrowRightUpLine } from '@remixicon/react'
|
||||
import classNames from '@/utils/classnames'
|
||||
|
||||
export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement> & {
|
||||
icon?: React.ReactNode
|
||||
link?: string
|
||||
disabled?: boolean
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled)
|
||||
return
|
||||
onClick?.(e)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={classNames(
|
||||
'flex items-center justify-start gap-2 rounded-lg bg-background-section-burn px-2.5 py-2 text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
||||
disabled ? 'cursor-not-allowed opacity-30 shadow-xs' : 'cursor-pointer text-text-secondary hover:bg-state-accent-hover hover:text-text-accent',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative h-4 w-4'>{icon}</div>
|
||||
<div className='system-sm-medium shrink grow basis-0'>{children}</div>
|
||||
<RiArrowRightUpLine className='h-3.5 w-3.5' />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuggestedAction
|
||||
114
dify/web/app/components/app/app-publisher/version-info-modal.tsx
Normal file
114
dify/web/app/components/app/app-publisher/version-info-modal.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import React, { type FC, useCallback, useState } from 'react'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import Input from '../../base/input'
|
||||
import Textarea from '../../base/textarea'
|
||||
import Button from '../../base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
|
||||
type VersionInfoModalProps = {
|
||||
isOpen: boolean
|
||||
versionInfo?: VersionHistory
|
||||
onClose: () => void
|
||||
onPublish: (params: { title: string; releaseNotes: string; id?: string }) => void
|
||||
}
|
||||
|
||||
const TITLE_MAX_LENGTH = 15
|
||||
const RELEASE_NOTES_MAX_LENGTH = 100
|
||||
|
||||
const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
isOpen,
|
||||
versionInfo,
|
||||
onClose,
|
||||
onPublish,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [title, setTitle] = useState(versionInfo?.marked_name || '')
|
||||
const [releaseNotes, setReleaseNotes] = useState(versionInfo?.marked_comment || '')
|
||||
const [titleError, setTitleError] = useState(false)
|
||||
const [releaseNotesError, setReleaseNotesError] = useState(false)
|
||||
|
||||
const handlePublish = () => {
|
||||
if (title.length > TITLE_MAX_LENGTH) {
|
||||
setTitleError(true)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('workflow.versionHistory.editField.titleLengthLimit', { limit: TITLE_MAX_LENGTH }),
|
||||
})
|
||||
return
|
||||
}
|
||||
else {
|
||||
if (titleError)
|
||||
setTitleError(false)
|
||||
}
|
||||
|
||||
if (releaseNotes.length > RELEASE_NOTES_MAX_LENGTH) {
|
||||
setReleaseNotesError(true)
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('workflow.versionHistory.editField.releaseNotesLengthLimit', { limit: RELEASE_NOTES_MAX_LENGTH }),
|
||||
})
|
||||
return
|
||||
}
|
||||
else {
|
||||
if (releaseNotesError)
|
||||
setReleaseNotesError(false)
|
||||
}
|
||||
|
||||
onPublish({ title, releaseNotes, id: versionInfo?.id })
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleTitleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTitle(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setReleaseNotes(e.target.value)
|
||||
}, [])
|
||||
|
||||
return <Modal className='p-0' isShow={isOpen} onClose={onClose}>
|
||||
<div className='relative w-full p-6 pb-4 pr-14'>
|
||||
<div className='title-2xl-semi-bold text-text-primary first-letter:capitalize'>
|
||||
{versionInfo?.marked_name ? t('workflow.versionHistory.editVersionInfo') : t('workflow.versionHistory.nameThisVersion')}
|
||||
</div>
|
||||
<div className='absolute right-5 top-5 flex h-8 w-8 cursor-pointer items-center justify-center p-1.5' onClick={onClose}>
|
||||
<RiCloseLine className='h-[18px] w-[18px] text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-4 px-6 py-3'>
|
||||
<div className='flex flex-col gap-y-1'>
|
||||
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>
|
||||
{t('workflow.versionHistory.editField.title')}
|
||||
</div>
|
||||
<Input
|
||||
value={title}
|
||||
placeholder={`${t('workflow.versionHistory.nameThisVersion')}${t('workflow.panel.optional')}`}
|
||||
onChange={handleTitleChange}
|
||||
destructive={titleError}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1'>
|
||||
<div className='system-sm-semibold flex h-6 items-center text-text-secondary'>
|
||||
{t('workflow.versionHistory.editField.releaseNotes')}
|
||||
</div>
|
||||
<Textarea
|
||||
value={releaseNotes}
|
||||
placeholder={`${t('workflow.versionHistory.releaseNotesPlaceholder')}${t('workflow.panel.optional')}`}
|
||||
onChange={handleDescriptionChange}
|
||||
destructive={releaseNotesError}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex justify-end p-6 pt-5'>
|
||||
<div className='flex items-center gap-x-3'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={handlePublish}>{t('workflow.common.publish')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export default VersionInfoModal
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import type { FC, ReactNode } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type IFeaturePanelProps = {
|
||||
className?: string
|
||||
headerIcon?: ReactNode
|
||||
title: ReactNode
|
||||
headerRight?: ReactNode
|
||||
hasHeaderBottomBorder?: boolean
|
||||
noBodySpacing?: boolean
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
const FeaturePanel: FC<IFeaturePanelProps> = ({
|
||||
className,
|
||||
headerIcon,
|
||||
title,
|
||||
headerRight,
|
||||
hasHeaderBottomBorder,
|
||||
noBodySpacing,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn('rounded-xl border-l-[0.5px] border-t-[0.5px] border-effects-highlight bg-background-section-burn pb-3', noBodySpacing && 'pb-0', className)}>
|
||||
{/* Header */}
|
||||
<div className={cn('px-3 pt-2', hasHeaderBottomBorder && 'border-b border-divider-subtle')}>
|
||||
<div className='flex h-8 items-center justify-between'>
|
||||
<div className='flex shrink-0 items-center space-x-1'>
|
||||
{headerIcon && <div className='flex h-6 w-6 items-center justify-center'>{headerIcon}</div>}
|
||||
<div className='system-sm-semibold text-text-secondary'>{title}</div>
|
||||
</div>
|
||||
<div className='flex items-center gap-2'>
|
||||
{headerRight && <div>{headerRight}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
{children && (
|
||||
<div className={cn(!noBodySpacing && 'mt-1 px-3')}>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(FeaturePanel)
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
export type IGroupNameProps = {
|
||||
name: string
|
||||
}
|
||||
|
||||
const GroupName: FC<IGroupNameProps> = ({
|
||||
name,
|
||||
}) => {
|
||||
return (
|
||||
<div className='mb-1 flex items-center'>
|
||||
<div className='mr-3 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{name}</div>
|
||||
<div className='h-px grow'
|
||||
style={{
|
||||
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
|
||||
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(GroupName)
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiAddLine,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type IOperationBtnProps = {
|
||||
className?: string
|
||||
type: 'add' | 'edit'
|
||||
actionName?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const iconMap = {
|
||||
add: <RiAddLine className='h-3.5 w-3.5' />,
|
||||
edit: <RiEditLine className='h-3.5 w-3.5' />,
|
||||
}
|
||||
|
||||
const OperationBtn: FC<IOperationBtnProps> = ({
|
||||
className,
|
||||
type,
|
||||
actionName,
|
||||
onClick = noop,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-7 cursor-pointer select-none items-center space-x-1 rounded-md px-3 text-text-secondary hover:bg-state-base-hover', className)}
|
||||
onClick={onClick}>
|
||||
<div>
|
||||
{iconMap[type]}
|
||||
</div>
|
||||
<div className='text-xs font-medium'>
|
||||
{actionName || t(`common.operation.${type}`)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(OperationBtn)
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import s from './style.module.css'
|
||||
|
||||
export type IVarHighlightProps = {
|
||||
name: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const VarHighlight: FC<IVarHighlightProps> = ({
|
||||
name,
|
||||
className = '',
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className={`${s.item} ${className} mb-2 inline-flex h-5 items-center justify-center rounded-md px-1 text-xs font-medium text-primary-600`}
|
||||
>
|
||||
<span className='opacity-60'>{'{{'}</span><span>{name}</span><span className='opacity-60'>{'}}'}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// DEPRECATED: This function is vulnerable to XSS attacks and should not be used
|
||||
// Use the VarHighlight React component instead
|
||||
export const varHighlightHTML = ({ name, className = '' }: IVarHighlightProps) => {
|
||||
const escapedName = name
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
const html = `<div class="${s.item} ${className} inline-flex mb-2 items-center justify-center px-1 rounded-md h-5 text-xs font-medium text-primary-600">
|
||||
<span class='opacity-60'>{{</span>
|
||||
<span>${escapedName}</span>
|
||||
<span class='opacity-60'>}}</span>
|
||||
</div>`
|
||||
return html
|
||||
}
|
||||
|
||||
export default React.memo(VarHighlight)
|
||||
@@ -0,0 +1,3 @@
|
||||
.item {
|
||||
background-color: rgba(21, 94, 239, 0.05);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import WarningMask from '.'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IFormattingChangedProps = {
|
||||
onConfirm: () => void
|
||||
}
|
||||
|
||||
const FormattingChanged: FC<IFormattingChangedProps> = ({
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<WarningMask
|
||||
title={t('appDebug.feature.dataSet.queryVariable.unableToQueryDataSet')}
|
||||
description={t('appDebug.feature.dataSet.queryVariable.unableToQueryDataSetTip')}
|
||||
footer={
|
||||
<div className='flex space-x-2'>
|
||||
<Button variant='primary' className='flex !w-[96px] justify-start' onClick={onConfirm}>
|
||||
<span className='text-[13px] font-medium'>{t('appDebug.feature.dataSet.queryVariable.ok')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(FormattingChanged)
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import WarningMask from '.'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IFormattingChangedProps = {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M1.33337 6.66667C1.33337 6.66667 2.67003 4.84548 3.75593 3.75883C4.84183 2.67218 6.34244 2 8.00004 2C11.3137 2 14 4.68629 14 8C14 11.3137 11.3137 14 8.00004 14C5.26465 14 2.95678 12.1695 2.23455 9.66667M1.33337 6.66667V2.66667M1.33337 6.66667H5.33337" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FormattingChanged: FC<IFormattingChangedProps> = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<WarningMask
|
||||
title={t('appDebug.formattingChangedTitle')}
|
||||
description={t('appDebug.formattingChangedText')}
|
||||
footer={
|
||||
<div className='flex space-x-2'>
|
||||
<Button variant='primary' className='flex space-x-2' onClick={onConfirm}>
|
||||
{icon}
|
||||
<span>{t('common.operation.refresh')}</span>
|
||||
</Button>
|
||||
<Button onClick={onCancel}>{t('common.operation.cancel') as string}</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(FormattingChanged)
|
||||
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import WarningMask from '.'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IHasNotSetAPIProps = {
|
||||
isTrailFinished: boolean
|
||||
onSetting: () => void
|
||||
}
|
||||
|
||||
const icon = (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M14 6.00001L14 2.00001M14 2.00001H9.99999M14 2.00001L8 8M6.66667 2H5.2C4.0799 2 3.51984 2 3.09202 2.21799C2.71569 2.40973 2.40973 2.71569 2.21799 3.09202C2 3.51984 2 4.07989 2 5.2V10.8C2 11.9201 2 12.4802 2.21799 12.908C2.40973 13.2843 2.71569 13.5903 3.09202 13.782C3.51984 14 4.07989 14 5.2 14H10.8C11.9201 14 12.4802 14 12.908 13.782C13.2843 13.5903 13.5903 13.2843 13.782 12.908C14 12.4802 14 11.9201 14 10.8V9.33333" stroke="white" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
|
||||
)
|
||||
|
||||
const HasNotSetAPI: FC<IHasNotSetAPIProps> = ({
|
||||
isTrailFinished,
|
||||
onSetting,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<WarningMask
|
||||
title={isTrailFinished ? t('appDebug.notSetAPIKey.trailFinished') : t('appDebug.notSetAPIKey.title')}
|
||||
description={t('appDebug.notSetAPIKey.description')}
|
||||
footer={
|
||||
<Button variant='primary' className='flex space-x-2' onClick={onSetting}>
|
||||
<span>{t('appDebug.notSetAPIKey.settingBtn')}</span>
|
||||
{icon}
|
||||
</Button>}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(HasNotSetAPI)
|
||||
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
|
||||
import s from './style.module.css'
|
||||
|
||||
export type IWarningMaskProps = {
|
||||
title: string
|
||||
description: string
|
||||
footer: React.ReactNode
|
||||
}
|
||||
|
||||
const warningIcon = (
|
||||
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.99996 13.3334V10.0001M9.99996 6.66675H10.0083M18.3333 10.0001C18.3333 14.6025 14.6023 18.3334 9.99996 18.3334C5.39759 18.3334 1.66663 14.6025 1.66663 10.0001C1.66663 5.39771 5.39759 1.66675 9.99996 1.66675C14.6023 1.66675 18.3333 5.39771 18.3333 10.0001Z" stroke="#F79009" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningMask: FC<IWarningMaskProps> = ({
|
||||
title,
|
||||
description,
|
||||
footer,
|
||||
}) => {
|
||||
return (
|
||||
<div className={`${s.mask} absolute inset-0 z-10 bg-components-panel-bg-blur pt-16`}
|
||||
>
|
||||
<div className='mx-auto px-10'>
|
||||
<div className={`${s.icon} flex h-11 w-11 items-center justify-center rounded-xl bg-components-panel-bg`}>{warningIcon}</div>
|
||||
<div className='mt-4 text-[24px] font-semibold leading-normal text-text-primary'>
|
||||
{title}
|
||||
</div>
|
||||
<div className='mt-3 text-base text-text-secondary'>
|
||||
{description}
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
{footer}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(WarningMask)
|
||||
@@ -0,0 +1,7 @@
|
||||
.mask {
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.icon {
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiErrorWarningFill,
|
||||
} from '@remixicon/react'
|
||||
import s from './style.module.css'
|
||||
import MessageTypeSelector from './message-type-selector'
|
||||
import ConfirmAddVar from './confirm-add-var'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PromptRole, PromptVariable } from '@/models/debug'
|
||||
import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
} from '@/app/components/base/icons/src/vender/line/files'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { getNewVar, getVars } from '@/utils/var'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
|
||||
type Props = {
|
||||
type: PromptRole
|
||||
isChatMode: boolean
|
||||
value: string
|
||||
onTypeChange: (value: PromptRole) => void
|
||||
onChange: (value: string) => void
|
||||
canDelete: boolean
|
||||
onDelete: () => void
|
||||
promptVariables: PromptVariable[]
|
||||
isContextMissing: boolean
|
||||
onHideContextMissingTip: () => void
|
||||
noResize?: boolean
|
||||
}
|
||||
|
||||
const AdvancedPromptInput: FC<Props> = ({
|
||||
type,
|
||||
isChatMode,
|
||||
value,
|
||||
onChange,
|
||||
onTypeChange,
|
||||
canDelete,
|
||||
onDelete,
|
||||
promptVariables,
|
||||
isContextMissing,
|
||||
onHideContextMissingTip,
|
||||
noResize,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const {
|
||||
mode,
|
||||
hasSetBlockStatus,
|
||||
modelConfig,
|
||||
setModelConfig,
|
||||
conversationHistoriesRole,
|
||||
showHistoryModal,
|
||||
dataSets,
|
||||
showSelectDataSet,
|
||||
externalDataToolsConfig,
|
||||
} = useContext(ConfigContext)
|
||||
const { notify } = useToastContext()
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
const handleOpenExternalDataToolModal = () => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
eventEmitter?.emit({
|
||||
type: ADD_EXTERNAL_DATA_TOOL,
|
||||
payload: newExternalDataTool,
|
||||
} as any)
|
||||
eventEmitter?.emit({
|
||||
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
|
||||
payload: newExternalDataTool.variable,
|
||||
} as any)
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable) {
|
||||
notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { key: promptVariables[i].key }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
const isChatApp = mode !== AppModeEnum.COMPLETION
|
||||
const [isCopied, setIsCopied] = React.useState(false)
|
||||
|
||||
const promptVariablesObj = (() => {
|
||||
const obj: Record<string, boolean> = {}
|
||||
promptVariables.forEach((item) => {
|
||||
obj[item.key] = true
|
||||
})
|
||||
return obj
|
||||
})()
|
||||
const [newPromptVariables, setNewPromptVariables] = React.useState<PromptVariable[]>(promptVariables)
|
||||
const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false)
|
||||
const handlePromptChange = (newValue: string) => {
|
||||
if (value === newValue)
|
||||
return
|
||||
onChange(newValue)
|
||||
}
|
||||
const handleBlur = () => {
|
||||
const keys = getVars(value)
|
||||
const newPromptVariables = keys.filter(key => !(key in promptVariablesObj) && !externalDataToolsConfig.find(item => item.variable === key)).map(key => getNewVar(key, ''))
|
||||
if (newPromptVariables.length > 0) {
|
||||
setNewPromptVariables(newPromptVariables)
|
||||
showConfirmAddVar()
|
||||
}
|
||||
}
|
||||
|
||||
const handleAutoAdd = (isAdd: boolean) => {
|
||||
return () => {
|
||||
if (isAdd) {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newPromptVariables]
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}
|
||||
hideConfirmAddVar()
|
||||
}
|
||||
}
|
||||
|
||||
const minHeight = 102
|
||||
const [editorHeight, setEditorHeight] = React.useState(isChatMode ? 200 : 508)
|
||||
const contextMissing = (
|
||||
<div
|
||||
className='flex h-11 items-center justify-between rounded-tl-xl rounded-tr-xl pb-1 pl-4 pr-3 pt-2'
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, #FEF0C7 0%, rgba(254, 240, 199, 0) 100%)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center pr-2' >
|
||||
<RiErrorWarningFill className='mr-1 h-4 w-4 text-[#F79009]' />
|
||||
<div className='text-[13px] font-medium leading-[18px] text-[#DC6803]'>{t('appDebug.promptMode.contextMissing')}</div>
|
||||
</div>
|
||||
<Button
|
||||
size='small'
|
||||
variant='secondary-accent'
|
||||
onClick={onHideContextMissingTip}
|
||||
>{t('common.operation.ok')}</Button>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className={`rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs ${!isContextMissing ? '' : s.warningBorder}`}>
|
||||
<div className='rounded-xl bg-background-default'>
|
||||
{isContextMissing
|
||||
? contextMissing
|
||||
: (
|
||||
<div className={cn(s.boxHeader, 'flex h-11 items-center justify-between rounded-tl-xl rounded-tr-xl bg-background-default pb-1 pl-4 pr-3 pt-2 hover:shadow-xs')}>
|
||||
{isChatMode
|
||||
? (
|
||||
<MessageTypeSelector value={type} onChange={onTypeChange} />
|
||||
)
|
||||
: (
|
||||
<div className='flex items-center space-x-1'>
|
||||
|
||||
<div className='text-sm font-semibold uppercase text-indigo-800'>{t('appDebug.pageTitle.line1')}
|
||||
</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
{t('appDebug.promptTip')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>)}
|
||||
<div className={cn(s.optionWrap, 'items-center space-x-1')}>
|
||||
{canDelete && (
|
||||
<RiDeleteBinLine onClick={onDelete} className='h-6 w-6 cursor-pointer p-1 text-text-tertiary' />
|
||||
)}
|
||||
{!isCopied
|
||||
? (
|
||||
<Copy className='h-6 w-6 cursor-pointer p-1 text-text-tertiary' onClick={() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}} />
|
||||
)
|
||||
: (
|
||||
<CopyCheck className='h-6 w-6 p-1 text-text-tertiary' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptEditorHeightResizeWrap
|
||||
className='min-h-[102px] overflow-y-auto px-4 text-sm text-text-secondary'
|
||||
height={editorHeight}
|
||||
minHeight={minHeight}
|
||||
onHeightChange={setEditorHeight}
|
||||
footer={(
|
||||
<div className='flex pb-2 pl-4'>
|
||||
<div className="h-[18px] rounded-md bg-divider-regular px-1 text-xs leading-[18px] text-text-tertiary">{value.length}</div>
|
||||
</div>
|
||||
)}
|
||||
hideResize={noResize}
|
||||
>
|
||||
<PromptEditor
|
||||
className='min-h-[84px]'
|
||||
value={value}
|
||||
contextBlock={{
|
||||
show: true,
|
||||
selectable: !hasSetBlockStatus.context,
|
||||
datasets: dataSets.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.data_source_type,
|
||||
})),
|
||||
onAddContext: showSelectDataSet,
|
||||
}}
|
||||
variableBlock={{
|
||||
show: true,
|
||||
variables: modelConfig.configs.prompt_variables.filter(item => item.type !== 'api' && item.key && item.key.trim() && item.name && item.name.trim()).map(item => ({
|
||||
name: item.name,
|
||||
value: item.key,
|
||||
})),
|
||||
}}
|
||||
externalToolBlock={{
|
||||
externalTools: modelConfig.configs.prompt_variables.filter(item => item.type === 'api').map(item => ({
|
||||
name: item.name,
|
||||
variableName: item.key,
|
||||
icon: item.icon,
|
||||
icon_background: item.icon_background,
|
||||
})),
|
||||
onAddExternalTool: handleOpenExternalDataToolModal,
|
||||
}}
|
||||
historyBlock={{
|
||||
show: !isChatMode && isChatApp,
|
||||
selectable: !hasSetBlockStatus.history,
|
||||
history: {
|
||||
user: conversationHistoriesRole?.user_prefix,
|
||||
assistant: conversationHistoriesRole?.assistant_prefix,
|
||||
},
|
||||
onEditRole: showHistoryModal,
|
||||
}}
|
||||
queryBlock={{
|
||||
show: !isChatMode && isChatApp,
|
||||
selectable: !hasSetBlockStatus.query,
|
||||
}}
|
||||
onChange={handlePromptChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
</PromptEditorHeightResizeWrap>
|
||||
|
||||
</div>
|
||||
|
||||
{isShowConfirmAddVar && (
|
||||
<ConfirmAddVar
|
||||
varNameArr={newPromptVariables.map(v => v.name)}
|
||||
onConfirm={handleAutoAdd(true)}
|
||||
onCancel={handleAutoAdd(false)}
|
||||
onHide={hideConfirmAddVar}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AdvancedPromptInput)
|
||||
@@ -0,0 +1,69 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import VarHighlight from '../../base/var-highlight'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IConfirmAddVarProps = {
|
||||
varNameArr: string[]
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
onHide: () => void
|
||||
}
|
||||
|
||||
const VarIcon = (
|
||||
<svg width="16" height="14" viewBox="0 0 16 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.8683 0.704745C13.7051 0.374685 13.3053 0.239393 12.9752 0.402563C12.6452 0.565732 12.5099 0.965573 12.673 1.29563C13.5221 3.01316 13.9999 4.94957 13.9999 7.00019C13.9999 9.05081 13.5221 10.9872 12.673 12.7047C12.5099 13.0348 12.6452 13.4346 12.9752 13.5978C13.3053 13.761 13.7051 13.6257 13.8683 13.2956C14.8063 11.3983 15.3333 9.26009 15.3333 7.00019C15.3333 4.74029 14.8063 2.60209 13.8683 0.704745Z" fill="#FD853A" />
|
||||
<path d="M3.32687 1.29563C3.49004 0.965573 3.35475 0.565732 3.02469 0.402563C2.69463 0.239393 2.29479 0.374685 2.13162 0.704745C1.19364 2.60209 0.666626 4.74029 0.666626 7.00019C0.666626 9.26009 1.19364 11.3983 2.13162 13.2956C2.29479 13.6257 2.69463 13.761 3.02469 13.5978C3.35475 13.4346 3.49004 13.0348 3.32687 12.7047C2.47779 10.9872 1.99996 9.05081 1.99996 7.00019C1.99996 4.94957 2.47779 3.01316 3.32687 1.29563Z" fill="#FD853A" />
|
||||
<path d="M9.33238 4.8413C9.74208 4.36081 10.3411 4.08337 10.9726 4.08337H11.0324C11.4006 4.08337 11.6991 4.38185 11.6991 4.75004C11.6991 5.11823 11.4006 5.41671 11.0324 5.41671H10.9726C10.7329 5.41671 10.5042 5.52196 10.347 5.7064L8.78693 7.536L9.28085 9.27382C9.29145 9.31112 9.32388 9.33337 9.35696 9.33337H10.2864C10.6545 9.33337 10.953 9.63185 10.953 10C10.953 10.3682 10.6545 10.6667 10.2864 10.6667H9.35696C8.72382 10.6667 8.17074 10.245 7.99832 9.63834L7.74732 8.75524L6.76373 9.90878C6.35403 10.3893 5.75501 10.6667 5.1235 10.6667H5.06372C4.69553 10.6667 4.39705 10.3682 4.39705 10C4.39705 9.63185 4.69553 9.33337 5.06372 9.33337H5.1235C5.3632 9.33337 5.59189 9.22812 5.74915 9.04368L7.30926 7.21399L6.81536 5.47626C6.80476 5.43897 6.77233 5.41671 6.73925 5.41671H5.80986C5.44167 5.41671 5.14319 5.11823 5.14319 4.75004C5.14319 4.38185 5.44167 4.08337 5.80986 4.08337H6.73925C7.37239 4.08337 7.92547 4.50508 8.0979 5.11174L8.34887 5.99475L9.33238 4.8413Z" fill="#FD853A" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ConfirmAddVar: FC<IConfirmAddVarProps> = ({
|
||||
varNameArr,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
// onHide,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const mainContentRef = useRef<HTMLDivElement>(null)
|
||||
// new prompt editor blur trigger click...
|
||||
// useClickAway(() => {
|
||||
// onHide()
|
||||
// }, mainContentRef)
|
||||
return (
|
||||
<div className='absolute inset-0 flex items-center justify-center rounded-xl'
|
||||
style={{
|
||||
backgroundColor: 'rgba(35, 56, 118, 0.2)',
|
||||
}}>
|
||||
<div
|
||||
ref={mainContentRef}
|
||||
className='w-[420px] rounded-xl bg-components-panel-bg p-6'
|
||||
style={{
|
||||
boxShadow: '0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03)',
|
||||
}}
|
||||
>
|
||||
<div className='flex items-start space-x-3'>
|
||||
<div
|
||||
className='flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-components-card-border bg-components-card-bg-alt shadow-lg'
|
||||
>{VarIcon}</div>
|
||||
<div className='grow-1'>
|
||||
<div className='text-sm font-medium text-text-primary'>{t('appDebug.autoAddVar')}</div>
|
||||
<div className='mt-[15px] flex max-h-[66px] flex-wrap space-x-1 overflow-y-auto px-1'>
|
||||
{varNameArr.map(name => (
|
||||
<VarHighlight key={name} name={name} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className='mt-7 flex justify-end space-x-2'>
|
||||
<Button onClick={onCancel}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={onConfirm}>{t('common.operation.add')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfirmAddVar)
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { ConversationHistoriesRole } from '@/models/debug'
|
||||
import Button from '@/app/components/base/button'
|
||||
type Props = {
|
||||
isShow: boolean
|
||||
saveLoading: boolean
|
||||
data: ConversationHistoriesRole
|
||||
onClose: () => void
|
||||
onSave: (data: any) => void
|
||||
}
|
||||
|
||||
const EditModal: FC<Props> = ({
|
||||
isShow,
|
||||
saveLoading,
|
||||
data,
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempData, setTempData] = useState(data)
|
||||
return (
|
||||
<Modal
|
||||
title={t('appDebug.feature.conversationHistory.editModal.title')}
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className={'mt-6 text-sm font-medium leading-[21px] text-text-primary'}>{t('appDebug.feature.conversationHistory.editModal.userPrefix')}</div>
|
||||
<input className={'mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10'}
|
||||
value={tempData.user_prefix}
|
||||
onChange={e => setTempData({
|
||||
...tempData,
|
||||
user_prefix: e.target.value,
|
||||
})}
|
||||
/>
|
||||
|
||||
<div className={'mt-6 text-sm font-medium leading-[21px] text-text-primary'}>{t('appDebug.feature.conversationHistory.editModal.assistantPrefix')}</div>
|
||||
<input className={'mt-2 box-border h-10 w-full rounded-lg bg-components-input-bg-normal px-3 text-sm leading-10'}
|
||||
value={tempData.assistant_prefix}
|
||||
onChange={e => setTempData({
|
||||
...tempData,
|
||||
assistant_prefix: e.target.value,
|
||||
})}
|
||||
placeholder={t('common.chat.conversationNamePlaceholder') || ''}
|
||||
/>
|
||||
|
||||
<div className='mt-10 flex justify-end'>
|
||||
<Button className='mr-2 shrink-0' onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='shrink-0' onClick={() => onSave(tempData)} loading={saveLoading}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(EditModal)
|
||||
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
import Panel from '@/app/components/app/configuration/base/feature-panel'
|
||||
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type Props = {
|
||||
showWarning: boolean
|
||||
onShowEditModal: () => void
|
||||
}
|
||||
|
||||
const HistoryPanel: FC<Props> = ({
|
||||
showWarning,
|
||||
onShowEditModal,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<Panel
|
||||
className='mt-2'
|
||||
title={
|
||||
<div className='flex items-center gap-2'>
|
||||
<div>{t('appDebug.feature.conversationHistory.title')}</div>
|
||||
</div>
|
||||
}
|
||||
headerIcon={
|
||||
<div className='rounded-md p-1 shadow-xs'>
|
||||
<MessageClockCircle className='h-4 w-4 text-[#DD2590]' />
|
||||
</div>}
|
||||
headerRight={
|
||||
<div className='flex items-center'>
|
||||
<div className='text-xs text-text-tertiary'>{t('appDebug.feature.conversationHistory.description')}</div>
|
||||
<div className='ml-3 h-[14px] w-[1px] bg-divider-regular'></div>
|
||||
<OperationBtn type="edit" onClick={onShowEditModal} />
|
||||
</div>
|
||||
}
|
||||
noBodySpacing
|
||||
>
|
||||
{showWarning && (
|
||||
<div className='flex justify-between rounded-b-xl bg-background-section-burn px-3 py-2 text-xs text-text-secondary'>
|
||||
<div>{t('appDebug.feature.conversationHistory.tip')}
|
||||
<a href={docLink('/learn-more/extended-reading/what-is-llmops',
|
||||
{ 'zh-Hans': '/learn-more/extended-reading/prompt-engineering/README' })}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='text-[#155EEF]'>{t('appDebug.feature.conversationHistory.learnMore')}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
export default React.memo(HistoryPanel)
|
||||
@@ -0,0 +1,172 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiAddLine,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import SimplePromptInput from './simple-prompt-input'
|
||||
import Button from '@/app/components/base/button'
|
||||
import AdvancedMessageInput from '@/app/components/app/configuration/config-prompt/advanced-prompt-input'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import type { PromptItem, PromptVariable } from '@/models/debug'
|
||||
import type { AppModeEnum } from '@/types/app'
|
||||
import { ModelModeType } from '@/types/app'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
|
||||
|
||||
export type IPromptProps = {
|
||||
mode: AppModeEnum
|
||||
promptTemplate: string
|
||||
promptVariables: PromptVariable[]
|
||||
readonly?: boolean
|
||||
noTitle?: boolean
|
||||
gradientBorder?: boolean
|
||||
editorHeight?: number
|
||||
noResize?: boolean
|
||||
onChange?: (prompt: string, promptVariables: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
const Prompt: FC<IPromptProps> = ({
|
||||
mode,
|
||||
promptTemplate,
|
||||
promptVariables,
|
||||
noTitle,
|
||||
gradientBorder,
|
||||
readonly = false,
|
||||
editorHeight,
|
||||
noResize,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const {
|
||||
isAdvancedMode,
|
||||
currentAdvancedPrompt,
|
||||
setCurrentAdvancedPrompt,
|
||||
modelModeType,
|
||||
dataSets,
|
||||
hasSetBlockStatus,
|
||||
} = useContext(ConfigContext)
|
||||
|
||||
const handleMessageTypeChange = (index: number, role: PromptRole) => {
|
||||
const newPrompt = produce(currentAdvancedPrompt as PromptItem[], (draft) => {
|
||||
draft[index].role = role
|
||||
})
|
||||
setCurrentAdvancedPrompt(newPrompt)
|
||||
}
|
||||
|
||||
const handleValueChange = (value: string, index?: number) => {
|
||||
if (modelModeType === ModelModeType.chat) {
|
||||
const newPrompt = produce(currentAdvancedPrompt as PromptItem[], (draft) => {
|
||||
draft[index as number].text = value
|
||||
})
|
||||
setCurrentAdvancedPrompt(newPrompt, true)
|
||||
}
|
||||
else {
|
||||
const prompt = currentAdvancedPrompt as PromptItem
|
||||
setCurrentAdvancedPrompt({
|
||||
...prompt,
|
||||
text: value,
|
||||
}, true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddMessage = () => {
|
||||
const currentAdvancedPromptList = currentAdvancedPrompt as PromptItem[]
|
||||
if (currentAdvancedPromptList.length === 0) {
|
||||
setCurrentAdvancedPrompt([{
|
||||
role: PromptRole.system,
|
||||
text: '',
|
||||
}])
|
||||
return
|
||||
}
|
||||
const lastMessageType = currentAdvancedPromptList[currentAdvancedPromptList.length - 1]?.role
|
||||
const appendMessage = {
|
||||
role: lastMessageType === PromptRole.user ? PromptRole.assistant : PromptRole.user,
|
||||
text: '',
|
||||
}
|
||||
setCurrentAdvancedPrompt([...currentAdvancedPromptList, appendMessage])
|
||||
}
|
||||
|
||||
const handlePromptDelete = (index: number) => {
|
||||
const currentAdvancedPromptList = currentAdvancedPrompt as PromptItem[]
|
||||
const newPrompt = produce(currentAdvancedPromptList, (draft) => {
|
||||
draft.splice(index, 1)
|
||||
})
|
||||
setCurrentAdvancedPrompt(newPrompt)
|
||||
}
|
||||
|
||||
const isContextMissing = dataSets.length > 0 && !hasSetBlockStatus.context
|
||||
const [isHideContextMissTip, setIsHideContextMissTip] = React.useState(false)
|
||||
|
||||
if (!isAdvancedMode) {
|
||||
return (
|
||||
<SimplePromptInput
|
||||
mode={mode}
|
||||
promptTemplate={promptTemplate}
|
||||
promptVariables={promptVariables}
|
||||
readonly={readonly}
|
||||
onChange={onChange}
|
||||
noTitle={noTitle}
|
||||
gradientBorder={gradientBorder}
|
||||
editorHeight={editorHeight}
|
||||
noResize={noResize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='space-y-3'>
|
||||
{modelModeType === ModelModeType.chat
|
||||
? (
|
||||
(currentAdvancedPrompt as PromptItem[]).map((item, index) => (
|
||||
<AdvancedMessageInput
|
||||
key={index}
|
||||
isChatMode
|
||||
type={item.role as PromptRole}
|
||||
value={item.text}
|
||||
onTypeChange={type => handleMessageTypeChange(index, type)}
|
||||
canDelete={(currentAdvancedPrompt as PromptItem[]).length > 1}
|
||||
onDelete={() => handlePromptDelete(index)}
|
||||
onChange={value => handleValueChange(value, index)}
|
||||
promptVariables={promptVariables}
|
||||
isContextMissing={isContextMissing && !isHideContextMissTip}
|
||||
onHideContextMissingTip={() => setIsHideContextMissTip(true)}
|
||||
noResize={noResize}
|
||||
/>
|
||||
))
|
||||
)
|
||||
: (
|
||||
<AdvancedMessageInput
|
||||
type={(currentAdvancedPrompt as PromptItem).role as PromptRole}
|
||||
isChatMode={false}
|
||||
value={(currentAdvancedPrompt as PromptItem).text}
|
||||
onTypeChange={type => handleMessageTypeChange(0, type)}
|
||||
canDelete={false}
|
||||
onDelete={() => handlePromptDelete(0)}
|
||||
onChange={value => handleValueChange(value)}
|
||||
promptVariables={promptVariables}
|
||||
isContextMissing={isContextMissing && !isHideContextMissTip}
|
||||
onHideContextMissingTip={() => setIsHideContextMissTip(true)}
|
||||
noResize={noResize}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{(modelModeType === ModelModeType.chat && (currentAdvancedPrompt as PromptItem[]).length < MAX_PROMPT_MESSAGE_LENGTH) && (
|
||||
<Button
|
||||
onClick={handleAddMessage}
|
||||
className='mt-3 w-full'>
|
||||
<RiAddLine className='mr-2 h-4 w-4' />
|
||||
<div>{t('appDebug.promptMode.operation.addMessage')}</div>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Prompt)
|
||||
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useBoolean, useClickAway } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
import { PromptRole } from '@/models/debug'
|
||||
import { ChevronSelectorVertical } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
type Props = {
|
||||
value: PromptRole
|
||||
onChange: (value: PromptRole) => void
|
||||
}
|
||||
|
||||
const allTypes = [PromptRole.system, PromptRole.user, PromptRole.assistant]
|
||||
const MessageTypeSelector: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const [showOption, { setFalse: setHide, toggle: toggleShow }] = useBoolean(false)
|
||||
const ref = React.useRef(null)
|
||||
useClickAway(() => {
|
||||
setHide()
|
||||
}, ref)
|
||||
return (
|
||||
<div className='relative left-[-8px]' ref={ref}>
|
||||
<div
|
||||
onClick={toggleShow}
|
||||
className={cn(showOption && 'bg-indigo-100', 'flex h-7 cursor-pointer items-center space-x-0.5 rounded-lg pl-1.5 pr-1 text-indigo-800')}>
|
||||
<div className='text-sm font-semibold uppercase'>{value}</div>
|
||||
<ChevronSelectorVertical className='h-3 w-3 ' />
|
||||
</div>
|
||||
{showOption && (
|
||||
<div className='absolute top-[30px] z-10 rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
|
||||
{allTypes.map(type => (
|
||||
<div
|
||||
key={type}
|
||||
onClick={() => {
|
||||
setHide()
|
||||
onChange(type)
|
||||
}}
|
||||
className='flex h-9 min-w-[44px] cursor-pointer items-center rounded-lg px-3 text-sm font-medium uppercase text-text-secondary hover:bg-state-base-hover'
|
||||
>{type}</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(MessageTypeSelector)
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useDebounceFn } from 'ahooks'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
height: number
|
||||
minHeight: number
|
||||
onHeightChange: (height: number) => void
|
||||
children: React.JSX.Element
|
||||
footer?: React.JSX.Element
|
||||
hideResize?: boolean
|
||||
}
|
||||
|
||||
const PromptEditorHeightResizeWrap: FC<Props> = ({
|
||||
className,
|
||||
height,
|
||||
minHeight,
|
||||
onHeightChange,
|
||||
children,
|
||||
footer,
|
||||
hideResize,
|
||||
}) => {
|
||||
const [clientY, setClientY] = useState(0)
|
||||
const [isResizing, setIsResizing] = useState(false)
|
||||
const [prevUserSelectStyle, setPrevUserSelectStyle] = useState(() => getComputedStyle(document.body).userSelect)
|
||||
const [oldHeight, setOldHeight] = useState(height)
|
||||
|
||||
const handleStartResize = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
setClientY(e.clientY)
|
||||
setIsResizing(true)
|
||||
setOldHeight(height)
|
||||
setPrevUserSelectStyle(getComputedStyle(document.body).userSelect)
|
||||
document.body.style.userSelect = 'none'
|
||||
}, [height])
|
||||
|
||||
const handleStopResize = useCallback(() => {
|
||||
setIsResizing(false)
|
||||
document.body.style.userSelect = prevUserSelectStyle
|
||||
}, [prevUserSelectStyle])
|
||||
|
||||
const { run: didHandleResize } = useDebounceFn((e) => {
|
||||
if (!isResizing)
|
||||
return
|
||||
|
||||
const offset = e.clientY - clientY
|
||||
let newHeight = oldHeight + offset
|
||||
if (newHeight < minHeight)
|
||||
newHeight = minHeight
|
||||
onHeightChange(newHeight)
|
||||
}, {
|
||||
wait: 0,
|
||||
})
|
||||
|
||||
const handleResize = useCallback(didHandleResize, [isResizing, height, minHeight, clientY])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mousemove', handleResize)
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleResize)
|
||||
}
|
||||
}, [handleResize])
|
||||
|
||||
useEffect(() => {
|
||||
document.addEventListener('mouseup', handleStopResize)
|
||||
return () => {
|
||||
document.removeEventListener('mouseup', handleStopResize)
|
||||
}
|
||||
}, [handleStopResize])
|
||||
|
||||
return (
|
||||
<div
|
||||
className='relative'
|
||||
>
|
||||
<div className={cn(className, 'overflow-y-auto')}
|
||||
style={{
|
||||
height,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{/* resize handler */}
|
||||
{footer}
|
||||
{!hideResize && (
|
||||
<div
|
||||
className='absolute bottom-0 left-0 flex h-2 w-full cursor-row-resize justify-center'
|
||||
onMouseDown={handleStartResize}>
|
||||
<div className='h-[3px] w-5 rounded-sm bg-gray-300'></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(PromptEditorHeightResizeWrap)
|
||||
@@ -0,0 +1,291 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ConfirmAddVar from './confirm-add-var'
|
||||
import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { getNewVar, getVars } from '@/utils/var'
|
||||
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import GetAutomaticResModal from '@/app/components/app/configuration/config/automatic/get-automatic-res'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import { ADD_EXTERNAL_DATA_TOOL } from '@/app/components/app/configuration/config-var'
|
||||
import { INSERT_VARIABLE_VALUE_BLOCK_COMMAND } from '@/app/components/base/prompt-editor/plugins/variable-block'
|
||||
import { PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER } from '@/app/components/base/prompt-editor/plugins/update-block'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type ISimplePromptInput = {
|
||||
mode: AppModeEnum
|
||||
promptTemplate: string
|
||||
promptVariables: PromptVariable[]
|
||||
readonly?: boolean
|
||||
onChange?: (prompt: string, promptVariables: PromptVariable[]) => void
|
||||
noTitle?: boolean
|
||||
gradientBorder?: boolean
|
||||
editorHeight?: number
|
||||
noResize?: boolean
|
||||
}
|
||||
|
||||
const Prompt: FC<ISimplePromptInput> = ({
|
||||
mode,
|
||||
promptTemplate,
|
||||
promptVariables,
|
||||
readonly = false,
|
||||
onChange,
|
||||
noTitle,
|
||||
editorHeight: initEditorHeight,
|
||||
noResize,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const media = useBreakpoints()
|
||||
const isMobile = media === MediaType.mobile
|
||||
const featuresStore = useFeaturesStore()
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const {
|
||||
appId,
|
||||
modelConfig,
|
||||
dataSets,
|
||||
setModelConfig,
|
||||
setPrevPromptConfig,
|
||||
setIntroduction,
|
||||
hasSetBlockStatus,
|
||||
showSelectDataSet,
|
||||
externalDataToolsConfig,
|
||||
} = useContext(ConfigContext)
|
||||
const { notify } = useToastContext()
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
const handleOpenExternalDataToolModal = () => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
eventEmitter?.emit({
|
||||
type: ADD_EXTERNAL_DATA_TOOL,
|
||||
payload: newExternalDataTool,
|
||||
} as any)
|
||||
eventEmitter?.emit({
|
||||
type: INSERT_VARIABLE_VALUE_BLOCK_COMMAND,
|
||||
payload: newExternalDataTool.variable,
|
||||
} as any)
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable) {
|
||||
notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { key: promptVariables[i].key }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const [newPromptVariables, setNewPromptVariables] = React.useState<PromptVariable[]>(promptVariables)
|
||||
const [newTemplates, setNewTemplates] = React.useState('')
|
||||
const [isShowConfirmAddVar, { setTrue: showConfirmAddVar, setFalse: hideConfirmAddVar }] = useBoolean(false)
|
||||
|
||||
const handleChange = (newTemplates: string, keys: string[]) => {
|
||||
// Filter out keys that are not properly defined (either not exist or exist but without valid name)
|
||||
const newPromptVariables = keys.filter((key) => {
|
||||
// Check if key exists in external data tools
|
||||
if (externalDataToolsConfig.find((item: ExternalDataTool) => item.variable === key))
|
||||
return false
|
||||
|
||||
// Check if key exists in prompt variables
|
||||
const existingVar = promptVariables.find((item: PromptVariable) => item.key === key)
|
||||
if (!existingVar) {
|
||||
// Variable doesn't exist at all
|
||||
return true
|
||||
}
|
||||
|
||||
// Variable exists but check if it has valid name and key
|
||||
return !existingVar.name || !existingVar.name.trim() || !existingVar.key || !existingVar.key.trim()
|
||||
|
||||
return false
|
||||
}).map(key => getNewVar(key, ''))
|
||||
|
||||
if (newPromptVariables.length > 0) {
|
||||
setNewPromptVariables(newPromptVariables)
|
||||
setNewTemplates(newTemplates)
|
||||
showConfirmAddVar()
|
||||
return
|
||||
}
|
||||
onChange?.(newTemplates, [])
|
||||
}
|
||||
|
||||
const handleAutoAdd = (isAdd: boolean) => {
|
||||
return () => {
|
||||
onChange?.(newTemplates, isAdd ? newPromptVariables : [])
|
||||
hideConfirmAddVar()
|
||||
}
|
||||
}
|
||||
|
||||
const [showAutomatic, { setTrue: showAutomaticTrue, setFalse: showAutomaticFalse }] = useBoolean(false)
|
||||
const handleAutomaticRes = (res: GenRes) => {
|
||||
// put eventEmitter in first place to prevent overwrite the configs.prompt_variables.But another problem is that prompt won't hight the prompt_variables.
|
||||
eventEmitter?.emit({
|
||||
type: PROMPT_EDITOR_UPDATE_VALUE_BY_EVENT_EMITTER,
|
||||
payload: res.modified,
|
||||
} as any)
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.configs.prompt_template = res.modified
|
||||
draft.configs.prompt_variables = (res.variables || []).map(key => ({ key, name: key, type: 'string', required: true }))
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
setPrevPromptConfig(modelConfig.configs)
|
||||
|
||||
if (mode !== AppModeEnum.COMPLETION) {
|
||||
setIntroduction(res.opening_statement || '')
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.opening = {
|
||||
...draft.opening,
|
||||
enabled: !!res.opening_statement,
|
||||
opening_statement: res.opening_statement,
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
}
|
||||
showAutomaticFalse()
|
||||
}
|
||||
const minHeight = initEditorHeight || 228
|
||||
const [editorHeight, setEditorHeight] = useState(minHeight)
|
||||
|
||||
return (
|
||||
<div className={cn('relative rounded-xl bg-gradient-to-r from-components-input-border-active-prompt-1 to-components-input-border-active-prompt-2 p-0.5 shadow-xs')}>
|
||||
<div className='rounded-xl bg-background-section-burn'>
|
||||
{!noTitle && (
|
||||
<div className="flex h-11 items-center justify-between pl-3 pr-2.5">
|
||||
<div className="flex items-center space-x-1">
|
||||
<div className='h2 system-sm-semibold-uppercase text-text-secondary'>{mode !== AppModeEnum.COMPLETION ? t('appDebug.chatSubTitle') : t('appDebug.completionSubTitle')}</div>
|
||||
{!readonly && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
{t('appDebug.promptTip')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
{!readonly && !isMobile && (
|
||||
<AutomaticBtn onClick={showAutomaticTrue} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<PromptEditorHeightResizeWrap
|
||||
className='min-h-[228px] rounded-t-xl bg-background-default px-4 pt-2 text-sm text-text-secondary'
|
||||
height={editorHeight}
|
||||
minHeight={minHeight}
|
||||
onHeightChange={setEditorHeight}
|
||||
hideResize={noResize}
|
||||
footer={(
|
||||
<div className='flex rounded-b-xl bg-background-default pb-2 pl-4'>
|
||||
<div className="h-[18px] rounded-md bg-components-badge-bg-gray-soft px-1 text-xs leading-[18px] text-text-tertiary">{promptTemplate.length}</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<PromptEditor
|
||||
className='min-h-[210px]'
|
||||
compact
|
||||
value={promptTemplate}
|
||||
contextBlock={{
|
||||
show: false,
|
||||
selectable: !hasSetBlockStatus.context,
|
||||
datasets: dataSets.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.data_source_type,
|
||||
})),
|
||||
onAddContext: showSelectDataSet,
|
||||
}}
|
||||
variableBlock={{
|
||||
show: true,
|
||||
variables: modelConfig.configs.prompt_variables.filter((item: PromptVariable) => item.type !== 'api' && item.key && item.key.trim() && item.name && item.name.trim()).map((item: PromptVariable) => ({
|
||||
name: item.name,
|
||||
value: item.key,
|
||||
})),
|
||||
}}
|
||||
externalToolBlock={{
|
||||
show: true,
|
||||
externalTools: modelConfig.configs.prompt_variables.filter((item: PromptVariable) => item.type === 'api').map((item: PromptVariable) => ({
|
||||
name: item.name,
|
||||
variableName: item.key,
|
||||
icon: item.icon,
|
||||
icon_background: item.icon_background,
|
||||
})),
|
||||
onAddExternalTool: handleOpenExternalDataToolModal,
|
||||
}}
|
||||
historyBlock={{
|
||||
show: false,
|
||||
selectable: false,
|
||||
history: {
|
||||
user: '',
|
||||
assistant: '',
|
||||
},
|
||||
onEditRole: noop,
|
||||
}}
|
||||
queryBlock={{
|
||||
show: false,
|
||||
selectable: !hasSetBlockStatus.query,
|
||||
}}
|
||||
onChange={(value) => {
|
||||
if (handleChange)
|
||||
handleChange(value, [])
|
||||
}}
|
||||
onBlur={() => {
|
||||
handleChange(promptTemplate, getVars(promptTemplate))
|
||||
}}
|
||||
editable={!readonly}
|
||||
/>
|
||||
</PromptEditorHeightResizeWrap>
|
||||
</div>
|
||||
|
||||
{isShowConfirmAddVar && (
|
||||
<ConfirmAddVar
|
||||
varNameArr={newPromptVariables.map(v => v.name)}
|
||||
onConfirm={handleAutoAdd(true)}
|
||||
onCancel={handleAutoAdd(false)}
|
||||
onHide={hideConfirmAddVar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAutomatic && (
|
||||
<GetAutomaticResModal
|
||||
flowId={appId}
|
||||
mode={mode as AppModeEnum}
|
||||
isShow={showAutomatic}
|
||||
onClose={showAutomaticFalse}
|
||||
onFinished={handleAutomaticRes}
|
||||
currentPrompt={promptTemplate}
|
||||
isBasicMode
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(Prompt)
|
||||
@@ -0,0 +1,28 @@
|
||||
.gradientBorder {
|
||||
background: radial-gradient(circle at 100% 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 0%/12px 12px no-repeat,
|
||||
radial-gradient(circle at 0 100%, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 0%/12px 12px no-repeat,
|
||||
radial-gradient(circle at 100% 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 0% 100%/12px 12px no-repeat,
|
||||
radial-gradient(circle at 0 0, #fcfcfd 0, #fcfcfd 10px, transparent 10px) 100% 100%/12px 12px no-repeat,
|
||||
linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 4px) calc(100% - 24px) no-repeat,
|
||||
linear-gradient(#fcfcfd, #fcfcfd) 50% 50%/calc(100% - 24px) calc(100% - 4px) no-repeat,
|
||||
radial-gradient(at 100% 100%, rgba(45,13,238,0.8) 0%, transparent 70%),
|
||||
radial-gradient(at 100% 0%, rgba(45,13,238,0.8) 0%, transparent 70%),
|
||||
radial-gradient(at 0% 0%, rgba(42,135,245,0.8) 0%, transparent 70%),
|
||||
radial-gradient(at 0% 100%, rgba(42,135,245,0.8) 0%, transparent 70%);
|
||||
border-radius: 12px;
|
||||
padding: 2px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.warningBorder {
|
||||
border: 2px solid #F79009;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.optionWrap {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.boxHeader:hover .optionWrap {
|
||||
display: flex;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export const jsonObjectWrap = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
}
|
||||
|
||||
export const jsonConfigPlaceHolder = JSON.stringify(
|
||||
{
|
||||
foo: {
|
||||
type: 'string',
|
||||
},
|
||||
bar: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
sub: {
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
additionalProperties: true,
|
||||
},
|
||||
}, null, 2,
|
||||
)
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
title: string
|
||||
isOptional?: boolean
|
||||
children: React.JSX.Element
|
||||
}
|
||||
|
||||
const Field: FC<Props> = ({
|
||||
className,
|
||||
title,
|
||||
isOptional,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn(className)}>
|
||||
<div className='system-sm-semibold leading-8 text-text-secondary'>
|
||||
{title}
|
||||
{isOptional && <span className='system-xs-regular ml-1 text-text-tertiary'>({t('appDebug.variableConfig.optional')})</span>}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Field)
|
||||
@@ -0,0 +1,454 @@
|
||||
'use client'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { produce } from 'immer'
|
||||
import ModalFoot from '../modal-foot'
|
||||
import ConfigSelect from '../config-select'
|
||||
import ConfigString from '../config-string'
|
||||
import Field from './field'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { checkKeys, getNewVarInWorkflow, replaceSpaceWithUnderscoreInVarNameInput } from '@/utils/var'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { InputVar, MoreInfo, UploadFileSetting } from '@/app/components/workflow/types'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import { ChangeType, InputVarType, SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import { DEFAULT_FILE_UPLOAD_SETTING } from '@/app/components/workflow/constants'
|
||||
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
import type { Item as SelectItem } from './type-select'
|
||||
import TypeSelector from './type-select'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { jsonConfigPlaceHolder, jsonObjectWrap } from './config'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { AppModeEnum, TransferMethod } from '@/types/app'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
|
||||
const TEXT_MAX_LENGTH = 256
|
||||
const CHECKBOX_DEFAULT_TRUE_VALUE = 'true'
|
||||
const CHECKBOX_DEFAULT_FALSE_VALUE = 'false'
|
||||
|
||||
const getCheckboxDefaultSelectValue = (value: InputVar['default']) => {
|
||||
if (typeof value === 'boolean')
|
||||
return value ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
if (typeof value === 'string')
|
||||
return value.toLowerCase() === CHECKBOX_DEFAULT_TRUE_VALUE ? CHECKBOX_DEFAULT_TRUE_VALUE : CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
return CHECKBOX_DEFAULT_FALSE_VALUE
|
||||
}
|
||||
|
||||
const parseCheckboxSelectValue = (value: string) =>
|
||||
value === CHECKBOX_DEFAULT_TRUE_VALUE
|
||||
|
||||
export type IConfigModalProps = {
|
||||
isCreate?: boolean
|
||||
payload?: InputVar
|
||||
isShow: boolean
|
||||
varKeys?: string[]
|
||||
onClose: () => void
|
||||
onConfirm: (newValue: InputVar, moreInfo?: MoreInfo) => void
|
||||
supportFile?: boolean
|
||||
}
|
||||
|
||||
const ConfigModal: FC<IConfigModalProps> = ({
|
||||
isCreate,
|
||||
payload,
|
||||
isShow,
|
||||
onClose,
|
||||
onConfirm,
|
||||
supportFile,
|
||||
}) => {
|
||||
const { modelConfig } = useContext(ConfigContext)
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState<InputVar>(() => payload || getNewVarInWorkflow('') as any)
|
||||
const { type, label, variable, options, max_length } = tempPayload
|
||||
const modalRef = useRef<HTMLDivElement>(null)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const isBasicApp = appDetail?.mode !== AppModeEnum.ADVANCED_CHAT && appDetail?.mode !== AppModeEnum.WORKFLOW
|
||||
const isSupportJSON = false
|
||||
const jsonSchemaStr = useMemo(() => {
|
||||
const isJsonObject = type === InputVarType.jsonObject
|
||||
if (!isJsonObject || !tempPayload.json_schema)
|
||||
return ''
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(tempPayload.json_schema).properties, null, 2)
|
||||
}
|
||||
catch {
|
||||
return ''
|
||||
}
|
||||
}, [tempPayload.json_schema])
|
||||
useEffect(() => {
|
||||
// To fix the first input element auto focus, then directly close modal will raise error
|
||||
if (isShow)
|
||||
modalRef.current?.focus()
|
||||
}, [isShow])
|
||||
|
||||
const isStringInput = type === InputVarType.textInput || type === InputVarType.paragraph
|
||||
const checkVariableName = useCallback((value: string, canBeEmpty?: boolean) => {
|
||||
const { isValid, errorMessageKey } = checkKeys([value], canBeEmpty)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: t('appDebug.variableConfig.varName') }),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}, [t])
|
||||
const handlePayloadChange = useCallback((key: string) => {
|
||||
return (value: any) => {
|
||||
setTempPayload((prev) => {
|
||||
const newPayload = {
|
||||
...prev,
|
||||
[key]: value,
|
||||
}
|
||||
|
||||
// Clear default value if modified options no longer include current default
|
||||
if (key === 'options' && prev.default) {
|
||||
const optionsArray = Array.isArray(value) ? value : []
|
||||
if (!optionsArray.includes(prev.default))
|
||||
newPayload.default = undefined
|
||||
}
|
||||
|
||||
return newPayload
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleJSONSchemaChange = useCallback((value: string) => {
|
||||
try {
|
||||
const v = JSON.parse(value)
|
||||
const res = {
|
||||
...jsonObjectWrap,
|
||||
properties: v,
|
||||
}
|
||||
handlePayloadChange('json_schema')(JSON.stringify(res, null, 2))
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}, [handlePayloadChange])
|
||||
|
||||
const selectOptions: SelectItem[] = [
|
||||
{
|
||||
name: t('appDebug.variableConfig.text-input'),
|
||||
value: InputVarType.textInput,
|
||||
},
|
||||
{
|
||||
name: t('appDebug.variableConfig.paragraph'),
|
||||
value: InputVarType.paragraph,
|
||||
},
|
||||
{
|
||||
name: t('appDebug.variableConfig.select'),
|
||||
value: InputVarType.select,
|
||||
},
|
||||
{
|
||||
name: t('appDebug.variableConfig.number'),
|
||||
value: InputVarType.number,
|
||||
},
|
||||
{
|
||||
name: t('appDebug.variableConfig.checkbox'),
|
||||
value: InputVarType.checkbox,
|
||||
},
|
||||
...(supportFile ? [
|
||||
{
|
||||
name: t('appDebug.variableConfig.single-file'),
|
||||
value: InputVarType.singleFile,
|
||||
},
|
||||
{
|
||||
name: t('appDebug.variableConfig.multi-files'),
|
||||
value: InputVarType.multiFiles,
|
||||
},
|
||||
] : []),
|
||||
...((!isBasicApp && isSupportJSON) ? [{
|
||||
name: t('appDebug.variableConfig.json'),
|
||||
value: InputVarType.jsonObject,
|
||||
}] : []),
|
||||
]
|
||||
|
||||
const handleTypeChange = useCallback((item: SelectItem) => {
|
||||
const type = item.value as InputVarType
|
||||
|
||||
const newPayload = produce(tempPayload, (draft) => {
|
||||
draft.type = type
|
||||
if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
(Object.keys(DEFAULT_FILE_UPLOAD_SETTING)).forEach((key) => {
|
||||
if (key !== 'max_length')
|
||||
(draft as any)[key] = (DEFAULT_FILE_UPLOAD_SETTING as any)[key]
|
||||
})
|
||||
if (type === InputVarType.multiFiles)
|
||||
draft.max_length = DEFAULT_FILE_UPLOAD_SETTING.max_length
|
||||
}
|
||||
if (type === InputVarType.paragraph)
|
||||
draft.max_length = DEFAULT_VALUE_MAX_LEN
|
||||
})
|
||||
setTempPayload(newPayload)
|
||||
}, [tempPayload])
|
||||
|
||||
const handleVarKeyBlur = useCallback((e: any) => {
|
||||
const varName = e.target.value
|
||||
if (!checkVariableName(varName, true) || tempPayload.label)
|
||||
return
|
||||
|
||||
setTempPayload((prev) => {
|
||||
return {
|
||||
...prev,
|
||||
label: varName,
|
||||
}
|
||||
})
|
||||
}, [checkVariableName, tempPayload.label])
|
||||
|
||||
const handleVarNameChange = useCallback((e: ChangeEvent<any>) => {
|
||||
replaceSpaceWithUnderscoreInVarNameInput(e.target)
|
||||
const value = e.target.value
|
||||
const { isValid, errorKey, errorMessageKey } = checkKeys([value], true)
|
||||
if (!isValid) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(`appDebug.varKeyError.${errorMessageKey}`, { key: errorKey }),
|
||||
})
|
||||
return
|
||||
}
|
||||
handlePayloadChange('variable')(e.target.value)
|
||||
}, [handlePayloadChange, t])
|
||||
|
||||
const checkboxDefaultSelectValue = useMemo(() => getCheckboxDefaultSelectValue(tempPayload.default), [tempPayload.default])
|
||||
|
||||
const handleConfirm = () => {
|
||||
const moreInfo = tempPayload.variable === payload?.variable
|
||||
? undefined
|
||||
: {
|
||||
type: ChangeType.changeVarName,
|
||||
payload: { beforeKey: payload?.variable || '', afterKey: tempPayload.variable },
|
||||
}
|
||||
|
||||
const isVariableNameValid = checkVariableName(tempPayload.variable)
|
||||
if (!isVariableNameValid)
|
||||
return
|
||||
|
||||
if (!tempPayload.label) {
|
||||
Toast.notify({ type: 'error', message: t('appDebug.variableConfig.errorMsg.labelNameRequired') })
|
||||
return
|
||||
}
|
||||
if (isStringInput || type === InputVarType.number) {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
}
|
||||
else if (type === InputVarType.select) {
|
||||
if (options?.length === 0) {
|
||||
Toast.notify({ type: 'error', message: t('appDebug.variableConfig.errorMsg.atLeastOneOption') })
|
||||
return
|
||||
}
|
||||
const obj: Record<string, boolean> = {}
|
||||
let hasRepeatedItem = false
|
||||
options?.forEach((o) => {
|
||||
if (obj[o]) {
|
||||
hasRepeatedItem = true
|
||||
return
|
||||
}
|
||||
obj[o] = true
|
||||
})
|
||||
if (hasRepeatedItem) {
|
||||
Toast.notify({ type: 'error', message: t('appDebug.variableConfig.errorMsg.optionRepeat') })
|
||||
return
|
||||
}
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
}
|
||||
else if ([InputVarType.singleFile, InputVarType.multiFiles].includes(type)) {
|
||||
if (tempPayload.allowed_file_types?.length === 0) {
|
||||
const errorMessages = t('workflow.errorMsg.fieldRequired', { field: t('appDebug.variableConfig.file.supportFileTypes') })
|
||||
Toast.notify({ type: 'error', message: errorMessages })
|
||||
return
|
||||
}
|
||||
if (tempPayload.allowed_file_types?.includes(SupportUploadFileTypes.custom) && !tempPayload.allowed_file_extensions?.length) {
|
||||
const errorMessages = t('workflow.errorMsg.fieldRequired', { field: t('appDebug.variableConfig.file.custom.name') })
|
||||
Toast.notify({ type: 'error', message: errorMessages })
|
||||
return
|
||||
}
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
}
|
||||
else {
|
||||
onConfirm(tempPayload, moreInfo)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t(`appDebug.variableConfig.${isCreate ? 'addModalTitle' : 'editModalTitle'}`)}
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
>
|
||||
<div className='mb-8' ref={modalRef} tabIndex={-1}>
|
||||
<div className='space-y-2'>
|
||||
<Field title={t('appDebug.variableConfig.fieldType')}>
|
||||
<TypeSelector value={type} items={selectOptions} onSelect={handleTypeChange} />
|
||||
</Field>
|
||||
|
||||
<Field title={t('appDebug.variableConfig.varName')}>
|
||||
<Input
|
||||
value={variable}
|
||||
onChange={handleVarNameChange}
|
||||
onBlur={handleVarKeyBlur}
|
||||
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
|
||||
/>
|
||||
</Field>
|
||||
<Field title={t('appDebug.variableConfig.labelName')}>
|
||||
<Input
|
||||
value={label as string}
|
||||
onChange={e => handlePayloadChange('label')(e.target.value)}
|
||||
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{isStringInput && (
|
||||
<Field title={t('appDebug.variableConfig.maxLength')}>
|
||||
<ConfigString maxLength={type === InputVarType.textInput ? TEXT_MAX_LENGTH : Infinity} modelId={modelConfig.model_id} value={max_length} onChange={handlePayloadChange('max_length')} />
|
||||
</Field>
|
||||
|
||||
)}
|
||||
|
||||
{/* Default value for text input */}
|
||||
{type === InputVarType.textInput && (
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<Input
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for paragraph */}
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<Textarea
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Default value for number input */}
|
||||
{type === InputVarType.number && (
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<Input
|
||||
type="number"
|
||||
value={tempPayload.default || ''}
|
||||
onChange={e => handlePayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('appDebug.variableConfig.inputPlaceholder')!}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.checkbox && (
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<SimpleSelect
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: CHECKBOX_DEFAULT_TRUE_VALUE, name: t('appDebug.variableConfig.startChecked') },
|
||||
{ value: CHECKBOX_DEFAULT_FALSE_VALUE, name: t('appDebug.variableConfig.noDefaultSelected') },
|
||||
]}
|
||||
defaultValue={checkboxDefaultSelectValue}
|
||||
onSelect={item => handlePayloadChange('default')(parseCheckboxSelectValue(String(item.value)))}
|
||||
placeholder={t('appDebug.variableConfig.selectDefaultValue')}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{type === InputVarType.select && (
|
||||
<>
|
||||
<Field title={t('appDebug.variableConfig.options')}>
|
||||
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
|
||||
</Field>
|
||||
{options && options.length > 0 && (
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<SimpleSelect
|
||||
key={`default-select-${options.join('-')}`}
|
||||
className="w-full"
|
||||
optionWrapClassName="max-h-[140px] overflow-y-auto"
|
||||
items={[
|
||||
{ value: '', name: t('appDebug.variableConfig.noDefaultValue') },
|
||||
...options.filter(opt => opt.trim() !== '').map(option => ({
|
||||
value: option,
|
||||
name: option,
|
||||
})),
|
||||
]}
|
||||
defaultValue={tempPayload.default || ''}
|
||||
onSelect={item => handlePayloadChange('default')(item.value === '' ? undefined : item.value)}
|
||||
placeholder={t('appDebug.variableConfig.selectDefaultValue')}
|
||||
allowSearch={false}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
|
||||
<>
|
||||
<FileUploadSetting
|
||||
payload={tempPayload as UploadFileSetting}
|
||||
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
|
||||
isMultiple={type === InputVarType.multiFiles}
|
||||
/>
|
||||
<Field title={t('appDebug.variableConfig.defaultValue')}>
|
||||
<FileUploaderInAttachmentWrapper
|
||||
value={(type === InputVarType.singleFile ? (tempPayload.default ? [tempPayload.default] : []) : (tempPayload.default || [])) as unknown as FileEntity[]}
|
||||
onChange={(files) => {
|
||||
if (type === InputVarType.singleFile)
|
||||
handlePayloadChange('default')(files?.[0] || undefined)
|
||||
else
|
||||
handlePayloadChange('default')(files || undefined)
|
||||
}}
|
||||
fileConfig={{
|
||||
allowed_file_types: tempPayload.allowed_file_types || [SupportUploadFileTypes.document],
|
||||
allowed_file_extensions: tempPayload.allowed_file_extensions || [],
|
||||
allowed_file_upload_methods: tempPayload.allowed_file_upload_methods || [TransferMethod.remote_url],
|
||||
number_limits: type === InputVarType.singleFile ? 1 : tempPayload.max_length || 5,
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{type === InputVarType.jsonObject && (
|
||||
<Field title={t('appDebug.variableConfig.jsonSchema')} isOptional>
|
||||
<CodeEditor
|
||||
language={CodeLanguage.json}
|
||||
value={jsonSchemaStr}
|
||||
onChange={handleJSONSchemaChange}
|
||||
noWrapper
|
||||
className='bg h-[80px] overflow-y-auto rounded-[10px] bg-components-input-bg-normal p-1'
|
||||
placeholder={
|
||||
<div className='whitespace-pre'>{jsonConfigPlaceHolder}</div>
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
<div className='!mt-5 flex h-6 items-center space-x-2'>
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
|
||||
<span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.required')}</span>
|
||||
</div>
|
||||
|
||||
<div className='!mt-5 flex h-6 items-center space-x-2'>
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.hide')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFoot
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={onClose}
|
||||
/>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigModal)
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { ChevronDownIcon } from '@heroicons/react/20/solid'
|
||||
import classNames from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import { inputVarTypeToVarType } from '@/app/components/workflow/nodes/_base/components/variable/utils'
|
||||
|
||||
export type Item = {
|
||||
value: InputVarType
|
||||
name: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: string | number
|
||||
onSelect: (value: Item) => void
|
||||
items: Item[]
|
||||
popupClassName?: string
|
||||
popupInnerClassName?: string
|
||||
readonly?: boolean
|
||||
hideChecked?: boolean
|
||||
}
|
||||
const TypeSelector: FC<Props> = ({
|
||||
value,
|
||||
onSelect,
|
||||
items,
|
||||
popupInnerClassName,
|
||||
readonly,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const selectedItem = value ? items.find(item => item.value === value) : undefined
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-start'
|
||||
offset={4}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => !readonly && setOpen(v => !v)} className='w-full'>
|
||||
<div
|
||||
className={classNames(`group flex h-9 items-center justify-between rounded-lg border-0 bg-components-input-bg-normal px-2 text-sm hover:bg-state-base-hover-alt ${readonly ? 'cursor-not-allowed' : 'cursor-pointer'}`)}
|
||||
title={selectedItem?.name}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<InputVarTypeIcon type={selectedItem?.value as InputVarType} className='size-4 shrink-0 text-text-secondary' />
|
||||
<span
|
||||
className={`
|
||||
ml-1.5 text-components-input-text-filled ${!selectedItem?.name && 'text-components-input-text-placeholder'}
|
||||
`}
|
||||
>
|
||||
{selectedItem?.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<Badge uppercase={false}>{inputVarTypeToVarType(selectedItem?.value as InputVarType)}</Badge>
|
||||
<ChevronDownIcon className={cn('h-4 w-4 shrink-0 text-text-quaternary group-hover:text-text-secondary', open && 'text-text-secondary')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-[61]'>
|
||||
<div
|
||||
className={classNames('w-[432px] rounded-md border-[0.5px] border-components-panel-border bg-components-panel-bg px-1 py-1 text-base shadow-lg focus:outline-none sm:text-sm', popupInnerClassName)}
|
||||
>
|
||||
{items.map((item: Item) => (
|
||||
<div
|
||||
key={item.value}
|
||||
className={'flex h-9 cursor-pointer items-center justify-between rounded-lg px-2 text-text-secondary hover:bg-state-base-hover'}
|
||||
title={item.name}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<InputVarTypeIcon type={item.value} className='size-4 shrink-0 text-text-secondary' />
|
||||
<span title={item.name}>{item.name}</span>
|
||||
</div>
|
||||
<Badge uppercase={false}>{inputVarTypeToVarType(item.value)}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
|
||||
export default TypeSelector
|
||||
@@ -0,0 +1,82 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import ConfigSelect from './index'
|
||||
|
||||
jest.mock('react-sortablejs', () => ({
|
||||
ReactSortable: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}))
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}))
|
||||
|
||||
describe('ConfigSelect Component', () => {
|
||||
const defaultProps = {
|
||||
options: ['Option 1', 'Option 2'],
|
||||
onChange: jest.fn(),
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders all options', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
|
||||
defaultProps.options.forEach((option) => {
|
||||
expect(screen.getByDisplayValue(option)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
it('renders add button', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
|
||||
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('handles option deletion', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
|
||||
const deleteButton = optionContainer?.querySelector('div[role="button"]')
|
||||
|
||||
if (!deleteButton) return
|
||||
fireEvent.click(deleteButton)
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith(['Option 2'])
|
||||
})
|
||||
|
||||
it('handles adding new option', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const addButton = screen.getByText('appDebug.variableConfig.addOption')
|
||||
|
||||
fireEvent.click(addButton)
|
||||
|
||||
expect(defaultProps.onChange).toHaveBeenCalledWith([...defaultProps.options, ''])
|
||||
})
|
||||
|
||||
it('applies focus styles on input focus', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const firstInput = screen.getByDisplayValue('Option 1')
|
||||
|
||||
fireEvent.focus(firstInput)
|
||||
|
||||
expect(firstInput.closest('div')).toHaveClass('border-components-input-border-active')
|
||||
})
|
||||
|
||||
it('applies delete hover styles', () => {
|
||||
render(<ConfigSelect {...defaultProps} />)
|
||||
const optionContainer = screen.getByDisplayValue('Option 1').closest('div')
|
||||
const deleteButton = optionContainer?.querySelector('div[role="button"]')
|
||||
|
||||
if (!deleteButton) return
|
||||
fireEvent.mouseEnter(deleteButton)
|
||||
expect(optionContainer).toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('renders empty state correctly', () => {
|
||||
render(<ConfigSelect options={[]} onChange={defaultProps.onChange} />)
|
||||
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument()
|
||||
expect(screen.getByText('appDebug.variableConfig.addOption')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { RiAddLine, RiDeleteBinLine, RiDraggable } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export type Options = string[]
|
||||
export type IConfigSelectProps = {
|
||||
options: Options
|
||||
onChange: (options: Options) => void
|
||||
}
|
||||
|
||||
const ConfigSelect: FC<IConfigSelectProps> = ({
|
||||
options,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [focusID, setFocusID] = useState<number | null>(null)
|
||||
const [deletingID, setDeletingID] = useState<number | null>(null)
|
||||
|
||||
const optionList = options.map((content, index) => {
|
||||
return ({
|
||||
id: index,
|
||||
name: content,
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
{options.length > 0 && (
|
||||
<div className='mb-1'>
|
||||
<ReactSortable
|
||||
className="space-y-1"
|
||||
list={optionList}
|
||||
setList={list => onChange(list.map(item => item.name))}
|
||||
handle='.handle'
|
||||
ghostClass="opacity-50"
|
||||
animation={150}
|
||||
>
|
||||
{options.map((o, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-center rounded-lg border border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
focusID === index && 'border-components-input-border-active bg-components-input-bg-active hover:border-components-input-border-active hover:bg-components-input-bg-active',
|
||||
deletingID === index && 'border-components-input-border-destructive bg-state-destructive-hover hover:border-components-input-border-destructive hover:bg-state-destructive-hover',
|
||||
)}
|
||||
key={index}
|
||||
>
|
||||
<RiDraggable className='handle h-4 w-4 cursor-grab text-text-quaternary' />
|
||||
<input
|
||||
key={index}
|
||||
type='input'
|
||||
value={o || ''}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value
|
||||
onChange(options.map((item, i) => {
|
||||
if (index === i)
|
||||
return value
|
||||
|
||||
return item
|
||||
}))
|
||||
}}
|
||||
className={'h-9 w-full grow cursor-pointer overflow-x-auto rounded-lg border-0 bg-transparent pl-1.5 pr-8 text-sm leading-9 text-text-secondary focus:outline-none'}
|
||||
onFocus={() => setFocusID(index)}
|
||||
onBlur={() => setFocusID(null)}
|
||||
/>
|
||||
<div
|
||||
role='button'
|
||||
className='absolute right-1.5 top-1/2 block translate-y-[-50%] cursor-pointer rounded-md p-1 text-text-tertiary hover:bg-state-destructive-hover hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
onChange(options.filter((_, i) => index !== i))
|
||||
setDeletingID(null)
|
||||
}}
|
||||
onMouseEnter={() => setDeletingID(index)}
|
||||
onMouseLeave={() => setDeletingID(null)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-3.5 w-3.5' />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
onClick={() => { onChange([...options, '']) }}
|
||||
className='mt-1 flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-components-button-tertiary-bg px-3 text-components-button-tertiary-text hover:bg-components-button-tertiary-bg-hover'>
|
||||
<RiAddLine className='h-4 w-4' />
|
||||
<div className='system-sm-medium text-[13px]'>{t('appDebug.variableConfig.addOption')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ConfigSelect)
|
||||
@@ -0,0 +1,45 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import Input from '@/app/components/base/input'
|
||||
|
||||
export type IConfigStringProps = {
|
||||
value: number | undefined
|
||||
maxLength: number
|
||||
modelId: string
|
||||
onChange: (value: number | undefined) => void
|
||||
}
|
||||
|
||||
const ConfigString: FC<IConfigStringProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
maxLength,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (value && value > maxLength)
|
||||
onChange(maxLength)
|
||||
}, [value, maxLength, onChange])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Input
|
||||
type="number"
|
||||
max={maxLength}
|
||||
min={1}
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
let value = Number.parseInt(e.target.value, 10)
|
||||
if (value > maxLength)
|
||||
value = maxLength
|
||||
|
||||
else if (value < 1)
|
||||
value = 1
|
||||
|
||||
onChange(value)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ConfigString)
|
||||
321
dify/web/app/components/app/configuration/config-var/index.tsx
Normal file
321
dify/web/app/components/app/configuration/config-var/index.tsx
Normal file
@@ -0,0 +1,321 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { produce } from 'immer'
|
||||
import { ReactSortable } from 'react-sortablejs'
|
||||
import Panel from '../base/feature-panel'
|
||||
import EditModal from './config-modal'
|
||||
import VarItem from './var-item'
|
||||
import SelectVarType from './select-var-type'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
|
||||
import { getNewVar, hasDuplicateStr } from '@/utils/var'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
export const ADD_EXTERNAL_DATA_TOOL = 'ADD_EXTERNAL_DATA_TOOL'
|
||||
|
||||
type ExternalDataToolParams = {
|
||||
key: string
|
||||
type: string
|
||||
index: number
|
||||
name: string
|
||||
config?: Record<string, any>
|
||||
icon?: string
|
||||
icon_background?: string
|
||||
}
|
||||
|
||||
export type IConfigVarProps = {
|
||||
promptVariables: PromptVariable[]
|
||||
readonly?: boolean
|
||||
onPromptVariablesChange?: (promptVariables: PromptVariable[]) => void
|
||||
}
|
||||
|
||||
const ConfigVar: FC<IConfigVarProps> = ({ promptVariables, readonly, onPromptVariablesChange }) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
mode,
|
||||
dataSets,
|
||||
} = useContext(ConfigContext)
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const hasVar = promptVariables.length > 0
|
||||
const [currIndex, setCurrIndex] = useState<number>(-1)
|
||||
const currItem = currIndex !== -1 ? promptVariables[currIndex] : null
|
||||
const currItemToEdit: InputVar | null = (() => {
|
||||
if (!currItem)
|
||||
return null
|
||||
|
||||
return {
|
||||
...currItem,
|
||||
label: currItem.name,
|
||||
variable: currItem.key,
|
||||
type: currItem.type === 'string' ? InputVarType.textInput : currItem.type,
|
||||
} as InputVar
|
||||
})()
|
||||
const updatePromptVariableItem = (payload: InputVar) => {
|
||||
const newPromptVariables = produce(promptVariables, (draft) => {
|
||||
const { variable, label, type, ...rest } = payload
|
||||
draft[currIndex] = {
|
||||
...rest,
|
||||
type: type === InputVarType.textInput ? 'string' : type,
|
||||
key: variable,
|
||||
name: label as string,
|
||||
}
|
||||
|
||||
if (payload.type === InputVarType.textInput)
|
||||
draft[currIndex].max_length = draft[currIndex].max_length || DEFAULT_VALUE_MAX_LEN
|
||||
|
||||
if (payload.type !== InputVarType.select)
|
||||
delete draft[currIndex].options
|
||||
})
|
||||
|
||||
const newList = newPromptVariables
|
||||
let errorMsgKey = ''
|
||||
let typeName = ''
|
||||
if (hasDuplicateStr(newList.map(item => item.key))) {
|
||||
errorMsgKey = 'appDebug.varKeyError.keyAlreadyExists'
|
||||
typeName = 'appDebug.variableConfig.varName'
|
||||
}
|
||||
else if (hasDuplicateStr(newList.map(item => item.name as string))) {
|
||||
errorMsgKey = 'appDebug.varKeyError.keyAlreadyExists'
|
||||
typeName = 'appDebug.variableConfig.labelName'
|
||||
}
|
||||
|
||||
if (errorMsgKey) {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t(errorMsgKey, { key: t(typeName) }),
|
||||
})
|
||||
return false
|
||||
}
|
||||
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
return true
|
||||
}
|
||||
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
|
||||
const handleOpenExternalDataToolModal = (
|
||||
{ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams,
|
||||
oldPromptVariables: PromptVariable[],
|
||||
) => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {
|
||||
type,
|
||||
variable: key,
|
||||
label: name,
|
||||
config,
|
||||
icon,
|
||||
icon_background,
|
||||
},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
const newPromptVariables = oldPromptVariables.map((item, i) => {
|
||||
if (i === index) {
|
||||
return {
|
||||
key: newExternalDataTool.variable as string,
|
||||
name: newExternalDataTool.label as string,
|
||||
enabled: newExternalDataTool.enabled,
|
||||
type: newExternalDataTool.type as string,
|
||||
config: newExternalDataTool.config,
|
||||
required: item.required,
|
||||
icon: newExternalDataTool.icon,
|
||||
icon_background: newExternalDataTool.icon_background,
|
||||
}
|
||||
}
|
||||
return item
|
||||
})
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
},
|
||||
onCancelCallback: () => {
|
||||
if (!key)
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable && i !== index) {
|
||||
Toast.notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { key: promptVariables[i].key }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleAddVar = (type: string) => {
|
||||
const newVar = getNewVar('', type)
|
||||
const newPromptVariables = [...promptVariables, newVar]
|
||||
onPromptVariablesChange?.(newPromptVariables)
|
||||
|
||||
if (type === 'api') {
|
||||
handleOpenExternalDataToolModal({
|
||||
type,
|
||||
key: newVar.key,
|
||||
name: newVar.name,
|
||||
index: promptVariables.length,
|
||||
}, newPromptVariables)
|
||||
}
|
||||
}
|
||||
|
||||
eventEmitter?.useSubscription((v: any) => {
|
||||
if (v.type === ADD_EXTERNAL_DATA_TOOL) {
|
||||
const payload = v.payload
|
||||
onPromptVariablesChange?.([
|
||||
...promptVariables,
|
||||
{
|
||||
key: payload.variable as string,
|
||||
name: payload.label as string,
|
||||
enabled: payload.enabled,
|
||||
type: payload.type as string,
|
||||
config: payload.config,
|
||||
required: true,
|
||||
icon: payload.icon,
|
||||
icon_background: payload.icon_background,
|
||||
},
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
const [isShowDeleteContextVarModal, { setTrue: showDeleteContextVarModal, setFalse: hideDeleteContextVarModal }] = useBoolean(false)
|
||||
const [removeIndex, setRemoveIndex] = useState<number | null>(null)
|
||||
const didRemoveVar = (index: number) => {
|
||||
onPromptVariablesChange?.(promptVariables.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleRemoveVar = (index: number) => {
|
||||
const removeVar = promptVariables[index]
|
||||
|
||||
if (mode === AppModeEnum.COMPLETION && dataSets.length > 0 && removeVar.is_context_var) {
|
||||
showDeleteContextVarModal()
|
||||
setRemoveIndex(index)
|
||||
return
|
||||
}
|
||||
didRemoveVar(index)
|
||||
}
|
||||
|
||||
// const [currKey, setCurrKey] = useState<string | null>(null)
|
||||
const [isShowEditModal, { setTrue: showEditModal, setFalse: hideEditModal }] = useBoolean(false)
|
||||
|
||||
const handleConfig = ({ key, type, index, name, config, icon, icon_background }: ExternalDataToolParams) => {
|
||||
// setCurrKey(key)
|
||||
setCurrIndex(index)
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number' && type !== 'checkbox') {
|
||||
handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables)
|
||||
return
|
||||
}
|
||||
|
||||
showEditModal()
|
||||
}
|
||||
|
||||
const promptVariablesWithIds = useMemo(() => promptVariables.map((item) => {
|
||||
return {
|
||||
id: item.key,
|
||||
variable: { ...item },
|
||||
}
|
||||
}), [promptVariables])
|
||||
|
||||
const canDrag = !readonly && promptVariables.length > 1
|
||||
|
||||
return (
|
||||
<Panel
|
||||
className="mt-2"
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-1'>{t('appDebug.variableTitle')}</div>
|
||||
{!readonly && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
{t('appDebug.variableTip')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
headerRight={!readonly ? <SelectVarType onChange={handleAddVar} /> : null}
|
||||
noBodySpacing
|
||||
>
|
||||
{!hasVar && (
|
||||
<div className='mt-1 px-3 pb-3'>
|
||||
<div className='pb-1 pt-2 text-xs text-text-tertiary'>{t('appDebug.notSetVar')}</div>
|
||||
</div>
|
||||
)}
|
||||
{hasVar && (
|
||||
<div className='mt-1 px-3 pb-3'>
|
||||
<ReactSortable
|
||||
className='space-y-1'
|
||||
list={promptVariablesWithIds}
|
||||
setList={(list) => { onPromptVariablesChange?.(list.map(item => item.variable)) }}
|
||||
handle='.handle'
|
||||
ghostClass='opacity-50'
|
||||
animation={150}
|
||||
>
|
||||
{promptVariablesWithIds.map((item, index) => {
|
||||
const { key, name, type, required, config, icon, icon_background } = item.variable
|
||||
return (
|
||||
<VarItem
|
||||
className={cn(canDrag && 'handle')}
|
||||
key={key}
|
||||
readonly={readonly}
|
||||
name={key}
|
||||
label={name}
|
||||
required={!!required}
|
||||
type={type}
|
||||
onEdit={() => handleConfig({ type, key, index, name, config, icon, icon_background })}
|
||||
onRemove={() => handleRemoveVar(index)}
|
||||
canDrag={canDrag}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</ReactSortable>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isShowEditModal && (
|
||||
<EditModal
|
||||
payload={currItemToEdit!}
|
||||
isShow={isShowEditModal}
|
||||
onClose={hideEditModal}
|
||||
onConfirm={(item) => {
|
||||
const isValid = updatePromptVariableItem(item)
|
||||
if (!isValid) return
|
||||
hideEditModal()
|
||||
}}
|
||||
varKeys={promptVariables.map(v => v.key)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isShowDeleteContextVarModal && (
|
||||
<Confirm
|
||||
isShow={isShowDeleteContextVarModal}
|
||||
title={t('appDebug.feature.dataSet.queryVariable.deleteContextVarTitle', { varName: promptVariables[removeIndex as number]?.name })}
|
||||
content={t('appDebug.feature.dataSet.queryVariable.deleteContextVarTip')}
|
||||
onConfirm={() => {
|
||||
didRemoveVar(removeIndex as number)
|
||||
hideDeleteContextVarModal()
|
||||
}}
|
||||
onCancel={hideDeleteContextVarModal}
|
||||
/>
|
||||
)}
|
||||
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigVar)
|
||||
@@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
export type IInputTypeIconProps = {
|
||||
type: 'string' | 'select'
|
||||
className: string
|
||||
}
|
||||
|
||||
const IconMap = (type: IInputTypeIconProps['type'], className: string) => {
|
||||
const classNames = `w-3.5 h-3.5 ${className}`
|
||||
const icons = {
|
||||
string: (
|
||||
<InputVarTypeIcon type={InputVarType.textInput} className={classNames} />
|
||||
),
|
||||
paragraph: (
|
||||
<InputVarTypeIcon type={InputVarType.paragraph} className={classNames} />
|
||||
),
|
||||
select: (
|
||||
<InputVarTypeIcon type={InputVarType.select} className={classNames} />
|
||||
),
|
||||
number: (
|
||||
<InputVarTypeIcon type={InputVarType.number} className={classNames} />
|
||||
),
|
||||
api: (
|
||||
<ApiConnection className={classNames} />
|
||||
),
|
||||
}
|
||||
|
||||
return icons[type]
|
||||
}
|
||||
|
||||
const InputTypeIcon: FC<IInputTypeIconProps> = ({
|
||||
type,
|
||||
className,
|
||||
}) => {
|
||||
const Icon = IconMap(type, className)
|
||||
return Icon
|
||||
}
|
||||
|
||||
export default React.memo(InputTypeIcon)
|
||||
@@ -0,0 +1,24 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IModalFootProps = {
|
||||
onConfirm: () => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
const ModalFoot: FC<IModalFootProps> = ({
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex justify-end gap-2'>
|
||||
<Button onClick={onCancel}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={onConfirm}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ModalFoot)
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { InputVarType } from '@/app/components/workflow/types'
|
||||
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
|
||||
export type ISelectTypeItemProps = {
|
||||
type: InputVarType
|
||||
selected: boolean
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
const i18nFileTypeMap: Record<string, string> = {
|
||||
'file': 'single-file',
|
||||
'file-list': 'multi-files',
|
||||
}
|
||||
|
||||
const SelectTypeItem: FC<ISelectTypeItemProps> = ({
|
||||
type,
|
||||
selected,
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const typeName = t(`appDebug.variableConfig.${i18nFileTypeMap[type] || type}`)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[58px] flex-col items-center justify-center space-y-1 rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
|
||||
selected ? 'system-xs-medium border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-xs' : ' system-xs-regular cursor-pointer hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover hover:shadow-xs')}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className='shrink-0'>
|
||||
<InputVarTypeIcon type={type} className='h-5 w-5' />
|
||||
</div>
|
||||
<span>{typeName}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(SelectTypeItem)
|
||||
@@ -0,0 +1,79 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { ApiConnection } from '@/app/components/base/icons/src/vender/solid/development'
|
||||
import InputVarTypeIcon from '@/app/components/workflow/nodes/_base/components/input-var-type-icon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
type Props = {
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
text: string
|
||||
value: string
|
||||
Icon?: any
|
||||
type?: InputVarType
|
||||
onClick: (value: string) => void
|
||||
}
|
||||
|
||||
const SelectItem: FC<ItemProps> = ({ text, type, value, Icon, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className='flex h-8 cursor-pointer items-center rounded-lg px-3 hover:bg-state-base-hover'
|
||||
onClick={() => onClick(value)}
|
||||
>
|
||||
{Icon ? <Icon className='h-4 w-4 text-text-secondary' /> : <InputVarTypeIcon type={type!} className='h-4 w-4 text-text-secondary' />}
|
||||
<div className='ml-2 truncate text-xs text-text-primary'>{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectVarType: FC<Props> = ({
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleChange = (value: string) => {
|
||||
onChange(value)
|
||||
setOpen(false)
|
||||
}
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<OperationBtn type='add' />
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className='min-w-[192px] rounded-lg border border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm'>
|
||||
<div className='p-1'>
|
||||
<SelectItem type={InputVarType.textInput} value='string' text={t('appDebug.variableConfig.string')} onClick={handleChange}></SelectItem>
|
||||
<SelectItem type={InputVarType.paragraph} value='paragraph' text={t('appDebug.variableConfig.paragraph')} onClick={handleChange}></SelectItem>
|
||||
<SelectItem type={InputVarType.select} value='select' text={t('appDebug.variableConfig.select')} onClick={handleChange}></SelectItem>
|
||||
<SelectItem type={InputVarType.number} value='number' text={t('appDebug.variableConfig.number')} onClick={handleChange}></SelectItem>
|
||||
<SelectItem type={InputVarType.checkbox} value='checkbox' text={t('appDebug.variableConfig.checkbox')} onClick={handleChange}></SelectItem>
|
||||
</div>
|
||||
<div className='h-px border-t border-components-panel-border'></div>
|
||||
<div className='p-1'>
|
||||
<SelectItem Icon={ApiConnection} value='api' text={t('appDebug.variableConfig.apiBasedVar')} onClick={handleChange}></SelectItem>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default React.memo(SelectVarType)
|
||||
@@ -0,0 +1,80 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiDraggable,
|
||||
RiEditLine,
|
||||
} from '@remixicon/react'
|
||||
import type { IInputTypeIconProps } from './input-type-icon'
|
||||
import IconTypeIcon from './input-type-icon'
|
||||
import { BracketsX as VarIcon } from '@/app/components/base/icons/src/vender/line/development'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type ItemProps = {
|
||||
className?: string
|
||||
readonly?: boolean
|
||||
name: string
|
||||
label: string
|
||||
required: boolean
|
||||
type: string
|
||||
onEdit: () => void
|
||||
onRemove: () => void
|
||||
canDrag?: boolean
|
||||
}
|
||||
|
||||
const VarItem: FC<ItemProps> = ({
|
||||
className,
|
||||
readonly,
|
||||
name,
|
||||
label,
|
||||
required,
|
||||
type,
|
||||
onEdit,
|
||||
onRemove,
|
||||
canDrag,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('group relative mb-1 flex h-[34px] w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover', readonly && 'cursor-not-allowed opacity-30', className)}>
|
||||
<VarIcon className={cn('mr-1 h-4 w-4 shrink-0 text-text-accent', canDrag && 'group-hover:opacity-0')} />
|
||||
{canDrag && (
|
||||
<RiDraggable className='absolute left-3 top-3 hidden h-3 w-3 cursor-pointer text-text-tertiary group-hover:block' />
|
||||
)}
|
||||
<div className='flex w-0 grow items-center'>
|
||||
<div className='truncate' title={`${name} · ${label}`}>
|
||||
<span className='system-sm-medium text-text-secondary'>{name}</span>
|
||||
<span className='system-xs-regular px-1 text-text-quaternary'>·</span>
|
||||
<span className='system-xs-medium text-text-tertiary'>{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='shrink-0'>
|
||||
<div className={cn('flex items-center', !readonly && 'group-hover:hidden')}>
|
||||
{required && <Badge text='required' />}
|
||||
<span className='system-xs-regular pl-2 pr-1 text-text-tertiary'>{type}</span>
|
||||
<IconTypeIcon type={type as IInputTypeIconProps['type']} className='text-text-tertiary' />
|
||||
</div>
|
||||
<div className={cn('hidden items-center justify-end rounded-lg', !readonly && 'group-hover:flex')}>
|
||||
<div
|
||||
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-black/5'
|
||||
onClick={onEdit}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
<div
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center text-text-tertiary hover:text-text-destructive'
|
||||
onClick={onRemove}
|
||||
onMouseOver={() => setIsDeleting(true)}
|
||||
onMouseLeave={() => setIsDeleting(false)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default VarItem
|
||||
@@ -0,0 +1,112 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce } from 'immer'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import ParamConfig from './param-config'
|
||||
import { Vision } from '@/app/components/base/icons/src/vender/features'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
// import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
// import { Resolution } from '@/types/app'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
|
||||
const ConfigVision: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { isShowVisionConfig, isAllowVideoUpload } = useContext(ConfigContext)
|
||||
const file = useFeatures(s => s.features.file)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
const isImageEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.image) ?? false
|
||||
|
||||
const handleChange = useCallback((value: boolean) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
if (value) {
|
||||
draft.file!.allowed_file_types = Array.from(new Set([
|
||||
...(draft.file?.allowed_file_types || []),
|
||||
SupportUploadFileTypes.image,
|
||||
...(isAllowVideoUpload ? [SupportUploadFileTypes.video] : []),
|
||||
]))
|
||||
}
|
||||
else {
|
||||
draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter(
|
||||
type => type !== SupportUploadFileTypes.image && (isAllowVideoUpload ? type !== SupportUploadFileTypes.video : true),
|
||||
)
|
||||
}
|
||||
|
||||
if (draft.file) {
|
||||
draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0
|
||||
draft.file.image = {
|
||||
...draft.file.image,
|
||||
enabled: value,
|
||||
}
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
}, [featuresStore, isAllowVideoUpload])
|
||||
|
||||
if (!isShowVisionConfig)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className='mt-2 flex items-center gap-2 rounded-xl border-l-[0.5px] border-t-[0.5px] border-effects-highlight bg-background-section-burn p-2'>
|
||||
<div className='shrink-0 p-1'>
|
||||
<div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-indigo-indigo-600 p-1 shadow-xs'>
|
||||
<Vision className='h-4 w-4 text-text-primary-on-surface' />
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex grow items-center'>
|
||||
<div className='system-sm-semibold mr-1 text-text-secondary'>{t('appDebug.vision.name')}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]' >
|
||||
{t('appDebug.vision.description')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center'>
|
||||
{/* <div className='mr-2 flex items-center gap-0.5'>
|
||||
<div className='text-text-tertiary system-xs-medium-uppercase'>{t('appDebug.vision.visionSettings.resolution')}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]' >
|
||||
{t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div> */}
|
||||
{/* <div className='flex items-center gap-1'>
|
||||
<OptionCard
|
||||
title={t('appDebug.vision.visionSettings.high')}
|
||||
selected={file?.image?.detail === Resolution.high}
|
||||
onSelect={() => handleChange(Resolution.high)}
|
||||
/>
|
||||
<OptionCard
|
||||
title={t('appDebug.vision.visionSettings.low')}
|
||||
selected={file?.image?.detail === Resolution.low}
|
||||
onSelect={() => handleChange(Resolution.low)}
|
||||
/>
|
||||
</div> */}
|
||||
<ParamConfig />
|
||||
<div className='ml-1 mr-3 h-3.5 w-[1px] bg-divider-regular'></div>
|
||||
<Switch
|
||||
defaultValue={isImageEnabled}
|
||||
onChange={handleChange}
|
||||
size='md'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ConfigVision)
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { produce } from 'immer'
|
||||
import OptionCard from '@/app/components/workflow/nodes/_base/components/option-card'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
import ParamItem from '@/app/components/base/param-item'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import type { FileUpload } from '@/app/components/base/features/types'
|
||||
|
||||
const MIN = 1
|
||||
const MAX = 6
|
||||
const ParamConfigContent: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const file = useFeatures(s => s.features.file)
|
||||
const featuresStore = useFeaturesStore()
|
||||
|
||||
const handleChange = useCallback((data: FileUpload) => {
|
||||
const {
|
||||
features,
|
||||
setFeatures,
|
||||
} = featuresStore!.getState()
|
||||
|
||||
const newFeatures = produce(features, (draft) => {
|
||||
draft.file = {
|
||||
...draft.file,
|
||||
allowed_file_upload_methods: data.allowed_file_upload_methods,
|
||||
number_limits: data.number_limits,
|
||||
image: {
|
||||
enabled: data.enabled,
|
||||
detail: data.image?.detail,
|
||||
transfer_methods: data.allowed_file_upload_methods,
|
||||
number_limits: data.number_limits,
|
||||
},
|
||||
}
|
||||
})
|
||||
setFeatures(newFeatures)
|
||||
}, [featuresStore])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className='text-base font-semibold leading-6 text-text-primary'>{t('appDebug.vision.visionSettings.title')}</div>
|
||||
<div className='space-y-6 pt-3'>
|
||||
<div>
|
||||
<div className='mb-2 flex items-center space-x-1'>
|
||||
<div className='text-[13px] font-semibold leading-[18px] text-text-secondary'>{t('appDebug.vision.visionSettings.resolution')}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]' >
|
||||
{t('appDebug.vision.visionSettings.resolutionTooltip').split('\n').map(item => (
|
||||
<div key={item}>{item}</div>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<OptionCard
|
||||
className='grow'
|
||||
title={t('appDebug.vision.visionSettings.high')}
|
||||
selected={file?.image?.detail === Resolution.high}
|
||||
onSelect={() => handleChange({
|
||||
...file,
|
||||
image: { detail: Resolution.high },
|
||||
})}
|
||||
/>
|
||||
<OptionCard
|
||||
className='grow'
|
||||
title={t('appDebug.vision.visionSettings.low')}
|
||||
selected={file?.image?.detail === Resolution.low}
|
||||
onSelect={() => handleChange({
|
||||
...file,
|
||||
image: { detail: Resolution.low },
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-2 text-[13px] font-semibold leading-[18px] text-text-secondary'>{t('appDebug.vision.visionSettings.uploadMethod')}</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
<OptionCard
|
||||
className='grow'
|
||||
title={t('appDebug.vision.visionSettings.both')}
|
||||
selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && !!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url)}
|
||||
onSelect={() => handleChange({
|
||||
...file,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
})}
|
||||
/>
|
||||
<OptionCard
|
||||
className='grow'
|
||||
title={t('appDebug.vision.visionSettings.localUpload')}
|
||||
selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.local_file) && file?.allowed_file_upload_methods?.length === 1}
|
||||
onSelect={() => handleChange({
|
||||
...file,
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
})}
|
||||
/>
|
||||
<OptionCard
|
||||
className='grow'
|
||||
title={t('appDebug.vision.visionSettings.url')}
|
||||
selected={!!file?.allowed_file_upload_methods?.includes(TransferMethod.remote_url) && file?.allowed_file_upload_methods?.length === 1}
|
||||
onSelect={() => handleChange({
|
||||
...file,
|
||||
allowed_file_upload_methods: [TransferMethod.remote_url],
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<ParamItem
|
||||
id='upload_limit'
|
||||
className=''
|
||||
name={t('appDebug.vision.visionSettings.uploadLimit')}
|
||||
noTooltip
|
||||
{...{
|
||||
default: 2,
|
||||
step: 1,
|
||||
min: MIN,
|
||||
max: MAX,
|
||||
}}
|
||||
value={file?.number_limits || 3}
|
||||
enable={true}
|
||||
onChange={(_key: string, value: number) => {
|
||||
if (!value)
|
||||
return
|
||||
|
||||
handleChange({
|
||||
...file,
|
||||
number_limits: value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default React.memo(ParamConfigContent)
|
||||
@@ -0,0 +1,42 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { memo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import ParamConfigContent from './param-config-content'
|
||||
import Button from '@/app/components/base/button'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const ParamsConfig: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<Button variant='ghost' size='small' className={cn('')}>
|
||||
<RiSettings2Line className='h-3.5 w-3.5' />
|
||||
<div className='ml-1'>{t('appDebug.voice.settings')}</div>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 50 }}>
|
||||
<div className='w-80 space-y-3 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-lg sm:w-[412px]'>
|
||||
<ParamConfigContent />
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
)
|
||||
}
|
||||
export default memo(ParamsConfig)
|
||||
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import AgentSetting from './agent/agent-setting'
|
||||
import Button from '@/app/components/base/button'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
|
||||
type Props = {
|
||||
isFunctionCall: boolean
|
||||
isChatModel: boolean
|
||||
agentConfig?: AgentConfig
|
||||
onAgentSettingChange: (payload: AgentConfig) => void
|
||||
}
|
||||
|
||||
const AgentSettingButton: FC<Props> = ({
|
||||
onAgentSettingChange,
|
||||
isFunctionCall,
|
||||
isChatModel,
|
||||
agentConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowAgentSetting, setIsShowAgentSetting] = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setIsShowAgentSetting(true)} className='mr-2 shrink-0'>
|
||||
<RiSettings2Line className='mr-1 h-4 w-4 text-text-tertiary' />
|
||||
{t('appDebug.agent.setting.name')}
|
||||
</Button>
|
||||
{isShowAgentSetting && (
|
||||
<AgentSetting
|
||||
isFunctionCall={isFunctionCall}
|
||||
payload={agentConfig as AgentConfig}
|
||||
isChatModel={isChatModel}
|
||||
onSave={(payloadNew) => {
|
||||
onAgentSettingChange(payloadNew)
|
||||
setIsShowAgentSetting(false)
|
||||
}}
|
||||
onCancel={() => setIsShowAgentSetting(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(AgentSettingButton)
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useClickAway } from 'ahooks'
|
||||
import ItemPanel from './item-panel'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { Unblur } from '@/app/components/base/icons/src/vender/solid/education'
|
||||
import Slider from '@/app/components/base/slider'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
import { DEFAULT_AGENT_PROMPT, MAX_ITERATIONS_NUM } from '@/config'
|
||||
|
||||
type Props = {
|
||||
isChatModel: boolean
|
||||
payload: AgentConfig
|
||||
isFunctionCall: boolean
|
||||
onCancel: () => void
|
||||
onSave: (payload: any) => void
|
||||
}
|
||||
|
||||
const maxIterationsMin = 1
|
||||
|
||||
const AgentSetting: FC<Props> = ({
|
||||
isChatModel,
|
||||
payload,
|
||||
isFunctionCall,
|
||||
onCancel,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [tempPayload, setTempPayload] = useState(payload)
|
||||
const ref = useRef(null)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useClickAway(() => {
|
||||
if (mounted)
|
||||
onCancel()
|
||||
}, ref)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(tempPayload)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='fixed inset-0 z-[100] flex justify-end overflow-hidden p-2'
|
||||
style={{
|
||||
backgroundColor: 'rgba(16, 24, 40, 0.20)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={ref}
|
||||
className='flex h-full w-[640px] flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
|
||||
>
|
||||
<div className='flex h-14 shrink-0 items-center justify-between border-b border-divider-regular pl-6 pr-5'>
|
||||
<div className='flex flex-col text-base font-semibold text-text-primary'>
|
||||
<div className='leading-6'>{t('appDebug.agent.setting.name')}</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<div
|
||||
onClick={onCancel}
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center'
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Body */}
|
||||
<div className='grow overflow-y-auto border-b p-6 pb-[68px] pt-5' style={{
|
||||
borderBottom: 'rgba(0, 0, 0, 0.05)',
|
||||
}}>
|
||||
{/* Agent Mode */}
|
||||
<ItemPanel
|
||||
className='mb-4'
|
||||
icon={
|
||||
<CuteRobot className='h-4 w-4 text-indigo-600' />
|
||||
}
|
||||
name={t('appDebug.agent.agentMode')}
|
||||
description={t('appDebug.agent.agentModeDes')}
|
||||
>
|
||||
<div className='text-[13px] font-medium leading-[18px] text-text-primary'>{isFunctionCall ? t('appDebug.agent.agentModeType.functionCall') : t('appDebug.agent.agentModeType.ReACT')}</div>
|
||||
</ItemPanel>
|
||||
|
||||
<ItemPanel
|
||||
className='mb-4'
|
||||
icon={
|
||||
<Unblur className='h-4 w-4 text-[#FB6514]' />
|
||||
}
|
||||
name={t('appDebug.agent.setting.maximumIterations.name')}
|
||||
description={t('appDebug.agent.setting.maximumIterations.description')}
|
||||
>
|
||||
<div className='flex items-center'>
|
||||
<Slider
|
||||
className='mr-3 w-[156px]'
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM}
|
||||
value={tempPayload.max_iteration}
|
||||
onChange={(value) => {
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
|
||||
<input
|
||||
type="number"
|
||||
min={maxIterationsMin}
|
||||
max={MAX_ITERATIONS_NUM} step={1}
|
||||
className="block h-7 w-11 rounded-lg border-0 bg-components-input-bg-normal px-1.5 pl-1 leading-7 text-text-primary placeholder:text-text-tertiary focus:ring-1 focus:ring-inset focus:ring-primary-600"
|
||||
value={tempPayload.max_iteration}
|
||||
onChange={(e) => {
|
||||
let value = Number.parseInt(e.target.value, 10)
|
||||
if (value < maxIterationsMin)
|
||||
value = maxIterationsMin
|
||||
|
||||
if (value > MAX_ITERATIONS_NUM)
|
||||
value = MAX_ITERATIONS_NUM
|
||||
setTempPayload({
|
||||
...tempPayload,
|
||||
max_iteration: value,
|
||||
})
|
||||
}} />
|
||||
</div>
|
||||
</ItemPanel>
|
||||
|
||||
{!isFunctionCall && (
|
||||
<div className='rounded-xl bg-background-section-burn py-2 shadow-xs'>
|
||||
<div className='flex h-8 items-center px-4 text-sm font-semibold leading-6 text-text-secondary'>{t('tools.builtInPromptTitle')}</div>
|
||||
<div className='h-[396px] overflow-y-auto whitespace-pre-line px-4 text-sm font-normal leading-5 text-text-secondary'>
|
||||
{isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion}
|
||||
</div>
|
||||
<div className='px-4'>
|
||||
<div className='inline-flex h-5 items-center rounded-md bg-components-input-bg-normal px-1 text-xs font-medium leading-[18px] text-text-tertiary'>{(isChatModel ? DEFAULT_AGENT_PROMPT.chat : DEFAULT_AGENT_PROMPT.completion).length}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
<div
|
||||
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section-burn px-6 py-4'
|
||||
>
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
className='mr-2'
|
||||
>
|
||||
{t('common.operation.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant='primary'
|
||||
onClick={handleSave}
|
||||
>
|
||||
{t('common.operation.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(AgentSetting)
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
type Props = {
|
||||
className?: string
|
||||
icon: React.JSX.Element
|
||||
name: string
|
||||
description: string
|
||||
children: React.JSX.Element
|
||||
}
|
||||
|
||||
const ItemPanel: FC<Props> = ({
|
||||
className,
|
||||
icon,
|
||||
name,
|
||||
description,
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className={cn(className, 'flex h-12 items-center justify-between rounded-lg bg-background-section-burn px-3')}>
|
||||
<div className='flex items-center'>
|
||||
{icon}
|
||||
<div className='ml-3 mr-1 text-sm font-semibold leading-6 text-text-secondary'>{name}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
{description}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ItemPanel)
|
||||
@@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { produce } from 'immer'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiEqualizer2Line,
|
||||
RiInformation2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useFormattingChangedDispatcher } from '../../../debug/hooks'
|
||||
import SettingBuiltInTool from './setting-built-in-tool'
|
||||
import Panel from '@/app/components/app/configuration/base/feature-panel'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import type { AgentTool } from '@/types/app'
|
||||
import { type Collection, CollectionType } from '@/app/components/tools/types'
|
||||
import { MAX_TOOLS_NUM } from '@/config'
|
||||
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { DefaultToolIcon } from '@/app/components/base/icons/src/public/other'
|
||||
import cn from '@/utils/classnames'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
const AgentTools: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
|
||||
const { modelConfig, setModelConfig } = useContext(ConfigContext)
|
||||
const { data: buildInTools } = useAllBuiltInTools()
|
||||
const { data: customTools } = useAllCustomTools()
|
||||
const { data: workflowTools } = useAllWorkflowTools()
|
||||
const { data: mcpTools } = useAllMCPTools()
|
||||
const collectionList = useMemo(() => {
|
||||
const allTools = [
|
||||
...(buildInTools || []),
|
||||
...(customTools || []),
|
||||
...(workflowTools || []),
|
||||
...(mcpTools || []),
|
||||
]
|
||||
return allTools
|
||||
}, [buildInTools, customTools, workflowTools, mcpTools])
|
||||
|
||||
const formattingChangedDispatcher = useFormattingChangedDispatcher()
|
||||
const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
|
||||
const [isShowSettingTool, setIsShowSettingTool] = useState(false)
|
||||
const tools = (modelConfig?.agentConfig?.tools as AgentTool[] || []).map((item) => {
|
||||
const collection = collectionList.find(
|
||||
collection =>
|
||||
canFindTool(collection.id, item.provider_id)
|
||||
&& collection.type === item.provider_type,
|
||||
)
|
||||
const icon = collection?.icon
|
||||
return {
|
||||
...item,
|
||||
icon,
|
||||
collection,
|
||||
}
|
||||
})
|
||||
const useSubscribe = useMittContextSelector(s => s.useSubscribe)
|
||||
const handleUpdateToolsWhenInstallToolSuccess = useCallback((installedPluginNames: string[]) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.forEach((item: any) => {
|
||||
if (item.isDeleted && installedPluginNames.includes(item.provider_id))
|
||||
item.isDeleted = false
|
||||
})
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}, [modelConfig, setModelConfig])
|
||||
useSubscribe('plugin:install:success', handleUpdateToolsWhenInstallToolSuccess as any)
|
||||
|
||||
const handleToolSettingChange = (value: Record<string, any>) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.collection?.id && item.tool_name === currentTool?.tool_name)
|
||||
if (tool)
|
||||
(tool as AgentTool).tool_parameters = value
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
setIsShowSettingTool(false)
|
||||
formattingChangedDispatcher()
|
||||
}
|
||||
|
||||
const [isDeleting, setIsDeleting] = useState<number>(-1)
|
||||
const getToolValue = (tool: ToolDefaultValue) => {
|
||||
return {
|
||||
provider_id: tool.provider_id,
|
||||
provider_type: tool.provider_type as CollectionType,
|
||||
provider_name: tool.provider_name,
|
||||
tool_name: tool.tool_name,
|
||||
tool_label: tool.tool_label,
|
||||
tool_parameters: tool.params,
|
||||
notAuthor: !tool.is_team_authorization,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
const handleSelectTool = (tool: ToolDefaultValue) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.push(getToolValue(tool))
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}
|
||||
|
||||
const handleSelectMultipleTool = (tool: ToolDefaultValue[]) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.push(...tool.map(getToolValue))
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}
|
||||
const getProviderShowName = (item: AgentTool) => {
|
||||
const type = item.provider_type
|
||||
if(type === CollectionType.builtIn)
|
||||
return item.provider_name.split('/').pop()
|
||||
return item.provider_name
|
||||
}
|
||||
|
||||
const handleAuthorizationItemClick = useCallback((credentialId: string) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === currentTool?.provider_id)
|
||||
if (tool)
|
||||
(tool as AgentTool).credential_id = credentialId
|
||||
})
|
||||
setCurrentTool({
|
||||
...currentTool,
|
||||
credential_id: credentialId,
|
||||
} as any)
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}, [currentTool, modelConfig, setModelConfig, formattingChangedDispatcher])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Panel
|
||||
className={cn('mt-2', tools.length === 0 && 'pb-2')}
|
||||
noBodySpacing={tools.length === 0}
|
||||
title={
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-1'>{t('appDebug.agent.tools.name')}</div>
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
{t('appDebug.agent.tools.description')}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
headerRight={
|
||||
<div className='flex items-center'>
|
||||
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>{tools.filter(item => !!item.enabled).length}/{tools.length} {t('appDebug.agent.tools.enabled')}</div>
|
||||
{tools.length < MAX_TOOLS_NUM && (
|
||||
<>
|
||||
<div className='ml-3 mr-1 h-3.5 w-px bg-divider-regular'></div>
|
||||
<ToolPicker
|
||||
trigger={<OperationBtn type="add" />}
|
||||
isShow={isShowChooseTool}
|
||||
onShowChange={setIsShowChooseTool}
|
||||
disabled={false}
|
||||
supportAddCustomTool
|
||||
onSelect={handleSelectTool}
|
||||
onSelectMultiple={handleSelectMultipleTool}
|
||||
selectedTools={tools as unknown as ToolValue[]}
|
||||
canChooseMCPTool
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='grid grid-cols-1 flex-wrap items-center justify-between gap-1 2xl:grid-cols-2'>
|
||||
{tools.map((item: AgentTool & { icon: any; collection?: Collection }, index) => (
|
||||
<div key={index}
|
||||
className={cn(
|
||||
'cursor group relative flex w-full items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-1.5 pr-2 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
|
||||
isDeleting === index && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}
|
||||
>
|
||||
<div className='flex w-0 grow items-center'>
|
||||
{item.isDeleted && <DefaultToolIcon className='h-5 w-5' />}
|
||||
{!item.isDeleted && (
|
||||
<div className={cn((item.notAuthor || !item.enabled) && 'shrink-0 opacity-50')}>
|
||||
{typeof item.icon === 'string' && <div className='h-5 w-5 rounded-md bg-cover bg-center' style={{ backgroundImage: `url(${item.icon})` }} />}
|
||||
{typeof item.icon !== 'string' && <AppIcon className='rounded-md' size='xs' icon={item.icon?.content} background={item.icon?.background} />}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={cn(
|
||||
'system-xs-regular ml-1.5 flex w-0 grow items-center truncate',
|
||||
(item.isDeleted || item.notAuthor || !item.enabled) ? 'opacity-50' : '',
|
||||
)}
|
||||
>
|
||||
<span className='system-xs-medium pr-1.5 text-text-secondary'>{getProviderShowName(item)}</span>
|
||||
<span className='text-text-tertiary'>{item.tool_label}</span>
|
||||
{!item.isDeleted && (
|
||||
<Tooltip
|
||||
popupContent={
|
||||
<div className='w-[180px]'>
|
||||
<div className='mb-1.5 text-text-secondary'>{item.tool_name}</div>
|
||||
<div className='mb-1.5 text-text-tertiary'>{t('tools.toolNameUsageTip')}</div>
|
||||
<div className='cursor-pointer text-text-accent' onClick={() => copy(item.tool_name)}>{t('tools.copyToolName')}</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className='h-4 w-4'>
|
||||
<div className='ml-0.5 hidden group-hover:inline-block'>
|
||||
<RiInformation2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className='ml-1 flex shrink-0 items-center'>
|
||||
{item.isDeleted && (
|
||||
<div className='mr-2 flex items-center'>
|
||||
<Tooltip
|
||||
popupContent={t('tools.toolRemoved')}
|
||||
>
|
||||
<div className='mr-1 cursor-pointer rounded-md p-1 hover:bg-black/5'>
|
||||
<AlertTriangle className='h-4 w-4 text-[#F79009]' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!item.isDeleted && (
|
||||
<div className='mr-2 hidden items-center gap-1 group-hover:flex'>
|
||||
{!item.notAuthor && (
|
||||
<Tooltip
|
||||
popupContent={t('tools.setBuiltInTools.infoAndSetting')}
|
||||
>
|
||||
<div className='cursor-pointer rounded-md p-1 hover:bg-black/5' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
<div
|
||||
className='cursor-pointer rounded-md p-1 text-text-tertiary hover:text-text-destructive'
|
||||
onClick={() => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.splice(index, 1)
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}}
|
||||
onMouseOver={() => setIsDeleting(index)}
|
||||
onMouseLeave={() => setIsDeleting(-1)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(item.isDeleted && 'opacity-50')}>
|
||||
{!item.notAuthor && (
|
||||
<Switch
|
||||
defaultValue={item.isDeleted ? false : item.enabled}
|
||||
disabled={item.isDeleted}
|
||||
size='md'
|
||||
onChange={(enabled) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
(draft.agentConfig.tools[index] as any).enabled = enabled
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
formattingChangedDispatcher()
|
||||
}} />
|
||||
)}
|
||||
{item.notAuthor && (
|
||||
<Button variant='secondary' size='small' onClick={() => {
|
||||
setCurrentTool(item)
|
||||
setIsShowSettingTool(true)
|
||||
}}>
|
||||
{t('tools.notAuthorized')}
|
||||
<Indicator className='ml-2' color='orange' />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div >
|
||||
</Panel >
|
||||
{isShowSettingTool && (
|
||||
<SettingBuiltInTool
|
||||
toolName={currentTool?.tool_name as string}
|
||||
setting={currentTool?.tool_parameters}
|
||||
collection={currentTool?.collection as ToolWithProvider}
|
||||
isModel={currentTool?.collection?.type === CollectionType.model}
|
||||
onSave={handleToolSettingChange}
|
||||
onHide={() => setIsShowSettingTool(false)}
|
||||
credentialId={currentTool?.credential_id}
|
||||
onAuthorizationItemClick={handleAuthorizationItemClick}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(AgentTools)
|
||||
@@ -0,0 +1,265 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiCloseLine,
|
||||
} from '@remixicon/react'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Icon from '@/app/components/plugins/card/base/card-icon'
|
||||
import OrgInfo from '@/app/components/plugins/card/base/org-info'
|
||||
import Description from '@/app/components/plugins/card/base/description'
|
||||
import TabSlider from '@/app/components/base/tab-slider-plain'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Form from '@/app/components/header/account-setting/model-provider-page/model-modal/Form'
|
||||
import { addDefaultValue, toolParametersToFormSchemas } from '@/app/components/tools/utils/to-form-schema'
|
||||
import type { Collection, Tool } from '@/app/components/tools/types'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { fetchBuiltInToolList, fetchCustomToolList, fetchModelToolList, fetchWorkflowToolList } from '@/service/tools'
|
||||
import I18n from '@/context/i18n'
|
||||
import { getLanguage } from '@/i18n-config/language'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import {
|
||||
AuthCategory,
|
||||
PluginAuthInAgent,
|
||||
} from '@/app/components/plugins/plugin-auth'
|
||||
import { ReadmeEntrance } from '@/app/components/plugins/readme-panel/entrance'
|
||||
|
||||
type Props = {
|
||||
showBackButton?: boolean
|
||||
collection: Collection | ToolWithProvider
|
||||
isBuiltIn?: boolean
|
||||
isModel?: boolean
|
||||
toolName: string
|
||||
setting?: Record<string, any>
|
||||
readonly?: boolean
|
||||
onHide: () => void
|
||||
onSave?: (value: Record<string, any>) => void
|
||||
credentialId?: string
|
||||
onAuthorizationItemClick?: (id: string) => void
|
||||
}
|
||||
|
||||
const SettingBuiltInTool: FC<Props> = ({
|
||||
showBackButton = false,
|
||||
collection,
|
||||
isBuiltIn = true,
|
||||
isModel = true,
|
||||
toolName,
|
||||
setting = {},
|
||||
readonly,
|
||||
onHide,
|
||||
onSave,
|
||||
credentialId,
|
||||
onAuthorizationItemClick,
|
||||
}) => {
|
||||
const { locale } = useContext(I18n)
|
||||
const language = getLanguage(locale)
|
||||
const { t } = useTranslation()
|
||||
const passedTools = (collection as ToolWithProvider).tools
|
||||
const hasPassedTools = passedTools?.length > 0
|
||||
const [isLoading, setIsLoading] = useState(!hasPassedTools)
|
||||
const [tools, setTools] = useState<Tool[]>(hasPassedTools ? passedTools : [])
|
||||
const currTool = tools.find(tool => tool.name === toolName)
|
||||
const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : []
|
||||
const infoSchemas = formSchemas.filter(item => item.form === 'llm')
|
||||
const settingSchemas = formSchemas.filter(item => item.form !== 'llm')
|
||||
const hasSetting = settingSchemas.length > 0
|
||||
const [tempSetting, setTempSetting] = useState(setting)
|
||||
const [currType, setCurrType] = useState('info')
|
||||
const isInfoActive = currType === 'info'
|
||||
useEffect(() => {
|
||||
if (!collection || hasPassedTools)
|
||||
return
|
||||
|
||||
(async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const list = await new Promise<Tool[]>((resolve) => {
|
||||
(async function () {
|
||||
if (isModel)
|
||||
resolve(await fetchModelToolList(collection.name))
|
||||
else if (isBuiltIn)
|
||||
resolve(await fetchBuiltInToolList(collection.name))
|
||||
else if (collection.type === CollectionType.workflow)
|
||||
resolve(await fetchWorkflowToolList(collection.id))
|
||||
else
|
||||
resolve(await fetchCustomToolList(collection.name))
|
||||
}())
|
||||
})
|
||||
setTools(list)
|
||||
const currTool = list.find(tool => tool.name === toolName)
|
||||
if (currTool) {
|
||||
const formSchemas = toolParametersToFormSchemas(currTool.parameters)
|
||||
setTempSetting(addDefaultValue(setting, formSchemas))
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
setIsLoading(false)
|
||||
})()
|
||||
}, [collection?.name, collection?.id, collection?.type])
|
||||
|
||||
useEffect(() => {
|
||||
setCurrType((!readonly && hasSetting) ? 'setting' : 'info')
|
||||
}, [hasSetting])
|
||||
|
||||
const isValid = (() => {
|
||||
let valid = true
|
||||
settingSchemas.forEach((item) => {
|
||||
if (item.required && !tempSetting[item.name])
|
||||
valid = false
|
||||
})
|
||||
return valid
|
||||
})()
|
||||
|
||||
const getType = (type: string) => {
|
||||
if (type === 'number-input')
|
||||
return t('tools.setBuiltInTools.number')
|
||||
if (type === 'text-input')
|
||||
return t('tools.setBuiltInTools.string')
|
||||
if (type === 'checkbox')
|
||||
return 'boolean'
|
||||
if (type === 'file')
|
||||
return t('tools.setBuiltInTools.file')
|
||||
return type
|
||||
}
|
||||
|
||||
const infoUI = (
|
||||
<div className=''>
|
||||
{infoSchemas.length > 0 && (
|
||||
<div className='space-y-1 py-2'>
|
||||
{infoSchemas.map((item, index) => (
|
||||
<div key={index} className='py-1'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<div className='code-sm-semibold text-text-secondary'>{item.label[language]}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>
|
||||
{getType(item.type)}
|
||||
</div>
|
||||
{item.required && (
|
||||
<div className='system-xs-medium text-text-warning-secondary'>{t('tools.setBuiltInTools.required')}</div>
|
||||
)}
|
||||
</div>
|
||||
{item.human_description && (
|
||||
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
|
||||
{item.human_description?.[language]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
const settingUI = (
|
||||
<Form
|
||||
value={tempSetting}
|
||||
onChange={setTempSetting}
|
||||
formSchemas={settingSchemas}
|
||||
isEditMode={false}
|
||||
showOnVariableMap={{}}
|
||||
validating={false}
|
||||
readonly={readonly}
|
||||
/>
|
||||
)
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
isOpen
|
||||
clickOutsideNotOpen={false}
|
||||
onClose={onHide}
|
||||
footer={null}
|
||||
mask={false}
|
||||
positionCenter={false}
|
||||
panelClassName={cn('mb-2 mr-2 mt-[64px] !w-[420px] !max-w-[420px] justify-start rounded-2xl border-[0.5px] border-components-panel-border !bg-components-panel-bg !p-0 shadow-xl')}
|
||||
>
|
||||
<>
|
||||
{isLoading && <Loading type='app' />}
|
||||
{!isLoading && (
|
||||
<>
|
||||
{/* header */}
|
||||
<div className='relative border-b border-divider-subtle p-4 pb-3'>
|
||||
<div className='absolute right-3 top-3'>
|
||||
<ActionButton onClick={onHide}>
|
||||
<RiCloseLine className='h-4 w-4' />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{showBackButton && (
|
||||
<div
|
||||
className='system-xs-semibold-uppercase mb-2 flex cursor-pointer items-center gap-1 text-text-accent-secondary'
|
||||
onClick={onHide}
|
||||
>
|
||||
<RiArrowLeftLine className='h-4 w-4' />
|
||||
{t('plugin.detailPanel.operation.back')}
|
||||
</div>
|
||||
)}
|
||||
<div className='flex items-center gap-1'>
|
||||
<Icon size='tiny' className='h-6 w-6' src={collection.icon} />
|
||||
<OrgInfo
|
||||
packageNameClassName='w-auto'
|
||||
orgName={collection.author}
|
||||
packageName={collection.name.split('/').pop() || ''}
|
||||
/>
|
||||
</div>
|
||||
<div className='system-md-semibold mt-1 text-text-primary'>{currTool?.label[language]}</div>
|
||||
{!!currTool?.description[language] && (
|
||||
<Description className='mb-2 mt-3 h-auto' text={currTool.description[language]} descriptionLineRows={2}></Description>
|
||||
)}
|
||||
{
|
||||
collection.allow_delete && collection.type === CollectionType.builtIn && (
|
||||
<PluginAuthInAgent
|
||||
pluginPayload={{
|
||||
provider: collection.name,
|
||||
category: AuthCategory.tool,
|
||||
providerType: collection.type,
|
||||
detail: collection as any,
|
||||
}}
|
||||
credentialId={credentialId}
|
||||
onAuthorizationItemClick={onAuthorizationItemClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
{/* form */}
|
||||
<div className='h-full'>
|
||||
<div className='flex h-full flex-col'>
|
||||
{(hasSetting && !readonly) ? (
|
||||
<TabSlider
|
||||
className='mt-1 shrink-0 px-4'
|
||||
itemClassName='py-3'
|
||||
noBorderBottom
|
||||
value={currType}
|
||||
onChange={(value) => {
|
||||
setCurrType(value)
|
||||
}}
|
||||
options={[
|
||||
{ value: 'info', text: t('tools.setBuiltInTools.parameters')! },
|
||||
{ value: 'setting', text: t('tools.setBuiltInTools.setting')! },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className='system-sm-semibold-uppercase p-4 pb-1 text-text-primary'>{t('tools.setBuiltInTools.parameters')}</div>
|
||||
)}
|
||||
<div className='h-0 grow overflow-y-auto px-4'>
|
||||
{isInfoActive ? infoUI : settingUI}
|
||||
{!readonly && !isInfoActive && (
|
||||
<div className='flex shrink-0 justify-end space-x-2 rounded-b-[10px] bg-components-panel-bg py-2'>
|
||||
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium ' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button className='flex h-8 items-center !px-3 !text-[13px] font-medium' variant='primary' disabled={!isValid} onClick={() => onSave?.(addDefaultValue(tempSetting, formSchemas))}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ReadmeEntrance pluginDetail={collection as any} className='mt-auto' />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
export default React.memo(SettingBuiltInTool)
|
||||
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
Copy,
|
||||
CopyCheck,
|
||||
} from '@/app/components/base/icons/src/vender/line/files'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { ExternalDataTool } from '@/models/common'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import s from '@/app/components/app/configuration/config-prompt/style.module.css'
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
type Props = {
|
||||
className?: string
|
||||
type: 'first-prompt' | 'next-iteration'
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const Editor: FC<Props> = ({
|
||||
className,
|
||||
type,
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { notify } = useToastContext()
|
||||
|
||||
const [isCopied, setIsCopied] = React.useState(false)
|
||||
const {
|
||||
modelConfig,
|
||||
hasSetBlockStatus,
|
||||
dataSets,
|
||||
showSelectDataSet,
|
||||
externalDataToolsConfig,
|
||||
setExternalDataToolsConfig,
|
||||
} = useContext(ConfigContext)
|
||||
const promptVariables = modelConfig.configs.prompt_variables
|
||||
const { setShowExternalDataToolModal } = useModalContext()
|
||||
const isFirstPrompt = type === 'first-prompt'
|
||||
const editorHeight = isFirstPrompt ? 'h-[336px]' : 'h-[52px]'
|
||||
|
||||
const handleOpenExternalDataToolModal = () => {
|
||||
setShowExternalDataToolModal({
|
||||
payload: {},
|
||||
onSaveCallback: (newExternalDataTool?: ExternalDataTool) => {
|
||||
if (!newExternalDataTool)
|
||||
return
|
||||
setExternalDataToolsConfig([...externalDataToolsConfig, newExternalDataTool])
|
||||
},
|
||||
onValidateBeforeSaveCallback: (newExternalDataTool: ExternalDataTool) => {
|
||||
for (let i = 0; i < promptVariables.length; i++) {
|
||||
if (promptVariables[i].key === newExternalDataTool.variable) {
|
||||
notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { key: promptVariables[i].key }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < externalDataToolsConfig.length; i++) {
|
||||
if (externalDataToolsConfig[i].variable === newExternalDataTool.variable) {
|
||||
notify({ type: 'error', message: t('appDebug.varKeyError.keyAlreadyExists', { key: externalDataToolsConfig[i].variable }) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
}
|
||||
return (
|
||||
<div className={cn(className, s.gradientBorder, 'relative')}>
|
||||
<div className='rounded-xl bg-white'>
|
||||
<div className={cn(s.boxHeader, 'flex h-11 items-center justify-between rounded-tl-xl rounded-tr-xl bg-white pb-1 pl-4 pr-3 pt-2 hover:shadow-xs')}>
|
||||
<div className='text-sm font-semibold uppercase text-indigo-800'>{t(`appDebug.agent.${isFirstPrompt ? 'firstPrompt' : 'nextIteration'}`)}</div>
|
||||
<div className={cn(s.optionWrap, 'items-center space-x-1')}>
|
||||
{!isCopied
|
||||
? (
|
||||
<Copy className='h-6 w-6 cursor-pointer p-1 text-gray-500' onClick={() => {
|
||||
copy(value)
|
||||
setIsCopied(true)
|
||||
}} />
|
||||
)
|
||||
: (
|
||||
<CopyCheck className='h-6 w-6 p-1 text-gray-500' />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(editorHeight, ' min-h-[102px] overflow-y-auto px-4 text-sm text-gray-700')}>
|
||||
<PromptEditor
|
||||
className={editorHeight}
|
||||
value={value}
|
||||
contextBlock={{
|
||||
show: true,
|
||||
selectable: !hasSetBlockStatus.context,
|
||||
datasets: dataSets.map(item => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.data_source_type,
|
||||
})),
|
||||
onAddContext: showSelectDataSet,
|
||||
}}
|
||||
variableBlock={{
|
||||
show: true,
|
||||
variables: modelConfig.configs.prompt_variables.filter(item => item.key && item.key.trim() && item.name && item.name.trim()).map(item => ({
|
||||
name: item.name,
|
||||
value: item.key,
|
||||
})),
|
||||
}}
|
||||
externalToolBlock={{
|
||||
show: true,
|
||||
externalTools: externalDataToolsConfig.map(item => ({
|
||||
name: item.label!,
|
||||
variableName: item.variable!,
|
||||
icon: item.icon,
|
||||
icon_background: item.icon_background,
|
||||
})),
|
||||
onAddExternalTool: handleOpenExternalDataToolModal,
|
||||
}}
|
||||
historyBlock={{
|
||||
show: false,
|
||||
selectable: false,
|
||||
history: {
|
||||
user: '',
|
||||
assistant: '',
|
||||
},
|
||||
onEditRole: noop,
|
||||
}}
|
||||
queryBlock={{
|
||||
show: false,
|
||||
selectable: false,
|
||||
}}
|
||||
onChange={onChange}
|
||||
onBlur={noop}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex pb-2 pl-4'>
|
||||
<div className="h-[18px] rounded-md bg-gray-100 px-1 text-xs leading-[18px] text-gray-500">{value.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Editor)
|
||||
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiArrowDownSLine } from '@remixicon/react'
|
||||
import AgentSetting from '../agent/agent-setting'
|
||||
import cn from '@/utils/classnames'
|
||||
import {
|
||||
PortalToFollowElem,
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { BubbleText } from '@/app/components/base/icons/src/vender/solid/education'
|
||||
import Radio from '@/app/components/base/radio/ui'
|
||||
import { CuteRobot } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import { ArrowUpRight } from '@/app/components/base/icons/src/vender/line/arrows'
|
||||
import type { AgentConfig } from '@/models/debug'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
disabled: boolean
|
||||
onChange: (value: string) => void
|
||||
isFunctionCall: boolean
|
||||
isChatModel: boolean
|
||||
agentConfig?: AgentConfig
|
||||
onAgentSettingChange: (payload: AgentConfig) => void
|
||||
}
|
||||
|
||||
type ItemProps = {
|
||||
text: string
|
||||
disabled: boolean
|
||||
value: string
|
||||
isChecked: boolean
|
||||
description: string
|
||||
Icon: any
|
||||
onClick: (value: string) => void
|
||||
}
|
||||
|
||||
const SelectItem: FC<ItemProps> = ({ text, value, Icon, isChecked, description, onClick, disabled }) => {
|
||||
return (
|
||||
<div
|
||||
className={cn(disabled ? 'opacity-50' : 'cursor-pointer', isChecked ? 'border-[2px] border-indigo-600 shadow-sm' : 'border border-gray-100', 'mb-2 rounded-xl bg-gray-25 p-3 pr-4 hover:bg-gray-50')}
|
||||
onClick={() => !disabled && onClick(value)}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center '>
|
||||
<div className='mr-3 rounded-lg bg-indigo-50 p-1'>
|
||||
<Icon className='h-4 w-4 text-indigo-600' />
|
||||
</div>
|
||||
<div className='text-sm font-medium leading-5 text-gray-900'>{text}</div>
|
||||
</div>
|
||||
<Radio isChecked={isChecked} />
|
||||
</div>
|
||||
<div className='ml-9 text-xs font-normal leading-[18px] text-gray-500'>{description}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const AssistantTypePicker: FC<Props> = ({
|
||||
value,
|
||||
disabled,
|
||||
onChange,
|
||||
onAgentSettingChange,
|
||||
isFunctionCall,
|
||||
isChatModel,
|
||||
agentConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const handleChange = (chosenValue: string) => {
|
||||
if (value === chosenValue)
|
||||
return
|
||||
|
||||
onChange(chosenValue)
|
||||
if (chosenValue !== 'agent')
|
||||
setOpen(false)
|
||||
}
|
||||
const isAgent = value === 'agent'
|
||||
const [isShowAgentSetting, setIsShowAgentSetting] = useState(false)
|
||||
|
||||
const agentConfigUI = (
|
||||
<>
|
||||
<div className='my-4 h-px bg-gray-100'></div>
|
||||
<div
|
||||
className={cn(isAgent ? 'group cursor-pointer hover:bg-primary-50' : 'opacity-30', 'rounded-xl bg-gray-50 p-3 pr-4 ')}
|
||||
onClick={() => {
|
||||
if (isAgent) {
|
||||
setOpen(false)
|
||||
setIsShowAgentSetting(true)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className='flex items-center '>
|
||||
<div className='mr-3 rounded-lg bg-gray-200 p-1 group-hover:bg-white'>
|
||||
<Settings04 className='h-4 w-4 text-gray-600 group-hover:text-[#155EEF]' />
|
||||
</div>
|
||||
<div className='text-sm font-medium leading-5 text-gray-900 group-hover:text-[#155EEF]'>{t('appDebug.agent.setting.name')}</div>
|
||||
</div>
|
||||
<ArrowUpRight className='h-4 w-4 text-gray-500 group-hover:text-[#155EEF]' />
|
||||
</div>
|
||||
<div className='ml-9 text-xs font-normal leading-[18px] text-gray-500'>{t('appDebug.agent.setting.description')}</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
return (
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement='bottom-end'
|
||||
offset={{
|
||||
mainAxis: 8,
|
||||
crossAxis: -2,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger onClick={() => setOpen(v => !v)}>
|
||||
<div className={cn(open && 'bg-gray-50', 'flex h-8 cursor-pointer select-none items-center space-x-1 rounded-lg border border-black/5 px-3 text-indigo-600')}>
|
||||
{isAgent ? <BubbleText className='h-3 w-3' /> : <CuteRobot className='h-3 w-3' />}
|
||||
<div className='text-xs font-medium'>{t(`appDebug.assistantType.${isAgent ? 'agentAssistant' : 'chatAssistant'}.name`)}</div>
|
||||
<RiArrowDownSLine className='h-3 w-3' />
|
||||
</div>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
|
||||
<div className='relative left-0.5 w-[480px] rounded-xl border border-black/8 bg-white p-6 shadow-lg'>
|
||||
<div className='mb-2 text-sm font-semibold leading-5 text-gray-900'>{t('appDebug.assistantType.name')}</div>
|
||||
<SelectItem
|
||||
Icon={BubbleText}
|
||||
value='chat'
|
||||
disabled={disabled}
|
||||
text={t('appDebug.assistantType.chatAssistant.name')}
|
||||
description={t('appDebug.assistantType.chatAssistant.description')}
|
||||
isChecked={!isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
<SelectItem
|
||||
Icon={CuteRobot}
|
||||
value='agent'
|
||||
disabled={disabled}
|
||||
text={t('appDebug.assistantType.agentAssistant.name')}
|
||||
description={t('appDebug.assistantType.agentAssistant.description')}
|
||||
isChecked={isAgent}
|
||||
onClick={handleChange}
|
||||
/>
|
||||
{!disabled && agentConfigUI}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
{isShowAgentSetting && (
|
||||
<AgentSetting
|
||||
isFunctionCall={isFunctionCall}
|
||||
payload={agentConfig as AgentConfig}
|
||||
isChatModel={isChatModel}
|
||||
onSave={(payloadNew) => {
|
||||
onAgentSettingChange(payloadNew)
|
||||
setIsShowAgentSetting(false)
|
||||
}}
|
||||
onCancel={() => setIsShowAgentSetting(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default React.memo(AssistantTypePicker)
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiSparklingFill,
|
||||
} from '@remixicon/react'
|
||||
import Button from '@/app/components/base/button'
|
||||
|
||||
export type IAutomaticBtnProps = {
|
||||
onClick: () => void
|
||||
}
|
||||
const AutomaticBtn: FC<IAutomaticBtnProps> = ({
|
||||
onClick,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<Button variant='secondary-accent' size='small' onClick={onClick}>
|
||||
<RiSparklingFill className='mr-1 h-3.5 w-3.5' />
|
||||
<span className=''>{t('appDebug.operation.automatic')}</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
export default React.memo(AutomaticBtn)
|
||||
@@ -0,0 +1,407 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean, useSessionStorageState } from 'ahooks'
|
||||
import {
|
||||
RiDatabase2Line,
|
||||
RiFileExcel2Line,
|
||||
RiGitCommitLine,
|
||||
RiNewspaperLine,
|
||||
RiPresentationLine,
|
||||
RiRoadMapLine,
|
||||
RiTerminalBoxLine,
|
||||
RiTranslate,
|
||||
RiUser2Line,
|
||||
} from '@remixicon/react'
|
||||
import s from './style.module.css'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { generateBasicAppFirstTimeRule, generateRule } from '@/service/debug'
|
||||
import type { AppModeEnum, CompletionParams, Model } from '@/types/app'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
|
||||
// type
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import type { ModelModeType } from '@/types/app'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import InstructionEditorInWorkflow from './instruction-editor-in-workflow'
|
||||
import InstructionEditorInBasic from './instruction-editor'
|
||||
import { GeneratorType } from './types'
|
||||
import Result from './result'
|
||||
import useGenData from './use-gen-data'
|
||||
import IdeaOutput from './idea-output'
|
||||
import ResPlaceholder from './res-placeholder'
|
||||
import { useGenerateRuleTemplate } from '@/service/use-apps'
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
export type IGetAutomaticResProps = {
|
||||
mode: AppModeEnum
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onFinished: (res: GenRes) => void
|
||||
flowId?: string
|
||||
nodeId?: string
|
||||
editorId?: string
|
||||
currentPrompt?: string
|
||||
isBasicMode?: boolean
|
||||
}
|
||||
|
||||
const TryLabel: FC<{
|
||||
Icon: any
|
||||
text: string
|
||||
onClick: () => void
|
||||
}> = ({ Icon, text, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className='mr-1 mt-2 flex h-7 shrink-0 cursor-pointer items-center rounded-lg bg-components-button-secondary-bg px-2'
|
||||
onClick={onClick}
|
||||
>
|
||||
<Icon className='h-4 w-4 text-text-tertiary'></Icon>
|
||||
<div className='ml-1 text-xs font-medium text-text-secondary'>{text}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
mode,
|
||||
isShow,
|
||||
onClose,
|
||||
flowId,
|
||||
nodeId,
|
||||
editorId,
|
||||
currentPrompt,
|
||||
isBasicMode,
|
||||
onFinished,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
||||
: null
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const tryList = [
|
||||
{
|
||||
icon: RiTerminalBoxLine,
|
||||
key: 'pythonDebugger',
|
||||
},
|
||||
{
|
||||
icon: RiTranslate,
|
||||
key: 'translation',
|
||||
},
|
||||
{
|
||||
icon: RiPresentationLine,
|
||||
key: 'meetingTakeaways',
|
||||
},
|
||||
{
|
||||
icon: RiNewspaperLine,
|
||||
key: 'writingsPolisher',
|
||||
},
|
||||
{
|
||||
icon: RiUser2Line,
|
||||
key: 'professionalAnalyst',
|
||||
},
|
||||
{
|
||||
icon: RiFileExcel2Line,
|
||||
key: 'excelFormulaExpert',
|
||||
},
|
||||
{
|
||||
icon: RiRoadMapLine,
|
||||
key: 'travelPlanning',
|
||||
},
|
||||
{
|
||||
icon: RiDatabase2Line,
|
||||
key: 'SQLSorcerer',
|
||||
},
|
||||
{
|
||||
icon: RiGitCommitLine,
|
||||
key: 'GitGud',
|
||||
},
|
||||
]
|
||||
|
||||
// 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>('')
|
||||
|
||||
const [editorKey, setEditorKey] = useState(`${flowId}-0`)
|
||||
const handleChooseTemplate = useCallback((key: string) => {
|
||||
return () => {
|
||||
const template = t(`appDebug.generate.template.${key}.instruction`)
|
||||
setInstruction(template)
|
||||
setEditorKey(`${flowId}-${Date.now()}`)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const { data: instructionTemplate } = useGenerateRuleTemplate(GeneratorType.prompt, isBasicMode)
|
||||
useEffect(() => {
|
||||
if (!instruction && instructionTemplate)
|
||||
setInstruction(instructionTemplate.data)
|
||||
|
||||
setEditorKey(`${flowId}-${Date.now()}`)
|
||||
}, [instructionTemplate])
|
||||
|
||||
const isValid = () => {
|
||||
if (instruction.trim() === '') {
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: t('common.errorMsg.fieldRequired', {
|
||||
field: t('appDebug.generate.instruction'),
|
||||
}),
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const storageKey = `${flowId}${isBasicMode ? '' : `-${nodeId}${editorId ? `-${editorId}` : ''}`}`
|
||||
const { addVersion, current, currentVersionIndex, setCurrentVersionIndex, versions } = useGenData({
|
||||
storageKey,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
||||
: null
|
||||
if (localModel) {
|
||||
setModel(localModel)
|
||||
}
|
||||
else {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const renderLoading = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3'>
|
||||
<Loading />
|
||||
<div className='text-[13px] text-text-tertiary'>{t('appDebug.generate.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!isValid())
|
||||
return
|
||||
if (isLoading)
|
||||
return
|
||||
setLoadingTrue()
|
||||
try {
|
||||
let apiRes: GenRes
|
||||
let hasError = false
|
||||
if (isBasicMode || !currentPrompt) {
|
||||
const { error, ...res } = await generateBasicAppFirstTimeRule({
|
||||
instruction,
|
||||
model_config: model,
|
||||
no_variable: false,
|
||||
})
|
||||
apiRes = {
|
||||
...res,
|
||||
modified: res.prompt,
|
||||
} as GenRes
|
||||
if (error) {
|
||||
hasError = true
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
else {
|
||||
const { error, ...res } = await generateRule({
|
||||
flow_id: flowId,
|
||||
node_id: nodeId,
|
||||
current: currentPrompt,
|
||||
instruction,
|
||||
ideal_output: ideaOutput,
|
||||
model_config: model,
|
||||
})
|
||||
apiRes = res
|
||||
if (error) {
|
||||
hasError = true
|
||||
Toast.notify({
|
||||
type: 'error',
|
||||
message: error,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (!hasError)
|
||||
addVersion(apiRes)
|
||||
}
|
||||
finally {
|
||||
setLoadingFalse()
|
||||
}
|
||||
}
|
||||
|
||||
const [isShowConfirmOverwrite, {
|
||||
setTrue: showConfirmOverwrite,
|
||||
setFalse: hideShowConfirmOverwrite,
|
||||
}] = useBoolean(false)
|
||||
|
||||
const isShowAutoPromptResPlaceholder = () => {
|
||||
return !isLoading && !current
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isShow={isShow}
|
||||
onClose={onClose}
|
||||
className='min-w-[1140px] !p-0'
|
||||
>
|
||||
<div className='flex h-[680px] flex-wrap'>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-6'>
|
||||
<div className='mb-5'>
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('appDebug.generate.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.generate.description')}</div>
|
||||
</div>
|
||||
<div>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[520px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
{isBasicMode && (
|
||||
<div className='mt-4'>
|
||||
<div className='flex items-center'>
|
||||
<div className='mr-3 shrink-0 text-xs font-semibold uppercase leading-[18px] text-text-tertiary'>{t('appDebug.generate.tryIt')}</div>
|
||||
<div className='h-px grow' style={{
|
||||
background: 'linear-gradient(to right, rgba(243, 244, 246, 1), rgba(243, 244, 246, 0))',
|
||||
}}></div>
|
||||
</div>
|
||||
<div className='flex flex-wrap'>
|
||||
{tryList.map(item => (
|
||||
<TryLabel
|
||||
key={item.key}
|
||||
Icon={item.icon}
|
||||
text={t(`appDebug.generate.template.${item.key}.name`)}
|
||||
onClick={handleChooseTemplate(item.key)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* inputs */}
|
||||
<div className='mt-4'>
|
||||
<div>
|
||||
<div className='system-sm-semibold-uppercase mb-1.5 text-text-secondary'>{t('appDebug.generate.instruction')}</div>
|
||||
{isBasicMode ? (
|
||||
<InstructionEditorInBasic
|
||||
editorKey={editorKey}
|
||||
generatorType={GeneratorType.prompt}
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
availableVars={[]}
|
||||
availableNodes={[]}
|
||||
isShowCurrentBlock={!!currentPrompt}
|
||||
isShowLastRunBlock={false}
|
||||
/>
|
||||
) : (
|
||||
<InstructionEditorInWorkflow
|
||||
editorKey={editorKey}
|
||||
generatorType={GeneratorType.prompt}
|
||||
value={instruction}
|
||||
onChange={setInstruction}
|
||||
nodeId={nodeId || ''}
|
||||
isShowCurrentBlock={!!currentPrompt}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<IdeaOutput
|
||||
value={ideaOutput}
|
||||
onChange={setIdeaOutput}
|
||||
/>
|
||||
|
||||
<div className='mt-7 flex justify-end space-x-2'>
|
||||
<Button onClick={onClose}>{t(`${i18nPrefix}.dismiss`)}</Button>
|
||||
<Button
|
||||
className='flex space-x-1'
|
||||
variant='primary'
|
||||
onClick={onGenerate}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<Generator className='h-4 w-4' />
|
||||
<span className='text-xs font-semibold'>{t('appDebug.generate.generate')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!isLoading && current) && (
|
||||
<div className='h-full w-0 grow bg-background-default-subtle p-6 pb-0'>
|
||||
<Result
|
||||
current={current!}
|
||||
isBasicMode={isBasicMode}
|
||||
nodeId={nodeId!}
|
||||
currentVersionIndex={currentVersionIndex || 0}
|
||||
setCurrentVersionIndex={setCurrentVersionIndex}
|
||||
versions={versions || []}
|
||||
onApply={showConfirmOverwrite}
|
||||
generatorType={GeneratorType.prompt}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isLoading && renderLoading}
|
||||
{isShowAutoPromptResPlaceholder() && <ResPlaceholder />}
|
||||
{isShowConfirmOverwrite && (
|
||||
<Confirm
|
||||
title={t('appDebug.generate.overwriteTitle')}
|
||||
content={t('appDebug.generate.overwriteMessage')}
|
||||
isShow
|
||||
onConfirm={() => {
|
||||
hideShowConfirmOverwrite()
|
||||
onFinished(current!)
|
||||
}}
|
||||
onCancel={hideShowConfirmOverwrite}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default React.memo(GetAutomaticRes)
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
const IdeaOutput: FC<Props> = ({
|
||||
value,
|
||||
onChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const [isFoldIdeaOutput, {
|
||||
toggle: toggleFoldIdeaOutput,
|
||||
}] = useBoolean(true)
|
||||
|
||||
return (
|
||||
<div className='mt-4 text-[0px]'>
|
||||
<div
|
||||
className='mb-1.5 flex cursor-pointer items-center text-sm font-medium leading-5 text-text-primary'
|
||||
onClick={toggleFoldIdeaOutput}
|
||||
>
|
||||
<div className='system-sm-semibold-uppercase mr-1 text-text-secondary'>{t(`${i18nPrefix}.idealOutput`)}</div>
|
||||
<div className='system-xs-regular text-text-tertiary'>({t(`${i18nPrefix}.optional`)})</div>
|
||||
<ArrowDownRoundFill className={cn('size text-text-quaternary', isFoldIdeaOutput && 'relative top-[1px] rotate-[-90deg]')} />
|
||||
</div>
|
||||
{!isFoldIdeaOutput && (
|
||||
<Textarea
|
||||
className="h-[80px]"
|
||||
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`)}
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(IdeaOutput)
|
||||
@@ -0,0 +1,58 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import type { GeneratorType } from './types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import InstructionEditor from './instruction-editor'
|
||||
import { useWorkflowVariableType } from '@/app/components/workflow/hooks'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type Props = {
|
||||
nodeId: string
|
||||
value: string
|
||||
editorKey: string
|
||||
onChange: (text: string) => void
|
||||
generatorType: GeneratorType
|
||||
isShowCurrentBlock: boolean
|
||||
}
|
||||
|
||||
const InstructionEditorInWorkflow: FC<Props> = ({
|
||||
nodeId,
|
||||
value,
|
||||
editorKey,
|
||||
onChange,
|
||||
generatorType,
|
||||
isShowCurrentBlock,
|
||||
}) => {
|
||||
const workflowStore = useWorkflowStore()
|
||||
const filterVar = useCallback((payload: Var, selector: ValueSelector) => {
|
||||
const { nodesWithInspectVars } = workflowStore.getState()
|
||||
const nodeId = selector?.[0]
|
||||
return !!nodesWithInspectVars.find(node => node.nodeId === nodeId) && payload.type !== VarType.file && payload.type !== VarType.arrayFile
|
||||
}, [workflowStore])
|
||||
const {
|
||||
availableVars,
|
||||
availableNodes,
|
||||
} = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar,
|
||||
})
|
||||
const getVarType = useWorkflowVariableType()
|
||||
|
||||
return (
|
||||
<InstructionEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
editorKey={editorKey}
|
||||
generatorType={generatorType}
|
||||
availableVars={availableVars}
|
||||
availableNodes={availableNodes}
|
||||
getVarType={getVarType}
|
||||
isShowCurrentBlock={isShowCurrentBlock}
|
||||
isShowLastRunBlock
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(InstructionEditorInWorkflow)
|
||||
@@ -0,0 +1,117 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { GeneratorType } from './types'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { Node, NodeOutPutVar, ValueSelector } from '@/app/components/workflow/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { PROMPT_EDITOR_INSERT_QUICKLY } from '@/app/components/base/prompt-editor/plugins/update-block'
|
||||
import { useEventEmitterContextContext } from '@/context/event-emitter'
|
||||
|
||||
type Props = {
|
||||
editorKey: string
|
||||
value: string
|
||||
onChange: (text: string) => void
|
||||
generatorType: GeneratorType
|
||||
availableVars: NodeOutPutVar[]
|
||||
availableNodes: Node[]
|
||||
getVarType?: (params: {
|
||||
nodeId: string,
|
||||
valueSelector: ValueSelector,
|
||||
}) => Type
|
||||
isShowCurrentBlock: boolean
|
||||
isShowLastRunBlock: boolean
|
||||
}
|
||||
|
||||
const i18nPrefix = 'appDebug.generate'
|
||||
|
||||
const InstructionEditor: FC<Props> = ({
|
||||
editorKey,
|
||||
generatorType,
|
||||
value,
|
||||
onChange,
|
||||
availableVars,
|
||||
availableNodes,
|
||||
getVarType = () => Type.string,
|
||||
isShowCurrentBlock,
|
||||
isShowLastRunBlock,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
|
||||
const isCode = generatorType === 'code'
|
||||
const placeholder = isCode ? <div className='system-sm-regular whitespace-break-spaces !leading-6 text-text-placeholder'>
|
||||
{t(`${i18nPrefix}.codeGenInstructionPlaceHolderLine`)}
|
||||
</div> : (
|
||||
<div className='system-sm-regular text-text-placeholder'>
|
||||
<div className='leading-6'>{t(`${i18nPrefix}.instructionPlaceHolderTitle`)}</div>
|
||||
<div className='mt-2'>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine1`)}</div>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine2`)}</div>
|
||||
<div>{t(`${i18nPrefix}.instructionPlaceHolderLine3`)}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleInsertVariable = () => {
|
||||
eventEmitter?.emit({ type: PROMPT_EDITOR_INSERT_QUICKLY, instanceId: editorKey } as any)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<PromptEditor
|
||||
wrapperClassName='border !border-components-input-bg-normal bg-components-input-bg-normal hover:!border-components-input-bg-hover rounded-[10px] px-4 pt-3'
|
||||
key={editorKey}
|
||||
instanceId={editorKey}
|
||||
placeholder={placeholder}
|
||||
placeholderClassName='px-4 pt-3'
|
||||
className={cn('min-h-[240px] pb-8')}
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars,
|
||||
getVarType,
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
position: node.position,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('workflow.blocks.start'),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {} as any),
|
||||
}}
|
||||
currentBlock={{
|
||||
show: isShowCurrentBlock,
|
||||
generatorType,
|
||||
}}
|
||||
errorMessageBlock={{
|
||||
show: isCode,
|
||||
}}
|
||||
lastRunBlock={{
|
||||
show: isShowLastRunBlock,
|
||||
}}
|
||||
onChange={onChange}
|
||||
editable
|
||||
isSupportFileVar={false}
|
||||
/>
|
||||
<div className='system-xs-regular absolute bottom-0 left-4 flex h-8 items-center space-x-0.5 text-components-input-text-placeholder'>
|
||||
<span>{t('appDebug.generate.press')}</span>
|
||||
<span className='system-kbd flex h-4 w-3.5 items-center justify-center rounded-[4px] bg-components-kbd-bg-gray text-text-placeholder'>/</span>
|
||||
<span>{t('appDebug.generate.to')}</span>
|
||||
<span onClick={handleInsertVariable} className='!ml-1 cursor-pointer hover:border-b hover:border-dotted hover:border-text-tertiary hover:text-text-tertiary'>{t('appDebug.generate.insertContext')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(InstructionEditor)
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
import useAvailableVarList from '@/app/components/workflow/nodes/_base/hooks/use-available-var-list'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import PromptRes from './prompt-res'
|
||||
import { Type } from '@/app/components/workflow/nodes/llm/types'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
nodeId: string
|
||||
}
|
||||
|
||||
const PromptResInWorkflow: FC<Props> = ({
|
||||
value,
|
||||
nodeId,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
availableVars,
|
||||
availableNodes,
|
||||
} = useAvailableVarList(nodeId, {
|
||||
onlyLeafNodeVar: false,
|
||||
filterVar: _payload => true,
|
||||
})
|
||||
return (
|
||||
<PromptRes
|
||||
value={value}
|
||||
workflowVariableBlock={{
|
||||
show: true,
|
||||
variables: availableVars || [],
|
||||
getVarType: () => Type.string,
|
||||
workflowNodesMap: availableNodes.reduce((acc, node) => {
|
||||
acc[node.id] = {
|
||||
title: node.data.title,
|
||||
type: node.data.type,
|
||||
width: node.width,
|
||||
height: node.height,
|
||||
position: node.position,
|
||||
}
|
||||
if (node.data.type === BlockEnum.Start) {
|
||||
acc.sys = {
|
||||
title: t('workflow.blocks.start'),
|
||||
type: BlockEnum.Start,
|
||||
}
|
||||
}
|
||||
return acc
|
||||
}, {} as any),
|
||||
}}
|
||||
>
|
||||
</PromptRes>
|
||||
)
|
||||
}
|
||||
export default React.memo(PromptResInWorkflow)
|
||||
@@ -0,0 +1,31 @@
|
||||
'use client'
|
||||
import PromptEditor from '@/app/components/base/prompt-editor'
|
||||
import type { WorkflowVariableBlockType } from '@/app/components/base/prompt-editor/types'
|
||||
import type { FC } from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
|
||||
type Props = {
|
||||
value: string
|
||||
workflowVariableBlock: WorkflowVariableBlockType
|
||||
}
|
||||
|
||||
const keyIdPrefix = 'prompt-res-editor'
|
||||
const PromptRes: FC<Props> = ({
|
||||
value,
|
||||
workflowVariableBlock,
|
||||
}) => {
|
||||
const [editorKey, setEditorKey] = React.useState<string>('keyIdPrefix-0')
|
||||
useEffect(() => {
|
||||
setEditorKey(`${keyIdPrefix}-${Date.now()}`)
|
||||
}, [value])
|
||||
return (
|
||||
<PromptEditor
|
||||
key={editorKey}
|
||||
value={value}
|
||||
editable={false}
|
||||
className='h-full bg-transparent pt-0'
|
||||
workflowVariableBlock={workflowVariableBlock}
|
||||
/>
|
||||
)
|
||||
}
|
||||
export default React.memo(PromptRes)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { RiArrowDownSLine, RiSparklingFill } from '@remixicon/react'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import React from 'react'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import s from './style.module.css'
|
||||
|
||||
type Props = {
|
||||
message: string
|
||||
className?: string
|
||||
}
|
||||
const PromptToast = ({
|
||||
message,
|
||||
className,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const [isFold, {
|
||||
toggle: toggleFold,
|
||||
}] = useBoolean(false)
|
||||
// const message = `
|
||||
// list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1
|
||||
// # h1
|
||||
// **strong text** ~~strikethrough~~
|
||||
|
||||
// * list1list1list1list1list1list1list1list1list1list1list1list1list1list1list1
|
||||
// * list2
|
||||
|
||||
// xxxx
|
||||
|
||||
// ## h2
|
||||
// \`\`\`python
|
||||
// print('Hello, World!')
|
||||
// \`\`\`
|
||||
// `
|
||||
return (
|
||||
<div className={cn('rounded-xl border-[0.5px] border-components-panel-border bg-background-section-burn pl-4 shadow-xs', className)}>
|
||||
<div className='my-3 flex h-4 items-center justify-between pr-3'>
|
||||
<div className='flex items-center space-x-1'>
|
||||
<RiSparklingFill className='size-3.5 text-components-input-border-active-prompt-1' />
|
||||
<span className={cn(s.optimizationNoteText, 'system-xs-semibold-uppercase')}>{t('appDebug.generate.optimizationNote')}</span>
|
||||
</div>
|
||||
<RiArrowDownSLine className={cn('size-4 cursor-pointer text-text-tertiary', isFold && 'rotate-[-90deg]')} onClick={toggleFold} />
|
||||
</div>
|
||||
{!isFold && (
|
||||
<div className='pb-4 pr-4'>
|
||||
<Markdown className="!text-sm" content={message} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PromptToast
|
||||
@@ -0,0 +1,18 @@
|
||||
'use client'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const ResPlaceholder: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
|
||||
<Generator className='size-8 text-text-quaternary' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
|
||||
<div>{t('appDebug.generate.newNoDataLine1')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(ResPlaceholder)
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { GeneratorType } from './types'
|
||||
import PromptToast from './prompt-toast'
|
||||
import Button from '@/app/components/base/button'
|
||||
import VersionSelector from './version-selector'
|
||||
import type { GenRes } from '@/service/debug'
|
||||
import { RiClipboardLine } from '@remixicon/react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/code-editor'
|
||||
import PromptRes from './prompt-res'
|
||||
import PromptResInWorkflow from './prompt-res-in-workflow'
|
||||
|
||||
type Props = {
|
||||
isBasicMode?: boolean
|
||||
nodeId?: string
|
||||
current: GenRes
|
||||
currentVersionIndex: number
|
||||
setCurrentVersionIndex: (index: number) => void
|
||||
versions: GenRes[]
|
||||
onApply: () => void
|
||||
generatorType: GeneratorType
|
||||
}
|
||||
|
||||
const Result: FC<Props> = ({
|
||||
isBasicMode,
|
||||
nodeId,
|
||||
current,
|
||||
currentVersionIndex,
|
||||
setCurrentVersionIndex,
|
||||
versions,
|
||||
onApply,
|
||||
generatorType,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const isGeneratorPrompt = generatorType === GeneratorType.prompt
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<div className='mb-3 flex shrink-0 items-center justify-between'>
|
||||
<div>
|
||||
<div className='shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.generate.resTitle')}</div>
|
||||
<VersionSelector
|
||||
versionLen={versions.length}
|
||||
value={currentVersionIndex}
|
||||
onChange={setCurrentVersionIndex}
|
||||
/>
|
||||
</div>
|
||||
<div className='flex items-center space-x-2'>
|
||||
<Button className='px-2' onClick={() => {
|
||||
copy(current.modified)
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
}}>
|
||||
<RiClipboardLine className='h-4 w-4 text-text-secondary' />
|
||||
</Button>
|
||||
<Button variant='primary' onClick={onApply}>
|
||||
{t('appDebug.generate.apply')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex grow flex-col overflow-y-auto'>
|
||||
{
|
||||
current?.message && (
|
||||
<PromptToast message={current.message} className='mb-3 shrink-0' />
|
||||
)
|
||||
}
|
||||
<div className='grow pb-6'>
|
||||
{isGeneratorPrompt ? (
|
||||
isBasicMode ? (
|
||||
<PromptRes
|
||||
value={current?.modified}
|
||||
workflowVariableBlock={{
|
||||
show: false,
|
||||
}}
|
||||
/>
|
||||
) : (<PromptResInWorkflow
|
||||
value={current?.modified || ''}
|
||||
nodeId={nodeId!}
|
||||
/>)
|
||||
) : (
|
||||
<CodeEditor
|
||||
editorWrapperClassName='h-full'
|
||||
className='bg-transparent pt-0'
|
||||
value={current?.modified}
|
||||
readOnly
|
||||
hideTopMenu
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default React.memo(Result)
|
||||
@@ -0,0 +1,13 @@
|
||||
.textGradient {
|
||||
background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.optimizationNoteText {
|
||||
background: linear-gradient(263deg, rgba(21, 90, 239, 0.95) -20.92%, rgba(11, 165, 236, 0.95) 87.04%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum GeneratorType {
|
||||
prompt = 'prompt',
|
||||
code = 'code',
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user