Initial commit

This commit is contained in:
2025-10-14 14:17:21 +08:00
commit ac715a8b88
35011 changed files with 3834178 additions and 0 deletions

View File

@@ -0,0 +1,382 @@
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext, useContextSelector } from 'use-context-selector'
import React, { useCallback, useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import AppIcon from '../base/app-icon'
import SwitchAppModal from '../app/switch-app-modal'
import cn from '@/utils/classnames'
import Confirm from '@/app/components/base/confirm'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import AppsContext, { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import DuplicateAppModal from '@/app/components/app/duplicate-modal'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import CreateAppModal from '@/app/components/explore/create-app-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 UpdateDSLModal from '@/app/components/workflow/update-dsl-modal'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import DSLExportConfirmModal from '@/app/components/workflow/dsl-export-confirm-modal'
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/cardView'
export type IAppInfoProps = {
expand: boolean
}
const AppInfo = ({ expand }: 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(false)
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 mutateApps = useContextSelector(
AppsContext,
state => state.mutateApps,
)
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
}) => {
if (!appDetail)
return
try {
const app = await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
})
setShowEditModal(false)
notify({
type: 'success',
message: t('app.editDone'),
})
setAppDetail(app)
mutateApps()
}
catch (e) {
notify({ type: 'error', message: t('app.editFailed') })
}
}, [appDetail, mutateApps, 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')
mutateApps()
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
catch (e) {
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' })
a.href = URL.createObjectURL(file)
a.download = `${appDetail.name}.yml`
a.click()
}
catch (e) {
notify({ type: 'error', message: t('app.exportFailed') })
}
}
const exportCheck = async () => {
if (!appDetail)
return
if (appDetail.mode !== 'workflow' && appDetail.mode !== 'advanced-chat') {
onExport()
return
}
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 (e) {
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') })
mutateApps()
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
notify({
type: 'error',
message: `${t('app.appDeleteFailed')}${'message' in e ? `: ${e.message}` : ''}`,
})
}
setShowConfirmDelete(false)
}, [appDetail, mutateApps, notify, onPlanInfoChanged, replace, t])
const { isCurrentWorkspaceEditor } = useAppContext()
if (!appDetail)
return null
return (
<div>
<button
onClick={() => {
if (isCurrentWorkspaceEditor)
setOpen(v => !v)
}}
className='block w-full'
>
<div className={cn('flex rounded-lg', expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1', open && 'bg-state-base-hover', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}>
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
<AppIcon
size={expand ? 'large' : 'small'}
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>
{
expand && (
<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 === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
)
}
</div>
</button>
<ContentDialog
show={open}
onClose={() => setOpen(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 w-full grow flex-col items-start justify-center'>
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
</div>
{/* description */}
{appDetail.description && (
<div className='system-xs-regular text-text-tertiary'>{appDetail.description}</div>
)}
{/* operations */}
<div className='flex flex-wrap items-center gap-1 self-stretch'>
<Button
size={'small'}
variant={'secondary'}
className='gap-[1px]'
onClick={() => {
setOpen(false)
setShowEditModal(true)
}}
>
<RiEditLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.editApp')}</span>
</Button>
<Button
size={'small'}
variant={'secondary'}
className='gap-[1px]'
onClick={() => {
setOpen(false)
setShowDuplicateModal(true)
}}
>
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
</Button>
<Button
size={'small'}
variant={'secondary'}
className='gap-[1px]'
onClick={exportCheck}
>
<RiFileDownloadLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.export')}</span>
</Button>
{
(appDetail.mode === 'advanced-chat' || appDetail.mode === 'workflow') && (
<Button
size={'small'}
variant={'secondary'}
className='gap-[1px]'
onClick={() => {
setOpen(false)
setShowImportDSLModal(true)
}}
>
<RiFileUploadLine className='h-3.5 w-3.5 text-components-button-secondary-text' />
<span className='system-xs-medium text-components-button-secondary-text'>{t('workflow.common.importDSL')}</span>
</Button>
)
}
</div>
</div>
<div className='flex flex-1'>
<CardView
appId={appDetail.id}
isInPanel={true}
className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
/>
</div>
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
<Button
size={'medium'}
variant={'ghost'}
className='gap-0.5'
onClick={() => {
setOpen(false)
setShowConfirmDelete(true)
}}
>
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
<span className='system-sm-medium text-text-tertiary'>{t('common.operation.deleteApp')}</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}
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([])}
/>
)}
</div>
)
}
export default React.memo(AppInfo)

View File

@@ -0,0 +1,99 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
import Tooltip from '@/app/components/base/tooltip'
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
}
const ApiSvg = <svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.5 3.5C8.5 4.60457 9.39543 5.5 10.5 5.5C11.6046 5.5 12.5 4.60457 12.5 3.5C12.5 2.39543 11.6046 1.5 10.5 1.5C9.39543 1.5 8.5 2.39543 8.5 3.5Z" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.5 9C12.5 10.1046 13.3954 11 14.5 11C15.6046 11 16.5 10.1046 16.5 9C16.5 7.89543 15.6046 7 14.5 7C13.3954 7 12.5 7.89543 12.5 9Z" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 3.5H5.5L3.5 6.5" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 14.5C8.5 15.6046 9.39543 16.5 10.5 16.5C11.6046 16.5 12.5 15.6046 12.5 14.5C12.5 13.3954 11.6046 12.5 10.5 12.5C9.39543 12.5 8.5 13.3954 8.5 14.5Z" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 14.5H5.5L3.5 11.5" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M12.5 9H1.5" stroke="#5850EC" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
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 WebappSvg = <svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.375 5.45825L7.99998 8.99992M7.99998 8.99992L1.62498 5.45825M7.99998 8.99992L8 16.1249M14.75 12.0439V5.95603C14.75 5.69904 14.75 5.57055 14.7121 5.45595C14.6786 5.35457 14.6239 5.26151 14.5515 5.18299C14.4697 5.09424 14.3574 5.03184 14.1328 4.90704L8.58277 1.8237C8.37007 1.70553 8.26372 1.64645 8.15109 1.62329C8.05141 1.60278 7.9486 1.60278 7.84891 1.62329C7.73628 1.64645 7.62993 1.70553 7.41723 1.8237L1.86723 4.90704C1.64259 5.03184 1.53026 5.09424 1.44847 5.18299C1.37612 5.26151 1.32136 5.35457 1.28786 5.45595C1.25 5.57055 1.25 5.69904 1.25 5.95603V12.0439C1.25 12.3008 1.25 12.4293 1.28786 12.5439C1.32136 12.6453 1.37612 12.7384 1.44847 12.8169C1.53026 12.9056 1.64259 12.968 1.86723 13.0928L7.41723 16.1762C7.62993 16.2943 7.73628 16.3534 7.84891 16.3766C7.9486 16.3971 8.05141 16.3971 8.15109 16.3766C8.26372 16.3534 8.37007 16.2943 8.58277 16.1762L14.1328 13.0928C14.3574 12.968 14.4697 12.9056 14.5515 12.8169C14.6239 12.7384 14.6786 12.6453 14.7121 12.5439C14.75 12.4293 14.75 12.3008 14.75 12.0439Z" stroke="#155EEF" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</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: <AppIcon innerIcon={ApiSvg} className='border !border-purple-200 !bg-purple-50' />,
dataset: <AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />,
webapp: <AppIcon innerIcon={WebappSvg} className='border !border-primary-200 !bg-primary-100' />,
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' }: IAppBasicProps) {
const { t } = useTranslation()
return (
<div className="flex grow items-center">
{icon && icon_background && iconType === 'app' && (
<div className='mr-3 shrink-0'>
<AppIcon icon={icon} background={icon_background} />
</div>
)}
{iconType !== 'app'
&& <div className='mr-3 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>
{isExtraInLine ? (
<div className="system-2xs-medium-uppercase flex text-text-tertiary">{type}</div>
) : (
<div className='system-2xs-medium-uppercase text-text-tertiary'>{isExternal ? t('dataset.externalTag') : type}</div>
)}
</div>}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,45 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
</svg>
type Props = {
isExternal?: boolean
name: string
description: string
expand: boolean
extraInfo?: React.ReactNode
}
const DatasetInfo: FC<Props> = ({
name,
description,
isExternal,
expand,
extraInfo,
}) => {
const { t } = useTranslation()
return (
<div className='pl-1 pt-1'>
<div className='mr-3 shrink-0'>
<AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />
</div>
{expand && (
<div className='mt-2'>
<div className='system-md-semibold text-text-secondary'>
{name}
</div>
<div className='system-2xs-medium-uppercase mt-1 text-text-tertiary'>{isExternal ? t('dataset.externalTag') : t('dataset.localDocs')}</div>
<div className='system-xs-regular my-3 text-text-tertiary first-letter:capitalize'>{description}</div>
</div>
)}
{extraInfo}
</div>
)
}
export default React.memo(DatasetInfo)

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,127 @@
import React, { useEffect } from 'react'
import { useShallow } from 'zustand/react/shallow'
import { RiLayoutRight2Line } from '@remixicon/react'
import { LayoutRight2LineMod } from '../base/icons/src/public/knowledge'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import AppBasic from './basic'
import AppInfo from './app-info'
import DatasetInfo from './dataset-info'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import cn from '@/utils/classnames'
export type IAppDetailNavProps = {
iconType?: 'app' | 'dataset' | 'notion'
title: string
desc: string
isExternal?: boolean
icon: string
icon_background: string
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>
extraInfo?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({ title, desc, isExternal, icon, icon_background, navigation, extraInfo, iconType = 'app' }: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSiderbarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSiderbarExpand: state.setAppSiderbarExpand,
})))
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const expand = appSidebarExpand === 'expand'
const handleToggle = (state: string) => {
setAppSiderbarExpand(state === 'expand' ? 'collapse' : 'expand')
}
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
setAppSiderbarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSiderbarExpand])
return (
<div
className={`
flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all
${expand ? 'w-[216px]' : 'w-14'}
`}
>
<div
className={`
shrink-0
${expand ? 'p-2' : 'p-1'}
`}
>
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType === 'dataset' && (
<DatasetInfo
name={title}
description={desc}
isExternal={isExternal}
expand={expand}
extraInfo={extraInfo && extraInfo(appSidebarExpand)}
/>
)}
{!['app', 'dataset'].includes(iconType) && (
<AppBasic
mode={appSidebarExpand}
iconType={iconType}
icon={icon}
icon_background={icon_background}
name={title}
type={desc}
isExternal={isExternal}
/>
)}
</div>
<div className='px-4'>
<div className={cn('mx-auto mt-1 h-[1px] bg-divider-subtle', !expand && 'w-6')} />
</div>
<nav
className={`
grow space-y-1
${expand ? 'p-4' : 'px-2.5 py-4'}
`}
>
{navigation.map((item, index) => {
return (
<NavLink key={index} mode={appSidebarExpand} iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
)
})}
</nav>
{
!isMobile && (
<div
className={`
shrink-0 py-3
${expand ? 'px-6' : 'px-4'}
`}
>
<div
className='flex h-6 w-6 cursor-pointer items-center justify-center text-gray-500'
onClick={() => handleToggle(appSidebarExpand)}
>
{
expand
? <RiLayoutRight2Line className='h-5 w-5 text-components-menu-item-text' />
: <LayoutRight2LineMod className='h-5 w-5 text-components-menu-item-text' />
}
</div>
</div>
)
}
</div>
)
}
export default React.memo(AppDetailNav)

View File

@@ -0,0 +1,63 @@
'use client'
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
}
export default function NavLink({
name,
href,
iconMap,
mode = 'expand',
}: 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
return (
<Link
key={name}
href={href}
className={classNames(
isActive ? 'bg-state-accent-active text-text-accent font-semibold' : 'text-components-menu-item-text hover:bg-state-base-hover hover:text-components-menu-item-text-hover',
'group flex items-center h-9 rounded-md py-2 text-sm font-normal',
mode === 'expand' ? 'px-3' : 'px-2.5',
)}
title={mode === 'collapse' ? name : ''}
>
<NavIcon
className={classNames(
'h-4 w-4 flex-shrink-0',
mode === 'expand' ? 'mr-2' : 'mr-0',
)}
aria-hidden="true"
/>
{mode === 'expand' && name}
</Link>
)
}

View File

@@ -0,0 +1,11 @@
.sidebar {
border-right: 1px solid #F3F4F6;
}
.completionPic {
background-image: url('./completion.png')
}
.expertPic {
background-image: url('./expert.png')
}

View File

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

View File

@@ -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 (e) {
}
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)

View File

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

View File

@@ -0,0 +1,126 @@
'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()
e.target !== dragRef.current && setDragging(true)
}
const handleDragOver = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
}
const handleDragLeave = (e: DragEvent) => {
e.preventDefault()
e.stopPropagation()
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)

View File

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

View File

@@ -0,0 +1,128 @@
'use client'
import type { FC } from 'react'
import React, { 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) => 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-[1px] 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)
const handleSave = () => {
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)

View File

@@ -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')}&nbsp;{formatTime(createdAt, t('appLog.dateTimeFormat') as string)}</div>}
</div>
)
: undefined
}
</div>
}
/>
</div>
)
}
export default React.memo(EditAnnotationModal)

View 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)

View 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)

View File

@@ -0,0 +1,175 @@
'use client'
import type { FC } from 'react'
import React, { Fragment, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import {
RiAddLine,
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 { LanguagesSupported } from '@/i18n/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' })
a.href = URL.createObjectURL(file)
a.download = `annotations-${locale}.jsonl`
a.click()
}
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 Operations = () => {
return (
<div className="w-full py-1">
<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 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 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>
</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={
<Button variant='secondary' className='w-8 p-0'>
<RiMoreFill className='h-4 w-4' />
</Button>
}
btnClassName='p-0 border-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}
/>
)
}
</div>
)
}
export default React.memo(HeaderOptions)

View File

@@ -0,0 +1,287 @@
'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 } from '@/types/app'
import cn from '@/utils/classnames'
type Props = {
appDetail: App
}
const Annotation: FC<Props> = ({
appDetail,
}) => {
const { t } = useTranslation()
const [isShowEdit, setIsShowEdit] = React.useState(false)
const [annotationConfig, setAnnotationConfig] = useState<AnnotationReplyConfig | null>(null)
const [isChatApp, setIsChatApp] = useState(false)
const fetchAnnotationConfig = async () => {
const res = await doFetchAnnotationConfig(appDetail.id)
setAnnotationConfig(res as AnnotationReplyConfig)
return (res as AnnotationReplyConfig).id
}
useEffect(() => {
const isChatApp = appDetail.mode !== 'completion'
setIsChatApp(isChatApp)
if (isChatApp)
fetchAnnotationConfig()
}, [])
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 ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
let isCompleted = false
while (!isCompleted) {
const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId)
isCompleted = res.job_status === JobStatus.completed
if (isCompleted)
break
await sleep(2000)
}
}
const [queryParams, setQueryParams] = useState<QueryParam>({})
const [currPage, setCurrPage] = React.useState<number>(0)
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
const query = {
page: currPage + 1,
limit,
keyword: debouncedQueryParams.keyword || '',
}
const [controlUpdateList, setControlUpdateList] = useState(Date.now())
const [list, setList] = useState<AnnotationItem[]>([])
const [total, setTotal] = useState(10)
const [isLoading, setIsLoading] = useState(false)
const fetchList = async (page = 1) => {
setIsLoading(true)
try {
const { data, total }: any = await fetchAnnotationList(appDetail.id, {
...query,
page,
})
setList(data as AnnotationItem[])
setTotal(total)
}
catch (e) {
}
setIsLoading(false)
}
useEffect(() => {
fetchList(currPage + 1)
}, [currPage])
useEffect(() => {
fetchList(1)
setControlUpdateList(Date.now())
}, [queryParams])
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 [currItem, setCurrItem] = useState<AnnotationItem | null>(list[0])
const [isShowViewModal, setIsShowViewModal] = useState(false)
useEffect(() => {
if (!isShowEdit)
setControlRefreshSwitch(Date.now())
}, [isShowEdit])
const handleView = (item: AnnotationItem) => {
setCurrItem(item)
setIsShowViewModal(true)
}
const handleSave = async (question: string, answer: string) => {
await editAnnotation(appDetail.id, (currItem as AnnotationItem).id, {
question,
answer,
})
Toast.notify({
message: t('common.api.actionSuccess'),
type: 'success',
})
fetchList()
setControlUpdateList(Date.now())
}
return (
<div className='flex h-full flex-col'>
<p className='system-sm-regular text-text-tertiary'>{t('appLog.description')}</p>
<div className='flex 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' />
: total > 0
? <List
list={list}
onRemove={handleRemove}
onView={handleView}
/>
: <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)

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React 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'
type Props = {
list: AnnotationItem[]
onRemove: (id: string) => void
onView: (item: AnnotationItem) => void
}
const List: FC<Props> = ({
list,
onView,
onRemove,
}) => {
const { t } = useTranslation()
const { formatTime } = useTimestamp()
const [currId, setCurrId] = React.useState<string | null>(null)
const [showConfirmDelete, setShowConfirmDelete] = React.useState(false)
return (
<div className='overflow-x-auto'>
<table className={cn('mt-2 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.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='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>
)
}
export default React.memo(List)

View File

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

View 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',
}

View File

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

View File

@@ -0,0 +1,215 @@
'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) => 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[]>([])
const fetchHitHistory = async (page = 1) => {
try {
const { data, total }: any = await fetchHitHistoryList(appId, id, {
page,
limit: 10,
})
setHitHistoryList(data as HitHistoryItem[])
setTotal(total)
}
catch (e) {
}
}
useEffect(() => {
fetchHitHistory(currPage + 1)
}, [currPage])
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 = (type: EditItemType, editedContent: string) => {
if (type === EditItemType.Query) {
setNewQuery(editedContent)
onSave(editedContent, newAnswer)
}
else {
setNewAnswer(editedContent)
onSave(newQuestion, editedContent)
}
}
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')}&nbsp;{formatTime(createdAt, t('appLog.dateTimeFormat') as string)}</div>
</div>
)
: undefined}
/>
</div>
)
}
export default React.memo(ViewAnnotationModal)

View File

@@ -0,0 +1,86 @@
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 handleConfirm = useCallback(() => {
props.resetAppConfig?.()
const {
features,
setFeatures,
} = featuresStore!.getState()
const newFeatures = produce(features, (draft) => {
draft.moreLikeThis = props.publishedConfig.modelConfig.more_like_this || { enabled: false }
draft.opening = {
enabled: !!props.publishedConfig.modelConfig.opening_statement,
opening_statement: props.publishedConfig.modelConfig.opening_statement || '',
suggested_questions: props.publishedConfig.modelConfig.suggested_questions || [],
}
draft.moderation = props.publishedConfig.modelConfig.sensitive_word_avoidance || { enabled: false }
draft.speech2text = props.publishedConfig.modelConfig.speech_to_text || { enabled: false }
draft.text2speech = props.publishedConfig.modelConfig.text_to_speech || { enabled: false }
draft.suggested = props.publishedConfig.modelConfig.suggested_questions_after_answer || { enabled: false }
draft.citation = props.publishedConfig.modelConfig.retriever_resource || { enabled: false }
draft.annotationReply = props.publishedConfig.modelConfig.annotation_reply || { enabled: false }
draft.file = {
image: {
detail: props.publishedConfig.modelConfig.file_upload?.image?.detail || Resolution.high,
enabled: !!props.publishedConfig.modelConfig.file_upload?.image?.enabled,
number_limits: props.publishedConfig.modelConfig.file_upload?.image?.number_limits || 3,
transfer_methods: props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
},
enabled: !!(props.publishedConfig.modelConfig.file_upload?.enabled || props.publishedConfig.modelConfig.file_upload?.image?.enabled),
allowed_file_types: props.publishedConfig.modelConfig.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
allowed_file_extensions: props.publishedConfig.modelConfig.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
allowed_file_upload_methods: props.publishedConfig.modelConfig.file_upload?.allowed_file_upload_methods || props.publishedConfig.modelConfig.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
number_limits: props.publishedConfig.modelConfig.file_upload?.number_limits || props.publishedConfig.modelConfig.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

View File

@@ -0,0 +1,303 @@
import {
memo,
useCallback,
useState,
} from 'react'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
import {
RiArrowDownSLine,
RiPlanetLine,
RiPlayCircleLine,
RiPlayList2Line,
RiTerminalBoxLine,
} from '@remixicon/react'
import { useKeyPress } from 'ahooks'
import Toast from '../../base/toast'
import type { ModelAndParameter } from '../configuration/debug/types'
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
import SuggestedAction from './suggested-action'
import PublishWithMultipleModel from './publish-with-multiple-model'
import Button from '@/app/components/base/button'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import { fetchInstalledAppList } from '@/service/explore'
import EmbeddedModal from '@/app/components/app/overview/embedded'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useGetLanguage } from '@/context/i18n'
import { CodeBrowser } from '@/app/components/base/icons/src/vender/line/development'
import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/configure-button'
import type { InputVar } from '@/app/components/workflow/types'
import { appDefaultIconBackground } from '@/config'
import type { PublishWorkflowParams } from '@/types/workflow'
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
}
const PUBLISH_SHORTCUT = ['⌘', '⇧', 'P']
const AppPublisher = ({
disabled = false,
publishDisabled = false,
publishedAt,
draftUpdatedAt,
debugWithMultipleModel = false,
multipleModelConfigs = [],
onPublish,
onRestore,
onToggle,
crossAxisOffset = 0,
toolPublished,
inputs,
onRefreshData,
}: AppPublisherProps) => {
const { t } = useTranslation()
const [published, setPublished] = useState(false)
const [open, setOpen] = useState(false)
const appDetail = useAppStore(state => state.appDetail)
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
const appURL = `${appBaseURL}/${appMode}/${accessToken}`
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
const language = useGetLanguage()
const formatTimeFromNow = useCallback((time: number) => {
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
}, [language])
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(`/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 [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
e.preventDefault()
if (publishDisabled || published)
return
handlePublish()
},
{ exactMatch: true, useCapture: true })
return (
<>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 4,
crossAxis: crossAxisOffset,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<Button
variant='primary'
className='p-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'>
{key}
</span>
))}
</div>
</div>
)
}
</Button>
)
}
</div>
<div className='border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
<SuggestedAction
disabled={!publishedAt}
link={appURL}
icon={<RiPlayCircleLine className='h-4 w-4' />}
>
{t('workflow.common.runApp')}
</SuggestedAction>
{appDetail?.mode === 'workflow'
? (
<SuggestedAction
disabled={!publishedAt}
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
icon={<RiPlayList2Line className='h-4 w-4' />}
>
{t('workflow.common.batchRunApp')}
</SuggestedAction>
)
: (
<SuggestedAction
onClick={() => {
setEmbeddingModalOpen(true)
handleTrigger()
}}
disabled={!publishedAt}
icon={<CodeBrowser className='h-4 w-4' />}
>
{t('workflow.common.embedIntoSite')}
</SuggestedAction>
)}
<SuggestedAction
onClick={() => {
publishedAt && handleOpenInExplore()
}}
disabled={!publishedAt}
icon={<RiPlanetLine className='h-4 w-4' />}
>
{t('workflow.common.openInExplore')}
</SuggestedAction>
<SuggestedAction
disabled={!publishedAt}
link='./develop'
icon={<RiTerminalBoxLine className='h-4 w-4' />}
>
{t('workflow.common.accessAPIReference')}
</SuggestedAction>
{appDetail?.mode === 'workflow' && (
<WorkflowToolConfigureButton
disabled={!publishedAt}
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}
/>
)}
</div>
</div>
</PortalToFollowElemContent>
<EmbeddedModal
siteInfo={appDetail?.site}
isShow={embeddingModalOpen}
onClose={() => setEmbeddingModalOpen(false)}
appBaseUrl={appBaseURL}
accessToken={accessToken}
/>
</PortalToFollowElem >
</>
)
}
export default memo(AppPublisher)

View File

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

View File

@@ -0,0 +1,29 @@
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, ...props }: SuggestedActionProps) => (
<a
href={disabled ? undefined : link}
target='_blank'
rel='noreferrer'
className={classNames(
'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg transition-colors [&:not(:first-child)]:mt-1',
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
className,
)}
{...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

View File

@@ -0,0 +1,112 @@
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 {
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 {
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

View File

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

View File

@@ -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-[1px] grow'
style={{
background: 'linear-gradient(270deg, rgba(243, 244, 246, 0) 0%, #F3F4F6 100%)',
}}
></div>
</div>
)
}
export default React.memo(GroupName)

View File

@@ -0,0 +1,14 @@
'use client'
import type { FC } from 'react'
import React from 'react'
const MoreLikeThisIcon: FC = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M5.83914 0.666748H10.1609C10.6975 0.666741 11.1404 0.666734 11.5012 0.696212C11.8759 0.726829 12.2204 0.792538 12.544 0.957399C13.0457 1.21306 13.4537 1.62101 13.7093 2.12277C13.8742 2.44633 13.9399 2.7908 13.9705 3.16553C14 3.52633 14 3.96923 14 4.50587V7.41171C14 7.62908 14 7.73776 13.9652 7.80784C13.9303 7.87806 13.8939 7.91566 13.8249 7.95288C13.756 7.99003 13.6262 7.99438 13.3665 8.00307C12.8879 8.01909 12.4204 8.14633 11.997 8.36429C10.9478 7.82388 9.62021 7.82912 8.53296 8.73228C7.15064 9.88056 6.92784 11.8645 8.0466 13.2641C8.36602 13.6637 8.91519 14.1949 9.40533 14.6492C9.49781 14.7349 9.54405 14.7777 9.5632 14.8041C9.70784 15.003 9.5994 15.2795 9.35808 15.3271C9.32614 15.3334 9.26453 15.3334 9.14129 15.3334H5.83912C5.30248 15.3334 4.85958 15.3334 4.49878 15.304C4.12405 15.2733 3.77958 15.2076 3.45603 15.0428C2.95426 14.7871 2.54631 14.3792 2.29065 13.8774C2.12579 13.5538 2.06008 13.2094 2.02946 12.8346C1.99999 12.4738 1.99999 12.0309 2 11.4943V4.50587C1.99999 3.96924 1.99999 3.52632 2.02946 3.16553C2.06008 2.7908 2.12579 2.44633 2.29065 2.12277C2.54631 1.62101 2.95426 1.21306 3.45603 0.957399C3.77958 0.792538 4.12405 0.726829 4.49878 0.696212C4.85957 0.666734 5.3025 0.666741 5.83914 0.666748ZM4.66667 5.33342C4.29848 5.33342 4 5.63189 4 6.00008C4 6.36827 4.29848 6.66675 4.66667 6.66675H8.66667C9.03486 6.66675 9.33333 6.36827 9.33333 6.00008C9.33333 5.63189 9.03486 5.33342 8.66667 5.33342H4.66667ZM4 8.66675C4 8.29856 4.29848 8.00008 4.66667 8.00008H6C6.36819 8.00008 6.66667 8.29856 6.66667 8.66675C6.66667 9.03494 6.36819 9.33342 6 9.33342H4.66667C4.29848 9.33342 4 9.03494 4 8.66675ZM4.66667 2.66675C4.29848 2.66675 4 2.96523 4 3.33342C4 3.7016 4.29848 4.00008 4.66667 4.00008H10.6667C11.0349 4.00008 11.3333 3.7016 11.3333 3.33342C11.3333 2.96523 11.0349 2.66675 10.6667 2.66675H4.66667Z" fill="#DD2590" />
<path d="M11.9977 10.0256C11.3313 9.26808 10.2199 9.06432 9.3849 9.75796C8.54988 10.4516 8.43232 11.6113 9.08807 12.4317C9.58479 13.0531 10.9986 14.3025 11.655 14.8719C11.7744 14.9754 11.8341 15.0272 11.9037 15.0477C11.9642 15.0654 12.0312 15.0654 12.0917 15.0477C12.1613 15.0272 12.221 14.9754 12.3404 14.8719C12.9968 14.3025 14.4106 13.0531 14.9074 12.4317C15.5631 11.6113 15.4599 10.4443 14.6105 9.75796C13.7612 9.07161 12.6642 9.26808 11.9977 10.0256Z" fill="#DD2590" />
</svg>
)
}
export default React.memo(MoreLikeThisIcon)

View File

@@ -0,0 +1,31 @@
'use client'
import React, { useState } from 'react'
import cn from '@/utils/classnames'
type IRemoveIconProps = {
className?: string
isHoverStatus?: boolean
onClick: () => void
}
const RemoveIcon = ({
className,
isHoverStatus,
onClick,
}: IRemoveIconProps) => {
const [isHovered, setIsHovered] = useState(false)
const computedIsHovered = isHoverStatus || isHovered
return (
<div
className={cn(className, computedIsHovered && 'bg-[#FEE4E2]', 'flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-[#FEE4E2]')}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onClick}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M10 6H14M6 8H18M16.6667 8L16.1991 15.0129C16.129 16.065 16.0939 16.5911 15.8667 16.99C15.6666 17.3412 15.3648 17.6235 15.0011 17.7998C14.588 18 14.0607 18 13.0062 18H10.9938C9.93927 18 9.41202 18 8.99889 17.7998C8.63517 17.6235 8.33339 17.3412 8.13332 16.99C7.90607 16.5911 7.871 16.065 7.80086 15.0129L7.33333 8M10.6667 11V14.3333M13.3333 11V14.3333" stroke={computedIsHovered ? '#D92D20' : '#667085'} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
)
}
export default React.memo(RemoveIcon)

View File

@@ -0,0 +1,12 @@
'use client'
import type { FC } from 'react'
import React from 'react'
const SuggestedQuestionsAfterAnswerIcon: FC = () => {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M10.8275 1.33325H5.17245C4.63581 1.33324 4.19289 1.33324 3.8321 1.36272C3.45737 1.39333 3.1129 1.45904 2.78934 1.6239C2.28758 1.87956 1.87963 2.28751 1.62397 2.78928C1.45911 3.11284 1.3934 3.4573 1.36278 3.83204C1.3333 4.19283 1.33331 4.63574 1.33332 5.17239L1.33328 9.42497C1.333 9.95523 1.33278 10.349 1.42418 10.6901C1.67076 11.6103 2.38955 12.3291 3.3098 12.5757C3.51478 12.6306 3.73878 12.6525 3.99998 12.6611L3.99998 13.5806C3.99995 13.7374 3.99992 13.8973 4.01182 14.0283C4.0232 14.1536 4.05333 14.3901 4.21844 14.5969C4.40843 14.8349 4.69652 14.9734 5.00106 14.973C5.26572 14.9728 5.46921 14.8486 5.57416 14.7792C5.6839 14.7066 5.80872 14.6067 5.93117 14.5087L7.53992 13.2217C7.88564 12.9451 7.98829 12.8671 8.09494 12.8126C8.20192 12.7579 8.3158 12.718 8.43349 12.6938C8.55081 12.6697 8.67974 12.6666 9.12248 12.6666H10.8275C11.3642 12.6666 11.8071 12.6666 12.1679 12.6371C12.5426 12.6065 12.8871 12.5408 13.2106 12.3759C13.7124 12.1203 14.1203 11.7123 14.376 11.2106C14.5409 10.887 14.6066 10.5425 14.6372 10.1678C14.6667 9.80701 14.6667 9.36411 14.6667 8.82747V5.17237C14.6667 4.63573 14.6667 4.19283 14.6372 3.83204C14.6066 3.4573 14.5409 3.11284 14.376 2.78928C14.1203 2.28751 13.7124 1.87956 13.2106 1.6239C12.8871 1.45904 12.5426 1.39333 12.1679 1.36272C11.8071 1.33324 11.3642 1.33324 10.8275 1.33325ZM8.99504 4.99992C8.99504 4.44763 9.44275 3.99992 9.99504 3.99992C10.5473 3.99992 10.995 4.44763 10.995 4.99992C10.995 5.5522 10.5473 5.99992 9.99504 5.99992C9.44275 5.99992 8.99504 5.5522 8.99504 4.99992ZM4.92837 7.79996C5.222 7.57974 5.63816 7.63837 5.85961 7.93051C5.90071 7.98295 5.94593 8.03229 5.99199 8.08035C6.09019 8.18282 6.23775 8.32184 6.42882 8.4608C6.81353 8.74059 7.3454 8.99996 7.99504 8.99996C8.64469 8.99996 9.17655 8.74059 9.56126 8.4608C9.75233 8.32184 9.89989 8.18282 9.99809 8.08035C10.0441 8.0323 10.0894 7.98294 10.1305 7.93051C10.3519 7.63837 10.7681 7.57974 11.0617 7.79996C11.3563 8.02087 11.416 8.43874 11.195 8.73329C11.1967 8.73112 11.1928 8.7361 11.186 8.74466C11.1697 8.7651 11.1372 8.80597 11.1261 8.81916C11.087 8.86575 11.0317 8.92884 10.9607 9.00289C10.8194 9.15043 10.6128 9.34474 10.3455 9.53912C9.81353 9.92599 9.01206 10.3333 7.99504 10.3333C6.97802 10.3333 6.17655 9.92599 5.64459 9.53912C5.37733 9.34474 5.17072 9.15043 5.02934 9.00289C4.95837 8.92884 4.90305 8.86575 4.86395 8.81916C4.84438 8.79585 4.82881 8.77659 4.81731 8.76207C4.58702 8.46455 4.61798 8.03275 4.92837 7.79996ZM5.99504 3.99992C5.44275 3.99992 4.99504 4.44763 4.99504 4.99992C4.99504 5.5522 5.44275 5.99992 5.99504 5.99992C6.54732 5.99992 6.99504 5.5522 6.99504 4.99992C6.99504 4.44763 6.54732 3.99992 5.99504 3.99992Z" fill="#06AED4" />
</svg>
)
}
export default React.memo(SuggestedQuestionsAfterAnswerIcon)

View File

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

View File

@@ -0,0 +1,37 @@
'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 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>
)
}
export const varHighlightHTML = ({ name, className = '' }: IVarHighlightProps) => {
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>${name}</span>
<span class='opacity-60'>}}</span>
</div>`
return html
}
export default React.memo(VarHighlight)

View File

@@ -0,0 +1,3 @@
.item {
background-color: rgba(21, 94, 239, 0.05);
}

View File

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

View File

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

View File

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

View File

@@ -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 pt-16`}
>
<div className='mx-auto px-10'>
<div className={`${s.icon} flex h-11 w-11 items-center justify-center rounded-xl bg-white`}>{warningIcon}</div>
<div className='mt-4 text-[24px] font-semibold leading-normal text-gray-800'>
{title}
</div>
<div className='mt-3 text-base text-gray-500'>
{description}
</div>
<div className='mt-6'>
{footer}
</div>
</div>
</div>
)
}
export default React.memo(WarningMask)

View File

@@ -0,0 +1,8 @@
.mask {
background-color: rgba(239, 244, 255, 0.9);
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);
}

View File

@@ -0,0 +1,275 @@
'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 {
Clipboard,
ClipboardCheck,
} 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 { AppType } 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) => {
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 !== AppType.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
? (
<Clipboard className='h-6 w-6 cursor-pointer p-1 text-text-tertiary' onClick={() => {
copy(value)
setIsCopied(true)
}} />
)
: (
<ClipboardCheck 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').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)

View File

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

View File

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

View File

@@ -0,0 +1,60 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useContext } from 'use-context-selector'
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 I18n from '@/context/i18n'
import { LanguagesSupported } from '@/i18n/language'
type Props = {
showWarning: boolean
onShowEditModal: () => void
}
const HistoryPanel: FC<Props> = ({
showWarning,
onShowEditModal,
}) => {
const { t } = useTranslation()
const { locale } = useContext(I18n)
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={`${locale === LanguagesSupported[1]
? 'https://docs.dify.ai/v/zh-hans/guides/application-design/prompt-engineering'
: 'https://docs.dify.ai/features/prompt-engineering'}`}
target='_blank' rel='noopener noreferrer'
className='text-[#155EEF]'>{t('appDebug.feature.conversationHistory.learnMore')}
</a>
</div>
</div>
)}
</Panel>
)
}
export default React.memo(HistoryPanel)

View File

@@ -0,0 +1,170 @@
'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 AppType, ModelModeType } from '@/types/app'
import ConfigContext from '@/context/debug-configuration'
import { MAX_PROMPT_MESSAGE_LENGTH } from '@/config'
export type IPromptProps = {
mode: AppType
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)

View File

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

View File

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

View File

@@ -0,0 +1,283 @@
'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 type { CompletionParams } from '@/types/app'
import { AppType } from '@/types/app'
import { getNewVar, getVars } from '@/utils/var'
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
import type { AutomaticRes } 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: AppType
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 {
modelConfig,
completionParams,
dataSets,
setModelConfig,
setPrevPromptConfig,
setIntroduction,
hasSetBlockStatus,
showSelectDataSet,
externalDataToolsConfig,
} = useContext(ConfigContext)
const { notify } = useToastContext()
const { setShowExternalDataToolModal } = useModalContext()
const handleOpenExternalDataToolModal = () => {
setShowExternalDataToolModal({
payload: {},
onSaveCallback: (newExternalDataTool: ExternalDataTool) => {
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 promptVariablesObj = (() => {
const obj: Record<string, boolean> = {}
promptVariables.forEach((item) => {
obj[item.key] = true
})
return obj
})()
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[]) => {
const newPromptVariables = keys.filter(key => !(key in promptVariablesObj) && !externalDataToolsConfig.find(item => item.variable === key)).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: AutomaticRes) => {
// 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.prompt,
} as any)
const newModelConfig = produce(modelConfig, (draft) => {
draft.configs.prompt_template = res.prompt
draft.configs.prompt_variables = res.variables.map(key => ({ key, name: key, type: 'string', required: true }))
})
setModelConfig(newModelConfig)
setPrevPromptConfig(modelConfig.configs)
if (mode !== AppType.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 !== AppType.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 => item.type !== 'api').map(item => ({
name: item.name,
value: item.key,
})),
}}
externalToolBlock={{
show: true,
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: false,
selectable: false,
history: {
user: '',
assistant: '',
},
onEditRole: noop,
}}
queryBlock={{
show: false,
selectable: !hasSetBlockStatus.query,
}}
onChange={(value) => {
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
mode={mode as AppType}
model={
{
provider: modelConfig.provider,
name: modelConfig.model_id,
mode: modelConfig.mode,
completion_params: completionParams as CompletionParams,
}
}
isShow={showAutomatic}
onClose={showAutomaticFalse}
onFinished={handleAutomaticRes}
/>
)}
</div>
)
}
export default React.memo(Prompt)

View File

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

View File

@@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
type Props = {
className?: string
title: string
children: React.JSX.Element
}
const Field: FC<Props> = ({
className,
title,
children,
}) => {
return (
<div className={cn(className)}>
<div className='system-sm-semibold leading-8 text-text-secondary'>{title}</div>
<div>{children}</div>
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,249 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, 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 SelectTypeItem from '../select-type-item'
import Field from './field'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { checkKeys, getNewVarInWorkflow } 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'
const TEXT_MAX_LENGTH = 256
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)
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,
}
return newPayload
})
}
}, [])
const handleTypeChange = useCallback((type: InputVarType) => {
return () => {
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 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
// TODO: check if key already exists. should the consider the edit case
// if (varKeys.map(key => key?.trim()).includes(tempPayload.variable.trim())) {
// Toast.notify({
// type: 'error',
// message: t('appDebug.varKeyError.keyAlreadyExists', { key: tempPayload.variable }),
// })
// 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')}>
<div className='grid grid-cols-3 gap-2'>
<SelectTypeItem type={InputVarType.textInput} selected={type === InputVarType.textInput} onClick={handleTypeChange(InputVarType.textInput)} />
<SelectTypeItem type={InputVarType.paragraph} selected={type === InputVarType.paragraph} onClick={handleTypeChange(InputVarType.paragraph)} />
<SelectTypeItem type={InputVarType.select} selected={type === InputVarType.select} onClick={handleTypeChange(InputVarType.select)} />
<SelectTypeItem type={InputVarType.number} selected={type === InputVarType.number} onClick={handleTypeChange(InputVarType.number)} />
{supportFile && <>
<SelectTypeItem type={InputVarType.singleFile} selected={type === InputVarType.singleFile} onClick={handleTypeChange(InputVarType.singleFile)} />
<SelectTypeItem type={InputVarType.multiFiles} selected={type === InputVarType.multiFiles} onClick={handleTypeChange(InputVarType.multiFiles)} />
</>}
</div>
</Field>
<Field title={t('appDebug.variableConfig.varName')}>
<Input
value={variable}
onChange={e => handlePayloadChange('variable')(e.target.value)}
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>
)}
{type === InputVarType.select && (
<Field title={t('appDebug.variableConfig.options')}>
<ConfigSelect options={options || []} onChange={handlePayloadChange('options')} />
</Field>
)}
{[InputVarType.singleFile, InputVarType.multiFiles].includes(type) && (
<FileUploadSetting
payload={tempPayload as UploadFileSetting}
onChange={(p: UploadFileSetting) => setTempPayload(p as InputVar)}
isMultiple={type === InputVarType.multiFiles}
/>
)}
<div className='!mt-5 flex h-6 items-center space-x-2'>
<Checkbox checked={tempPayload.required} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
<span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.required')}</span>
</div>
</div>
</div>
<ModalFoot
onConfirm={handleConfirm}
onCancel={onClose}
/>
</Modal>
)
}
export default React.memo(ConfigModal)

View File

@@ -0,0 +1,86 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { PlusIcon } from '@heroicons/react/24/outline'
import { ReactSortable } from 'react-sortablejs'
import RemoveIcon from '../../base/icons/remove-icon'
import s from './style.module.css'
export type Options = string[]
export type IConfigSelectProps = {
options: Options
onChange: (options: Options) => void
}
const ConfigSelect: FC<IConfigSelectProps> = ({
options,
onChange,
}) => {
const { t } = useTranslation()
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={`${s.inputWrap} relative`} key={index}>
<div className='handle flex h-4 w-4 cursor-grab items-center justify-center'>
<svg width="6" height="10" viewBox="0 0 6 10" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M1 2C1.55228 2 2 1.55228 2 1C2 0.447715 1.55228 0 1 0C0.447715 0 0 0.447715 0 1C0 1.55228 0.447715 2 1 2ZM1 6C1.55228 6 2 5.55228 2 5C2 4.44772 1.55228 4 1 4C0.447715 4 0 4.44772 0 5C0 5.55228 0.447715 6 1 6ZM6 1C6 1.55228 5.55228 2 5 2C4.44772 2 4 1.55228 4 1C4 0.447715 4.44772 0 5 0C5.55228 0 6 0.447715 6 1ZM5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4C4.44772 4 4 4.44772 4 5C4 5.55228 4.44772 6 5 6ZM2 9C2 9.55229 1.55228 10 1 10C0.447715 10 0 9.55229 0 9C0 8.44771 0.447715 8 1 8C1.55228 8 2 8.44771 2 9ZM5 10C5.55228 10 6 9.55229 6 9C6 8.44771 5.55228 8 5 8C4.44772 8 4 8.44771 4 9C4 9.55229 4.44772 10 5 10Z" fill="#98A2B3" />
</svg>
</div>
<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 border-0 bg-transparent pl-1.5 pr-8 text-sm leading-9 text-gray-900 focus:outline-none'}
/>
<RemoveIcon
className={`${s.deleteBtn} absolute right-1.5 top-1/2 h-6 w-6 translate-y-[-50%] cursor-pointer items-center justify-center rounded-md hover:bg-[#FEE4E2]`}
onClick={() => {
onChange(options.filter((_, i) => index !== i))
}}
/>
</div>
))}
</ReactSortable>
</div>
)}
<div
onClick={() => { onChange([...options, '']) }}
className='flex h-9 cursor-pointer items-center gap-2 rounded-lg bg-gray-100 px-3 text-gray-400'>
<PlusIcon width={16} height={16}></PlusIcon>
<div className='text-[13px] text-gray-500'>{t('appDebug.variableConfig.addOption')}</div>
</div>
</div>
)
}
export default React.memo(ConfigSelect)

View File

@@ -0,0 +1,21 @@
.inputWrap {
display: flex;
align-items: center;
border-radius: 8px;
border: 1px solid #EAECF0;
padding-left: 10px;
cursor: pointer;
}
.deleteBtn {
display: none;
display: flex;
}
.inputWrap:hover {
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
}
.inputWrap:hover .deleteBtn {
display: flex;
}

View File

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

View File

@@ -0,0 +1,271 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import { useContext } from 'use-context-selector'
import produce from 'immer'
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 } from '@/utils/var'
import Toast from '@/app/components/base/toast'
import Confirm from '@/app/components/base/confirm'
import ConfigContext from '@/context/debug-configuration'
import { AppType } 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'
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
})
onPromptVariablesChange?.(newPromptVariables)
}
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) => {
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 === AppType.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') {
handleOpenExternalDataToolModal({ key, type, index, name, config, icon, icon_background }, promptVariables)
return
}
showEditModal()
}
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'>
{promptVariables.map(({ key, name, type, required, config, icon, icon_background }, index) => (
<VarItem
key={index}
readonly={readonly}
name={key}
label={name}
required={!!required}
type={type}
onEdit={() => handleConfig({ type, key, index, name, config, icon, icon_background })}
onRemove={() => handleRemoveVar(index)}
/>
))}
</div>
)}
{isShowEditModal && (
<EditModal
payload={currItemToEdit!}
isShow={isShowEditModal}
onClose={hideEditModal}
onConfirm={(item) => {
updatePromptVariableItem(item)
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)

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from '@/utils/classnames'
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-gray-50'
onClick={() => onClick(value)}
>
{Icon ? <Icon className='h-4 w-4 text-gray-500' /> : <InputVarTypeIcon type={type!} className='h-4 w-4 text-gray-500' />}
<div className='ml-2 truncate text-xs text-gray-600'>{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' className={cn(open && 'bg-gray-200')} />
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
<div className='min-w-[192px] rounded-lg border border-gray-200 bg-white shadow-lg'>
<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>
</div>
<div className='h-[1px] bg-gray-100'></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)

View File

@@ -0,0 +1,72 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import {
RiDeleteBinLine,
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 = {
readonly?: boolean
name: string
label: string
required: boolean
type: string
onEdit: () => void
onRemove: () => void
}
const VarItem: FC<ItemProps> = ({
readonly,
name,
label,
required,
type,
onEdit,
onRemove,
}) => {
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')}>
<VarIcon className='mr-1 h-4 w-4 shrink-0 text-text-accent' />
<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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,302 @@
'use client'
import type { FC } from 'react'
import React, { 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 Toast from '@/app/components/base/toast'
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 ConfigCredential from '@/app/components/tools/setting/build-in/config-credentials'
import { updateBuiltInToolCredential } from '@/service/tools'
import cn from '@/utils/classnames'
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
import type { ToolDefaultValue } from '@/app/components/workflow/block-selector/types'
import { canFindTool } from '@/utils'
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
const AgentTools: FC = () => {
const { t } = useTranslation()
const [isShowChooseTool, setIsShowChooseTool] = useState(false)
const { modelConfig, setModelConfig, collectionList } = useContext(ConfigContext)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
const currentCollection = useMemo(() => {
if (!currentTool) return null
const collection = collectionList.find(collection => canFindTool(collection.id, currentTool?.provider_id) && collection.type === currentTool?.provider_type)
return collection
}, [currentTool, collectionList])
const [isShowSettingTool, setIsShowSettingTool] = useState(false)
const [isShowSettingAuth, setShowSettingAuth] = 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 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 handleToolAuthSetting = (value: any) => {
const newModelConfig = produce(modelConfig, (draft) => {
const tool = (draft.agentConfig.tools).find((item: any) => item.provider_id === value?.collection?.id && item.tool_name === value?.tool_name)
if (tool)
(tool as AgentTool).notAuthor = false
})
setModelConfig(newModelConfig)
setIsShowSettingTool(false)
formattingChangedDispatcher()
}
const [isDeleting, setIsDeleting] = useState<number>(-1)
const handleSelectTool = (tool: ToolDefaultValue) => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.agentConfig.tools.push({
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,
})
})
setModelConfig(newModelConfig)
}
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: any) => !!item.enabled).length}/{tools.length}&nbsp;{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}
selectedTools={tools}
/>
</>
)}
</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) && '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'>{item.provider_type === CollectionType.builtIn ? item.provider_name.split('/').pop() : item.tool_label}</span>
<span className='text-text-tertiary'>{item.tool_label}</span>
{!item.isDeleted && (
<Tooltip
needsDelay
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')}
needsDelay
>
<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')}
needsDelay
>
<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)
setShowSettingAuth(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 as any}
collection={currentTool?.collection as Collection}
isBuiltIn={currentTool?.collection?.type === CollectionType.builtIn}
isModel={currentTool?.collection?.type === CollectionType.model}
onSave={handleToolSettingChange}
onHide={() => setIsShowSettingTool(false)}
/>
)}
{isShowSettingAuth && (
<ConfigCredential
collection={currentCollection as any}
onCancel={() => setShowSettingAuth(false)}
onSaved={async (value) => {
await updateBuiltInToolCredential((currentCollection as any).name, value)
Toast.notify({
type: 'success',
message: t('common.api.actionSuccess'),
})
handleToolAuthSetting(currentTool as any)
setShowSettingAuth(false)
}}
/>
)}
</>
)
}
export default React.memo(AgentTools)

View File

@@ -0,0 +1,238 @@
'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/language'
import cn from '@/utils/classnames'
type Props = {
showBackButton?: boolean
collection: Collection
isBuiltIn?: boolean
isModel?: boolean
toolName: string
setting?: Record<string, any>
readonly?: boolean
onHide: () => void
onSave?: (value: Record<string, any>) => void
}
const SettingBuiltInTool: FC<Props> = ({
showBackButton = false,
collection,
isBuiltIn = true,
isModel = true,
toolName,
setting = {},
readonly,
onHide,
onSave,
}) => {
const { locale } = useContext(I18n)
const language = getLanguage(locale)
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const [tools, setTools] = useState<Tool[]>([])
const currTool = tools.find(tool => tool.name === toolName)
const formSchemas = currTool ? toolParametersToFormSchemas(currTool.parameters) : []
const infoSchemas = formSchemas.filter((item: any) => item.form === 'llm')
const settingSchemas = formSchemas.filter((item: any) => 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)
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 (e) { }
setIsLoading(false)
})()
}, [collection?.name, collection?.id, collection?.type])
useEffect(() => {
setCurrType((!readonly && hasSetting) ? 'setting' : 'info')
}, [hasSetting])
const isValid = (() => {
let valid = true
settingSchemas.forEach((item: any) => {
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 === '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: any, 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 as any}
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' />
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='mt-3' text={currTool.description[language]} descriptionLineRows={2}></Description>
)}
</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}
</div>
{!readonly && !isInfoActive && (
<div className='mt-2 flex shrink-0 justify-end space-x-2 rounded-b-[10px] border-t border-divider-regular bg-components-panel-bg px-6 py-4'>
<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>
</div>
</>
)}
</>
</Drawer>
)
}
export default React.memo(SettingBuiltInTool)

View File

@@ -0,0 +1,149 @@
'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 {
Clipboard,
ClipboardCheck,
} 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) => {
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
? (
<Clipboard className='h-6 w-6 cursor-pointer p-1 text-gray-500' onClick={() => {
copy(value)
setIsCopied(true)
}} />
)
: (
<ClipboardCheck 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.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)

View File

@@ -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-[1px] 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)

View File

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

View File

@@ -0,0 +1,330 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import {
RiDatabase2Line,
RiFileExcel2Line,
RiGitCommitLine,
RiNewspaperLine,
RiPresentationLine,
RiRoadMapLine,
RiTerminalBoxLine,
RiTranslate,
RiUser2Line,
} from '@remixicon/react'
import cn from 'classnames'
import s from './style.module.css'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { generateRule } from '@/service/debug'
import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
import type { Model } from '@/types/app'
import { AppType } from '@/types/app'
import ConfigVar from '@/app/components/app/configuration/config-var'
import GroupName from '@/app/components/app/configuration/base/group-name'
import Loading from '@/app/components/base/loading'
import Confirm from '@/app/components/base/confirm'
import { LoveMessage } from '@/app/components/base/icons/src/vender/features'
// type
import type { AutomaticRes } from '@/service/debug'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
export type IGetAutomaticResProps = {
mode: AppType
model: Model
isShow: boolean
onClose: () => void
onFinished: (res: AutomaticRes) => void
isInLLMNode?: 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,
model,
isShow,
onClose,
isInLLMNode,
onFinished,
}) => {
const { t } = useTranslation()
const {
currentProvider,
currentModel,
} = 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',
},
]
const [instruction, setInstruction] = React.useState<string>('')
const handleChooseTemplate = useCallback((key: string) => {
return () => {
const template = t(`appDebug.generate.template.${key}.instruction`)
setInstruction(template)
}
}, [t])
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 [res, setRes] = React.useState<AutomaticRes | null>(null)
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 renderNoData = (
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
<Generator className='h-14 w-14 text-text-tertiary' />
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
<div>{t('appDebug.generate.noDataLine1')}</div>
<div>{t('appDebug.generate.noDataLine2')}</div>
</div>
</div>
)
const onGenerate = async () => {
if (!isValid())
return
if (isLoading)
return
setLoadingTrue()
try {
const { error, ...res } = await generateRule({
instruction,
model_config: model,
no_variable: !!isInLLMNode,
})
setRes(res)
if (error) {
Toast.notify({
type: 'error',
message: error,
})
}
}
finally {
setLoadingFalse()
}
}
const [showConfirmOverwrite, setShowConfirmOverwrite] = React.useState(false)
const isShowAutoPromptResPlaceholder = () => {
return !isLoading && !res
}
return (
<Modal
isShow={isShow}
onClose={onClose}
className='min-w-[1140px] !p-0'
closable
>
<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-8'>
<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 className='mb-8 flex items-center'>
<ModelIcon
className='mr-1.5 shrink-0 '
provider={currentProvider}
modelName={currentModel?.model}
/>
<ModelName
className='grow'
modelItem={currentModel!}
showMode
showFeatures
/>
</div>
<div >
<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-6'>
<div className='text-[0px]'>
<div className='mb-2 text-sm font-medium leading-5 text-text-primary'>{t('appDebug.generate.instruction')}</div>
<Textarea
className="h-[200px] resize-none"
placeholder={t('appDebug.generate.instructionPlaceHolder') as string}
value={instruction}
onChange={e => setInstruction(e.target.value)} />
</div>
<div className='mt-5 flex justify-end'>
<Button
className='flex space-x-1'
variant='primary'
onClick={onGenerate}
disabled={isLoading}
>
<Generator className='h-4 w-4 text-white' />
<span className='text-xs font-semibold text-white'>{t('appDebug.generate.generate')}</span>
</Button>
</div>
</div>
</div>
{(!isLoading && res) && (
<div className='h-full w-0 grow p-6 pb-0'>
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.generate.resTitle')}</div>
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
<ConfigPrompt
mode={mode}
promptTemplate={res?.prompt || ''}
promptVariables={[]}
readonly
noTitle={isInLLMNode}
gradientBorder
editorHeight={isInLLMNode ? 524 : 0}
noResize={isInLLMNode}
/>
{!isInLLMNode && (
<>
{(res?.variables?.length && res?.variables?.length > 0)
? (
<ConfigVar
promptVariables={res?.variables.map(key => ({ key, name: key, type: 'string', required: true })) || []}
readonly
/>
)
: ''}
{(mode !== AppType.completion && res?.opening_statement) && (
<div className='mt-7'>
<GroupName name={t('appDebug.feature.groupChat.title')} />
<div
className='mb-1 rounded-xl border-l-[0.5px] border-t-[0.5px] border-effects-highlight bg-background-section-burn p-3'
>
<div className='mb-2 flex items-center gap-2'>
<div className='shrink-0 rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-light-blue-light-500 p-1 shadow-xs'>
<LoveMessage className='h-4 w-4 text-text-primary-on-surface' />
</div>
<div className='system-sm-semibold flex grow items-center text-text-secondary'>
{t('appDebug.feature.conversationOpener.title')}
</div>
</div>
<div className='system-xs-regular min-h-8 text-text-tertiary'>{res.opening_statement}</div>
</div>
</div>
)}
</>
)}
</div>
<div className='flex justify-end bg-background-default py-4'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='ml-2' onClick={() => {
setShowConfirmOverwrite(true)
}}>{t('appDebug.generate.apply')}</Button>
</div>
</div>
)}
{isLoading && renderLoading}
{isShowAutoPromptResPlaceholder() && renderNoData}
{showConfirmOverwrite && (
<Confirm
title={t('appDebug.generate.overwriteTitle')}
content={t('appDebug.generate.overwriteMessage')}
isShow={showConfirmOverwrite}
onConfirm={() => {
setShowConfirmOverwrite(false)
onFinished(res!)
}}
onCancel={() => setShowConfirmOverwrite(false)}
/>
)}
</div>
</Modal>
)
}
export default React.memo(GetAutomaticRes)

View File

@@ -0,0 +1,7 @@
.textGradient {
background: linear-gradient(92deg, #2250F2 -29.55%, #0EBCF3 75.22%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-fill-color: transparent;
}

View File

@@ -0,0 +1,230 @@
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import useBoolean from 'ahooks/lib/useBoolean'
import { useTranslation } from 'react-i18next'
import ConfigPrompt from '../../config-prompt'
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
import { generateRuleCode } from '@/service/debug'
import type { CodeGenRes } from '@/service/debug'
import { type AppType, type Model, ModelModeType } from '@/types/app'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { Generator } from '@/app/components/base/icons/src/vender/other'
import Toast from '@/app/components/base/toast'
import Loading from '@/app/components/base/loading'
import Confirm from '@/app/components/base/confirm'
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
export type IGetCodeGeneratorResProps = {
mode: AppType
isShow: boolean
codeLanguages: CodeLanguage
onClose: () => void
onFinished: (res: CodeGenRes) => void
}
export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
{
mode,
isShow,
codeLanguages,
onClose,
onFinished,
},
) => {
const {
currentProvider,
currentModel,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
const { t } = useTranslation()
const [instruction, setInstruction] = React.useState<string>('')
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
const [res, setRes] = React.useState<CodeGenRes | null>(null)
const isValid = () => {
if (instruction.trim() === '') {
Toast.notify({
type: 'error',
message: t('common.errorMsg.fieldRequired', {
field: t('appDebug.code.instruction'),
}),
})
return false
}
return true
}
const model: Model = {
provider: currentProvider?.provider || '',
name: currentModel?.model || '',
mode: ModelModeType.chat,
// This is a fixed parameter
completion_params: {
temperature: 0.7,
max_tokens: 0,
top_p: 0,
echo: false,
stop: [],
presence_penalty: 0,
frequency_penalty: 0,
},
}
const isInLLMNode = true
const onGenerate = async () => {
if (!isValid())
return
if (isLoading)
return
setLoadingTrue()
try {
const { error, ...res } = await generateRuleCode({
instruction,
model_config: model,
no_variable: !!isInLLMNode,
code_language: languageMap[codeLanguages] || 'javascript',
})
setRes(res)
if (error) {
Toast.notify({
type: 'error',
message: error,
})
}
}
finally {
setLoadingFalse()
}
}
const [showConfirmOverwrite, setShowConfirmOverwrite] = React.useState(false)
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-gray-400'>{t('appDebug.codegen.loading')}</div>
</div>
)
const renderNoData = (
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
<Generator className='h-14 w-14 text-gray-300' />
<div className='text-center text-[13px] font-normal leading-5 text-gray-400'>
<div>{t('appDebug.codegen.noDataLine1')}</div>
<div>{t('appDebug.codegen.noDataLine2')}</div>
</div>
</div>
)
return (
<Modal
isShow={isShow}
onClose={onClose}
className='min-w-[1140px] !p-0'
closable
>
<div className='relative flex h-[680px] flex-wrap'>
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-gray-100 p-8'>
<div className='mb-8'>
<div className={'text-lg font-bold leading-[28px]'}>{t('appDebug.codegen.title')}</div>
<div className='mt-1 text-[13px] font-normal text-gray-500'>{t('appDebug.codegen.description')}</div>
</div>
<div className='flex items-center'>
<ModelIcon
className='mr-1.5 shrink-0'
provider={currentProvider}
modelName={currentModel?.model}
/>
<ModelName
className='grow'
modelItem={currentModel!}
showMode
showFeatures
/>
</div>
<div className='mt-6'>
<div className='text-[0px]'>
<div className='mb-2 text-sm font-medium leading-5 text-gray-900'>{t('appDebug.codegen.instruction')}</div>
<textarea
className="h-[200px] w-full overflow-y-auto rounded-lg bg-gray-50 px-3 py-2 text-sm"
placeholder={t('appDebug.codegen.instructionPlaceholder') || ''}
value={instruction}
onChange={e => setInstruction(e.target.value)}
/>
</div>
<div className='mt-5 flex justify-end'>
<Button
className='flex space-x-1'
variant='primary'
onClick={onGenerate}
disabled={isLoading}
>
<Generator className='h-4 w-4 text-white' />
<span className='text-xs font-semibold text-white'>{t('appDebug.codegen.generate')}</span>
</Button>
</div>
</div>
</div>
{isLoading && renderLoading}
{!isLoading && !res && renderNoData}
{(!isLoading && res) && (
<div className='h-full w-0 grow p-6 pb-0'>
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-gray-800'>{t('appDebug.codegen.resTitle')}</div>
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
<ConfigPrompt
mode={mode}
promptTemplate={res?.code || ''}
promptVariables={[]}
readonly
noTitle={isInLLMNode}
gradientBorder
editorHeight={isInLLMNode ? 524 : 0}
noResize={isInLLMNode}
/>
{!isInLLMNode && (
<>
{res?.code && (
<div className='mt-4'>
<h3 className='mb-2 text-sm font-medium text-gray-900'>{t('appDebug.codegen.generatedCode')}</h3>
<pre className='overflow-x-auto rounded-lg bg-gray-50 p-4'>
<code className={`language-${res.language}`}>
{res.code}
</code>
</pre>
</div>
)}
{res?.error && (
<div className='mt-4 rounded-lg bg-red-50 p-4'>
<p className='text-sm text-red-600'>{res.error}</p>
</div>
)}
</>
)}
</div>
<div className='flex justify-end bg-white py-4'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='ml-2' onClick={() => {
setShowConfirmOverwrite(true)
}}>{t('appDebug.codegen.apply')}</Button>
</div>
</div>
)}
</div>
{showConfirmOverwrite && (
<Confirm
title={t('appDebug.codegen.overwriteConfirmTitle')}
content={t('appDebug.codegen.overwriteConfirmMessage')}
isShow={showConfirmOverwrite}
onConfirm={() => {
setShowConfirmOverwrite(false)
onFinished(res!)
}}
onCancel={() => setShowConfirmOverwrite(false)}
/>
)}
</Modal>
)
}
export default React.memo(GetCodeGeneratorResModal)

View File

@@ -0,0 +1,78 @@
'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 { Document } from '@/app/components/base/icons/src/vender/features'
import Tooltip from '@/app/components/base/tooltip'
import ConfigContext from '@/context/debug-configuration'
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
import { useFeatures, useFeaturesStore } from '@/app/components/base/features/hooks'
import Switch from '@/app/components/base/switch'
const ConfigDocument: FC = () => {
const { t } = useTranslation()
const file = useFeatures(s => s.features.file)
const featuresStore = useFeaturesStore()
const { isShowDocumentConfig } = useContext(ConfigContext)
const isDocumentEnabled = file?.allowed_file_types?.includes(SupportUploadFileTypes.document) ?? 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.document,
]))
}
else {
draft.file!.allowed_file_types = draft.file!.allowed_file_types?.filter(
type => type !== SupportUploadFileTypes.document,
)
}
if (draft.file)
draft.file.enabled = (draft.file.allowed_file_types?.length ?? 0) > 0
})
setFeatures(newFeatures)
}, [featuresStore])
if (!isShowDocumentConfig)
return null
return (
<div className='mt-2 flex items-center gap-2 rounded-xl border-l-[0.5px] border-t-[0.5px] 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'>
<Document 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.feature.documentUpload.title')}</div>
<Tooltip
popupContent={
<div className='w-[180px]' >
{t('appDebug.feature.documentUpload.description')}
</div>
}
/>
</div>
<div className='flex shrink-0 items-center'>
<div className='ml-1 mr-3 h-3.5 w-[1px] bg-divider-subtle'></div>
<Switch
defaultValue={isDocumentEnabled}
onChange={handleChange}
size='md'
/>
</div>
</div>
)
}
export default React.memo(ConfigDocument)

View File

@@ -0,0 +1,96 @@
import React, { useEffect } from 'react'
function useFeature({
introduction,
setIntroduction,
moreLikeThis,
setMoreLikeThis,
suggestedQuestionsAfterAnswer,
setSuggestedQuestionsAfterAnswer,
speechToText,
setSpeechToText,
textToSpeech,
setTextToSpeech,
citation,
setCitation,
annotation,
setAnnotation,
moderation,
setModeration,
}: {
introduction: string
setIntroduction: (introduction: string) => void
moreLikeThis: boolean
setMoreLikeThis: (moreLikeThis: boolean) => void
suggestedQuestionsAfterAnswer: boolean
setSuggestedQuestionsAfterAnswer: (suggestedQuestionsAfterAnswer: boolean) => void
speechToText: boolean
setSpeechToText: (speechToText: boolean) => void
textToSpeech: boolean
setTextToSpeech: (textToSpeech: boolean) => void
citation: boolean
setCitation: (citation: boolean) => void
annotation: boolean
setAnnotation: (annotation: boolean) => void
moderation: boolean
setModeration: (moderation: boolean) => void
}) {
const [tempShowOpeningStatement, setTempShowOpeningStatement] = React.useState(!!introduction)
useEffect(() => {
// wait to api data back
if (introduction)
setTempShowOpeningStatement(true)
}, [introduction])
// const [tempMoreLikeThis, setTempMoreLikeThis] = React.useState(moreLikeThis)
// useEffect(() => {
// setTempMoreLikeThis(moreLikeThis)
// }, [moreLikeThis])
const featureConfig = {
openingStatement: tempShowOpeningStatement,
moreLikeThis,
suggestedQuestionsAfterAnswer,
speechToText,
textToSpeech,
citation,
annotation,
moderation,
}
const handleFeatureChange = (key: string, value: boolean) => {
switch (key) {
case 'openingStatement':
if (!value)
setIntroduction('')
setTempShowOpeningStatement(value)
break
case 'moreLikeThis':
setMoreLikeThis(value)
break
case 'suggestedQuestionsAfterAnswer':
setSuggestedQuestionsAfterAnswer(value)
break
case 'speechToText':
setSpeechToText(value)
break
case 'textToSpeech':
setTextToSpeech(value)
break
case 'citation':
setCitation(value)
break
case 'annotation':
setAnnotation(value)
break
case 'moderation':
setModeration(value)
}
}
return {
featureConfig,
handleFeatureChange,
}
}
export default useFeature

View File

@@ -0,0 +1,99 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useContext } from 'use-context-selector'
import produce from 'immer'
import { useFormattingChangedDispatcher } from '../debug/hooks'
import DatasetConfig from '../dataset-config'
import HistoryPanel from '../config-prompt/conversation-history/history-panel'
import ConfigVision from '../config-vision'
import ConfigDocument from './config-document'
import AgentTools from './agent/agent-tools'
import ConfigContext from '@/context/debug-configuration'
import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
import ConfigVar from '@/app/components/app/configuration/config-var'
import type { ModelConfig, PromptVariable } from '@/models/debug'
import type { AppType } from '@/types/app'
import { ModelModeType } from '@/types/app'
const Config: FC = () => {
const {
mode,
isAdvancedMode,
modelModeType,
isAgent,
hasSetBlockStatus,
showHistoryModal,
modelConfig,
setModelConfig,
setPrevPromptConfig,
} = useContext(ConfigContext)
const isChatApp = ['advanced-chat', 'agent-chat', 'chat'].includes(mode)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
const promptTemplate = modelConfig.configs.prompt_template
const promptVariables = modelConfig.configs.prompt_variables
// simple mode
const handlePromptChange = (newTemplate: string, newVariables: PromptVariable[]) => {
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.configs.prompt_template = newTemplate
draft.configs.prompt_variables = [...draft.configs.prompt_variables, ...newVariables]
})
if (modelConfig.configs.prompt_template !== newTemplate)
formattingChangedDispatcher()
setPrevPromptConfig(modelConfig.configs)
setModelConfig(newModelConfig)
}
const handlePromptVariablesNameChange = (newVariables: PromptVariable[]) => {
setPrevPromptConfig(modelConfig.configs)
const newModelConfig = produce(modelConfig, (draft: ModelConfig) => {
draft.configs.prompt_variables = newVariables
})
setModelConfig(newModelConfig)
}
return (
<>
<div
className="relative h-0 grow overflow-y-auto px-6 pb-[50px]"
>
{/* Template */}
<ConfigPrompt
mode={mode as AppType}
promptTemplate={promptTemplate}
promptVariables={promptVariables}
onChange={handlePromptChange}
/>
{/* Variables */}
<ConfigVar
promptVariables={promptVariables}
onPromptVariablesChange={handlePromptVariablesNameChange}
/>
{/* Dataset */}
<DatasetConfig />
{/* Tools */}
{isAgent && (
<AgentTools />
)}
<ConfigVision />
<ConfigDocument />
{/* Chat History */}
{isAdvancedMode && isChatApp && modelModeType === ModelModeType.completion && (
<HistoryPanel
showWarning={!hasSetBlockStatus.history}
onShowEditModal={showHistoryModal}
/>
)}
</div>
</>
)
}
export default React.memo(Config)

View File

@@ -0,0 +1,24 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import s from './style.module.css'
import Button from '@/app/components/base/button'
export type IContrlBtnGroupProps = {
onSave: () => void
onReset: () => void
}
const ContrlBtnGroup: FC<IContrlBtnGroupProps> = ({ onSave, onReset }) => {
const { t } = useTranslation()
return (
<div className="fixed bottom-0 left-[224px] h-[64px] w-[519px]">
<div className={`${s.ctrlBtn} flex h-full items-center gap-2 bg-white pl-4`}>
<Button variant='primary' onClick={onSave}>{t('appDebug.operation.applyConfig')}</Button>
<Button onClick={onReset}>{t('appDebug.operation.resetConfig')}</Button>
</div>
</div>
)
}
export default React.memo(ContrlBtnGroup)

View File

@@ -0,0 +1,6 @@
.ctrlBtn {
left: -16px;
right: -16px;
bottom: -16px;
border-top: 1px solid #F3F4F6;
}

View File

@@ -0,0 +1,58 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import TypeIcon from '../type-icon'
import RemoveIcon from '../../base/icons/remove-icon'
import s from './style.module.css'
import cn from '@/utils/classnames'
import type { DataSet } from '@/models/datasets'
import { formatNumber } from '@/utils/format'
import Tooltip from '@/app/components/base/tooltip'
export type ICardItemProps = {
className?: string
config: DataSet
onRemove: (id: string) => void
readonly?: boolean
}
const CardItem: FC<ICardItemProps> = ({
className,
config,
onRemove,
readonly,
}) => {
const { t } = useTranslation()
return (
<div
className={
cn(className, s.card,
'relative flex cursor-pointer items-center rounded-xl border border-gray-200 bg-white px-3 py-2.5')
}>
<div className='flex items-center space-x-2'>
<div className={cn(!config.embedding_available && 'opacity-50')}>
<TypeIcon type="upload_file" />
</div>
<div>
<div className='mr-1 flex w-[160px] items-center'>
<div className={cn('overflow-hidden text-ellipsis whitespace-nowrap text-[13px] font-medium leading-[18px] text-gray-800', !config.embedding_available && 'opacity-50')}>{config.name}</div>
{!config.embedding_available && (
<Tooltip
popupContent={t('dataset.unavailableTip')}
>
<span className='inline-flex shrink-0 whitespace-nowrap rounded-md border border-gray-200 px-1 text-xs font-normal leading-[18px] text-gray-500'>{t('dataset.unavailable')}</span>
</Tooltip>
)}
</div>
<div className={cn('flex max-w-[150px] text-xs text-gray-500', !config.embedding_available && 'opacity-50')}>
{formatNumber(config.word_count)} {t('appDebug.feature.dataSet.words')} · {formatNumber(config.document_count)} {t('appDebug.feature.dataSet.textBlocks')}
</div>
</div>
</div>
{!readonly && <RemoveIcon className={`${s.deleteBtn} absolute right-1 top-1/2 translate-y-[-50%]`} onClick={() => onRemove(config.id)} />}
</div>
)
}
export default React.memo(CardItem)

View File

@@ -0,0 +1,111 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import SettingsModal from '../settings-modal'
import type { DataSet } from '@/models/datasets'
import { DataSourceType } from '@/models/datasets'
import FileIcon from '@/app/components/base/file-icon'
import { Folder } from '@/app/components/base/icons/src/vender/solid/files'
import { Globe06 } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
import Drawer from '@/app/components/base/drawer'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
type ItemProps = {
className?: string
config: DataSet
onRemove: (id: string) => void
readonly?: boolean
onSave: (newDataset: DataSet) => void
editable?: boolean
}
const Item: FC<ItemProps> = ({
config,
onSave,
onRemove,
editable = true,
}) => {
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const [showSettingsModal, setShowSettingsModal] = useState(false)
const { formatIndexingTechniqueAndMethod } = useKnowledge()
const { t } = useTranslation()
const handleSave = (newDataset: DataSet) => {
onSave(newDataset)
setShowSettingsModal(false)
}
const [isDeleting, setIsDeleting] = useState(false)
return (
<div className={cn('group relative mb-1 flex w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-2 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')}>
{
config.data_source_type === DataSourceType.FILE && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF]'>
<Folder className='h-4 w-4 text-[#444CE7]' />
</div>
)
}
{
config.data_source_type === DataSourceType.NOTION && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5]'>
<FileIcon type='notion' className='h-4 w-4' />
</div>
)
}
{
config.data_source_type === DataSourceType.WEB && (
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-blue-100 bg-[#F5FAFF]'>
<Globe06 className='h-4 w-4 text-blue-600' />
</div>
)
}
<div className='grow'>
<div className='flex h-[18px] items-center'>
<div className='grow truncate text-[13px] font-medium text-text-secondary' title={config.name}>{config.name}</div>
{config.provider === 'external'
? <Badge text={t('dataset.externalTag') as string} />
: <Badge
text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)}
/>}
</div>
</div >
<div className='absolute bottom-0 right-0 top-0 hidden w-[124px] items-center justify-end rounded-lg bg-gradient-to-r from-white/50 to-white to-50% pr-2 group-hover:flex'>
{
editable && <div
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-black/5'
onClick={() => setShowSettingsModal(true)}
>
<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(config.id)}
onMouseOver={() => setIsDeleting(true)}
onMouseLeave={() => setIsDeleting(false)}
>
<RiDeleteBinLine className='h-4 w-4' />
</div>
</div>
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassname='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
<SettingsModal
currentDataset={config}
onCancel={() => setShowSettingsModal(false)}
onSave={handleSave}
/>
</Drawer>
</div >
)
}
export default Item

View File

@@ -0,0 +1,22 @@
.card {
box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05);
width: 100%;
}
.card:hover {
box-shadow: 0px 4px 8px -2px rgba(16, 24, 40, 0.1), 0px 2px 4px -2px rgba(16, 24, 40, 0.06);
}
.btnWrap {
padding-left: 64px;
visibility: hidden;
background: linear-gradient(270deg, #FFF 49.99%, rgba(255, 255, 255, 0.00) 98.1%);
}
.card:hover .btnWrap {
visibility: visible;
}
.settingBtn:hover {
background-color: rgba(0, 0, 0, 0.05);
}

View File

@@ -0,0 +1,37 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import type { Props } from './var-picker'
import VarPicker from './var-picker'
import cn from '@/utils/classnames'
import { BracketsX } from '@/app/components/base/icons/src/vender/line/development'
import Tooltip from '@/app/components/base/tooltip'
const ContextVar: FC<Props> = (props) => {
const { t } = useTranslation()
const { value, options } = props
const currItem = options.find(item => item.value === value)
const notSetVar = !currItem
return (
<div className={cn(notSetVar ? 'rounded-bl-xl rounded-br-xl border-[#FEF0C7] bg-[#FEF0C7]' : 'border-components-panel-border-subtle', 'flex h-12 items-center justify-between border-t px-3 ')}>
<div className='flex shrink-0 items-center space-x-1'>
<div className='p-1'>
<BracketsX className='h-4 w-4 text-text-accent' />
</div>
<div className='mr-1 text-sm font-medium text-text-secondary'>{t('appDebug.feature.dataSet.queryVariable.title')}</div>
<Tooltip
popupContent={
<div className='w-[180px]'>
{t('appDebug.feature.dataSet.queryVariable.tip')}
</div>
}
/>
</div>
<VarPicker {...props} />
</div>
)
}
export default React.memo(ContextVar)

View File

@@ -0,0 +1,104 @@
'use client'
import type { FC } from 'react'
import React, { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ChevronDownIcon } from '@heroicons/react/24/outline'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import type { IInputTypeIconProps } from '@/app/components/app/configuration/config-var/input-type-icon'
import IconTypeIcon from '@/app/components/app/configuration/config-var/input-type-icon'
type Option = { name: string; value: string; type: string }
export type Props = {
triggerClassName?: string
className?: string
value: string | undefined
options: Option[]
onChange: (value: string) => void
notSelectedVarTip?: string | null
}
const VarItem: FC<{ item: Option }> = ({ item }) => (
<div className='flex h-[18px] items-center space-x-1 rounded bg-[#EFF8FF] px-1'>
<IconTypeIcon type={item.type as IInputTypeIconProps['type']} className='text-[#1570EF]' />
<div className='flex text-xs font-medium text-[#1570EF]'>
<span className='opacity-60'>{'{{'}</span>
<span className='max-w-[150px] truncate'>{item.value}</span>
<span className='opacity-60'>{'}}'}</span>
</div>
</div>
)
const VarPicker: FC<Props> = ({
triggerClassName,
className,
value,
options,
onChange,
notSelectedVarTip,
}) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const currItem = options.find(item => item.value === value)
const notSetVar = !currItem
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 8,
}}
>
<PortalToFollowElemTrigger className={cn(triggerClassName)} onClick={() => setOpen(v => !v)}>
<div className={cn(
className,
notSetVar ? 'border-[#FEDF89] bg-[#FFFCF5] text-[#DC6803]' : ' border-components-button-secondary-border text-text-accent hover:bg-components-button-secondary-bg',
open ? 'bg-components-button-secondary-bg' : 'bg-transparent',
`
flex h-8 cursor-pointer items-center justify-center space-x-1 rounded-lg border px-2 text-[13px]
font-medium shadow-xs
`)}>
<div>
{value
? (
<VarItem item={currItem as Option} />
)
: (<div>
{notSelectedVarTip || t('appDebug.feature.dataSet.queryVariable.choosePlaceholder')}
</div>)}
</div>
<ChevronDownIcon className={cn(open && 'rotate-180 text-text-tertiary', 'h-3.5 w-3.5')} />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent style={{ zIndex: 1000 }}>
{options.length > 0
? (<div className='max-h-[50vh] w-[240px] overflow-y-auto rounded-lg border border-components-panel-border bg-components-panel-bg p-1 shadow-lg'>
{options.map(({ name, value, type }, index) => (
<div
key={index}
className='flex cursor-pointer rounded-lg px-3 py-1 hover:bg-state-base-hover'
onClick={() => {
onChange(value)
setOpen(false)
}}
>
<VarItem item={{ name, value, type }} />
</div>
))}
</div>)
: (
<div className='w-[240px] rounded-lg border border-components-panel-border bg-components-panel-bg p-6 shadow-lg'>
<div className='mb-1 text-sm font-medium text-text-secondary'>{t('appDebug.feature.dataSet.queryVariable.noVar')}</div>
<div className='text-xs leading-normal text-text-tertiary'>{t('appDebug.feature.dataSet.queryVariable.noVarTip')}</div>
</div>
)}
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(VarPicker)

View File

@@ -0,0 +1,288 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { intersectionBy } from 'lodash-es'
import { useContext } from 'use-context-selector'
import produce from 'immer'
import { v4 as uuid4 } from 'uuid'
import { useFormattingChangedDispatcher } from '../debug/hooks'
import FeaturePanel from '../base/feature-panel'
import OperationBtn from '../base/operation-btn'
import CardItem from './card-item/item'
import ParamsConfig from './params-config'
import ContextVar from './context-var'
import ConfigContext from '@/context/debug-configuration'
import { AppType } from '@/types/app'
import type { DataSet } from '@/models/datasets'
import {
getMultipleRetrievalConfig,
getSelectedDatasetsMode,
} from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useSelector as useAppContextSelector } from '@/context/app-context'
import { hasEditPermissionForDataset } from '@/utils/permission'
import MetadataFilter from '@/app/components/workflow/nodes/knowledge-retrieval/components/metadata/metadata-filter'
import type {
HandleAddCondition,
HandleRemoveCondition,
HandleToggleConditionLogicalOperator,
HandleUpdateCondition,
MetadataFilteringModeEnum,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
import {
ComparisonOperator,
LogicalOperator,
MetadataFilteringVariableType,
} from '@/app/components/workflow/nodes/knowledge-retrieval/types'
const DatasetConfig: FC = () => {
const { t } = useTranslation()
const userProfile = useAppContextSelector(s => s.userProfile)
const {
mode,
dataSets: dataSet,
setDataSets: setDataSet,
modelConfig,
setModelConfig,
showSelectDataSet,
isAgent,
datasetConfigs,
datasetConfigsRef,
setDatasetConfigs,
setRerankSettingModalOpen,
} = useContext(ConfigContext)
const formattingChangedDispatcher = useFormattingChangedDispatcher()
const hasData = dataSet.length > 0
const {
currentModel: currentRerankModel,
currentProvider: currentRerankProvider,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const onRemove = (id: string) => {
const filteredDataSets = dataSet.filter(item => item.id !== id)
setDataSet(filteredDataSets)
const retrievalConfig = getMultipleRetrievalConfig(datasetConfigs as any, filteredDataSets, dataSet, {
provider: currentRerankProvider?.provider,
model: currentRerankModel?.model,
})
setDatasetConfigs({
...(datasetConfigs as any),
...retrievalConfig,
})
const {
allExternal,
allInternal,
mixtureInternalAndExternal,
mixtureHighQualityAndEconomic,
inconsistentEmbeddingModel,
} = getSelectedDatasetsMode(filteredDataSets)
if (
(allInternal && (mixtureHighQualityAndEconomic || inconsistentEmbeddingModel))
|| mixtureInternalAndExternal
|| allExternal
)
setRerankSettingModalOpen(true)
formattingChangedDispatcher()
}
const handleSave = (newDataset: DataSet) => {
const index = dataSet.findIndex(item => item.id === newDataset.id)
const newDatasets = [...dataSet.slice(0, index), newDataset, ...dataSet.slice(index + 1)]
setDataSet(newDatasets)
formattingChangedDispatcher()
}
const promptVariables = modelConfig.configs.prompt_variables
const promptVariablesToSelect = promptVariables.map(item => ({
name: item.name,
type: item.type,
value: item.key,
}))
const selectedContextVar = promptVariables?.find(item => item.is_context_var)
const handleSelectContextVar = (selectedValue: string) => {
const newModelConfig = produce(modelConfig, (draft) => {
draft.configs.prompt_variables = modelConfig.configs.prompt_variables.map((item) => {
return ({
...item,
is_context_var: item.key === selectedValue,
})
})
})
setModelConfig(newModelConfig)
}
const formattedDataset = useMemo(() => {
return dataSet.map((item) => {
const datasetConfig = {
createdBy: item.created_by,
partialMemberList: item.partial_member_list || [],
permission: item.permission,
}
return {
...item,
editable: hasEditPermissionForDataset(userProfile?.id || '', datasetConfig),
}
})
}, [dataSet, userProfile?.id])
const metadataList = useMemo(() => {
return intersectionBy(...formattedDataset.filter((dataset) => {
return !!dataset.doc_metadata
}).map((dataset) => {
return dataset.doc_metadata!
}), 'name')
}, [formattedDataset])
const handleMetadataFilterModeChange = useCallback((newMode: MetadataFilteringModeEnum) => {
setDatasetConfigs(produce(datasetConfigsRef.current!, (draft) => {
draft.metadata_filtering_mode = newMode
}))
}, [setDatasetConfigs, datasetConfigsRef])
const handleAddCondition = useCallback<HandleAddCondition>(({ name, type }) => {
let operator: ComparisonOperator = ComparisonOperator.is
if (type === MetadataFilteringVariableType.number)
operator = ComparisonOperator.equal
const newCondition = {
id: uuid4(),
name,
comparison_operator: operator,
}
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
if (draft.metadata_filtering_conditions) {
draft.metadata_filtering_conditions.conditions.push(newCondition)
}
else {
draft.metadata_filtering_conditions = {
logical_operator: LogicalOperator.and,
conditions: [newCondition],
}
}
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
const handleRemoveCondition = useCallback<HandleRemoveCondition>((id) => {
const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || []
const index = conditions.findIndex(c => c.id === id)
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
if (index > -1)
draft.metadata_filtering_conditions?.conditions.splice(index, 1)
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
const handleUpdateCondition = useCallback<HandleUpdateCondition>((id, newCondition) => {
const conditions = datasetConfigsRef.current!.metadata_filtering_conditions?.conditions || []
const index = conditions.findIndex(c => c.id === id)
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
if (index > -1)
draft.metadata_filtering_conditions!.conditions[index] = newCondition
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
const handleToggleConditionLogicalOperator = useCallback<HandleToggleConditionLogicalOperator>(() => {
const oldLogicalOperator = datasetConfigsRef.current!.metadata_filtering_conditions?.logical_operator
const newLogicalOperator = oldLogicalOperator === LogicalOperator.and ? LogicalOperator.or : LogicalOperator.and
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
draft.metadata_filtering_conditions!.logical_operator = newLogicalOperator
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
const handleMetadataModelChange = useCallback((model: { provider: string; modelId: string; mode?: string }) => {
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
draft.metadata_model_config = {
provider: model.provider,
name: model.modelId,
mode: model.mode || 'chat',
completion_params: draft.metadata_model_config?.completion_params || { temperature: 0.7 },
}
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
const handleMetadataCompletionParamsChange = useCallback((newParams: Record<string, any>) => {
const newInputs = produce(datasetConfigsRef.current!, (draft) => {
draft.metadata_model_config = {
...draft.metadata_model_config!,
completion_params: newParams,
}
})
setDatasetConfigs(newInputs)
}, [setDatasetConfigs, datasetConfigsRef])
return (
<FeaturePanel
className='mt-2'
title={t('appDebug.feature.dataSet.title')}
headerRight={
<div className='flex items-center gap-1'>
{!isAgent && <ParamsConfig disabled={!hasData} selectedDatasets={dataSet} />}
<OperationBtn type="add" onClick={showSelectDataSet} />
</div>
}
hasHeaderBottomBorder={!hasData}
noBodySpacing
>
{hasData
? (
<div className='mt-1 flex flex-wrap justify-between px-3 pb-3'>
{formattedDataset.map(item => (
<CardItem
key={item.id}
config={item}
onRemove={onRemove}
onSave={handleSave}
editable={item.editable}
/>
))}
</div>
)
: (
<div className='mt-1 px-3 pb-3'>
<div className='pb-1 pt-2 text-xs text-text-tertiary'>{t('appDebug.feature.dataSet.noData')}</div>
</div>
)}
<div className='border-t border-t-divider-subtle py-2'>
<MetadataFilter
metadataList={metadataList}
selectedDatasetsLoaded
metadataFilterMode={datasetConfigs.metadata_filtering_mode}
metadataFilteringConditions={datasetConfigs.metadata_filtering_conditions}
handleAddCondition={handleAddCondition}
handleMetadataFilterModeChange={handleMetadataFilterModeChange}
handleRemoveCondition={handleRemoveCondition}
handleToggleConditionLogicalOperator={handleToggleConditionLogicalOperator}
handleUpdateCondition={handleUpdateCondition}
metadataModelConfig={datasetConfigs.metadata_model_config}
handleMetadataModelChange={handleMetadataModelChange}
handleMetadataCompletionParamsChange={handleMetadataCompletionParamsChange}
isCommonVariable
availableCommonStringVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.string || item.type === MetadataFilteringVariableType.select)}
availableCommonNumberVars={promptVariablesToSelect.filter(item => item.type === MetadataFilteringVariableType.number)}
/>
</div>
{mode === AppType.completion && dataSet.length > 0 && (
<ContextVar
value={selectedContextVar?.key}
options={promptVariablesToSelect}
onChange={handleSelectContextVar}
/>
)}
</FeaturePanel>
)
}
export default React.memo(DatasetConfig)

View File

@@ -0,0 +1,382 @@
'use client'
import { memo, useCallback, useEffect, useMemo } from 'react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import WeightedScore from './weighted-score'
import TopKItem from '@/app/components/base/param-item/top-k-item'
import ScoreThresholdItem from '@/app/components/base/param-item/score-threshold-item'
import { RETRIEVE_TYPE } from '@/types/app'
import type {
DatasetConfigs,
} from '@/models/debug'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import type { ModelConfig } from '@/app/components/workflow/types'
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
import Tooltip from '@/app/components/base/tooltip'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import type {
DataSet,
} from '@/models/datasets'
import { RerankingModeEnum } from '@/models/datasets'
import cn from '@/utils/classnames'
import { useSelectedDatasetsMode } from '@/app/components/workflow/nodes/knowledge-retrieval/hooks'
import Switch from '@/app/components/base/switch'
import Toast from '@/app/components/base/toast'
import Divider from '@/app/components/base/divider'
import { noop } from 'lodash-es'
type Props = {
datasetConfigs: DatasetConfigs
onChange: (configs: DatasetConfigs, isRetrievalModeChange?: boolean) => void
isInWorkflow?: boolean
singleRetrievalModelConfig?: ModelConfig
onSingleRetrievalModelChange?: (config: ModelConfig) => void
onSingleRetrievalModelParamsChange?: (config: ModelConfig) => void
selectedDatasets?: DataSet[]
}
const ConfigContent: FC<Props> = ({
datasetConfigs,
onChange,
isInWorkflow,
singleRetrievalModelConfig: singleRetrievalConfig = {} as ModelConfig,
onSingleRetrievalModelChange = noop,
onSingleRetrievalModelParamsChange = noop,
selectedDatasets = [],
}) => {
const { t } = useTranslation()
const selectedDatasetsMode = useSelectedDatasetsMode(selectedDatasets)
const type = datasetConfigs.retrieval_model
useEffect(() => {
if (type === RETRIEVE_TYPE.oneWay) {
onChange({
...datasetConfigs,
retrieval_model: RETRIEVE_TYPE.multiWay,
}, isInWorkflow)
}
}, [type, datasetConfigs, isInWorkflow, onChange])
const {
modelList: rerankModelList,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const {
currentModel: currentRerankModel,
} = useCurrentProviderAndModel(
rerankModelList,
{
provider: datasetConfigs.reranking_model?.reranking_provider_name,
model: datasetConfigs.reranking_model?.reranking_model_name,
},
)
const rerankModel = useMemo(() => {
return {
provider_name: datasetConfigs?.reranking_model?.reranking_provider_name ?? '',
model_name: datasetConfigs?.reranking_model?.reranking_model_name ?? '',
}
}, [datasetConfigs.reranking_model])
const handleParamChange = (key: string, value: number) => {
if (key === 'top_k') {
onChange({
...datasetConfigs,
top_k: value,
})
}
else if (key === 'score_threshold') {
onChange({
...datasetConfigs,
score_threshold: value,
})
}
}
const handleSwitch = (key: string, enable: boolean) => {
if (key === 'top_k')
return
onChange({
...datasetConfigs,
score_threshold_enabled: enable,
})
}
const handleWeightedScoreChange = (value: { value: number[] }) => {
const configs = {
...datasetConfigs,
weights: {
...datasetConfigs.weights!,
vector_setting: {
...datasetConfigs.weights!.vector_setting!,
vector_weight: value.value[0],
},
keyword_setting: {
keyword_weight: value.value[1],
},
},
}
onChange(configs)
}
const handleRerankModeChange = (mode: RerankingModeEnum) => {
if (mode === datasetConfigs.reranking_mode)
return
if (mode === RerankingModeEnum.RerankingModel && !currentRerankModel)
Toast.notify({ type: 'error', message: t('workflow.errorMsg.rerankModelRequired') })
onChange({
...datasetConfigs,
reranking_mode: mode,
})
}
const model = singleRetrievalConfig
const rerankingModeOptions = [
{
value: RerankingModeEnum.WeightedScore,
label: t('dataset.weightedScore.title'),
tips: t('dataset.weightedScore.description'),
},
{
value: RerankingModeEnum.RerankingModel,
label: t('common.modelProvider.rerankModel.key'),
tips: t('common.modelProvider.rerankModel.tip'),
},
]
const showWeightedScore = selectedDatasetsMode.allHighQuality
&& !selectedDatasetsMode.inconsistentEmbeddingModel
const showWeightedScorePanel = showWeightedScore && datasetConfigs.reranking_mode === RerankingModeEnum.WeightedScore && datasetConfigs.weights
const selectedRerankMode = datasetConfigs.reranking_mode || RerankingModeEnum.RerankingModel
const canManuallyToggleRerank = useMemo(() => {
return (selectedDatasetsMode.allInternal && selectedDatasetsMode.allEconomic)
|| selectedDatasetsMode.allExternal
}, [selectedDatasetsMode.allEconomic, selectedDatasetsMode.allExternal, selectedDatasetsMode.allInternal])
const showRerankModel = useMemo(() => {
if (!canManuallyToggleRerank)
return true
return datasetConfigs.reranking_enable
}, [datasetConfigs.reranking_enable, canManuallyToggleRerank])
const handleDisabledSwitchClick = useCallback((enable: boolean) => {
if (!currentRerankModel && enable)
Toast.notify({ type: 'error', message: t('workflow.errorMsg.rerankModelRequired') })
onChange({
...datasetConfigs,
reranking_enable: enable,
})
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [currentRerankModel, datasetConfigs, onChange])
return (
<div>
<div className='system-xl-semibold text-text-primary'>{t('dataset.retrievalSettings')}</div>
<div className='system-xs-regular text-text-tertiary'>
{t('dataset.defaultRetrievalTip')}
</div>
{type === RETRIEVE_TYPE.multiWay && (
<>
<div className='my-2 flex h-6 items-center py-1'>
<div className='system-xs-semibold-uppercase mr-2 shrink-0 text-text-secondary'>
{t('dataset.rerankSettings')}
</div>
<Divider bgStyle='gradient' className='mx-0 !h-px' />
</div>
{
selectedDatasetsMode.inconsistentEmbeddingModel
&& (
<div className='system-xs-medium mt-4 text-text-warning'>
{t('dataset.inconsistentEmbeddingModelTip')}
</div>
)
}
{
selectedDatasetsMode.mixtureInternalAndExternal && (
<div className='system-xs-medium mt-4 text-text-warning'>
{t('dataset.mixtureInternalAndExternalTip')}
</div>
)
}
{
selectedDatasetsMode.allExternal && (
<div className='system-xs-medium mt-4 text-text-warning'>
{t('dataset.allExternalTip')}
</div>
)
}
{
selectedDatasetsMode.mixtureHighQualityAndEconomic
&& (
<div className='system-xs-medium mt-4 text-text-warning'>
{t('dataset.mixtureHighQualityAndEconomicTip')}
</div>
)
}
{
showWeightedScore && (
<div className='flex items-center justify-between'>
{
rerankingModeOptions.map(option => (
<div
key={option.value}
className={cn(
'system-sm-medium flex h-8 w-[calc((100%-8px)/2)] cursor-pointer items-center justify-center rounded-lg border border-components-option-card-option-border bg-components-option-card-option-bg text-text-secondary',
selectedRerankMode === option.value && 'border-[1.5px] border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg text-text-primary',
)}
onClick={() => handleRerankModeChange(option.value)}
>
<div className='truncate'>{option.label}</div>
<Tooltip
popupContent={
<div className='w-[200px]'>
{option.tips}
</div>
}
popupClassName='ml-0.5'
triggerClassName='ml-0.5 w-3.5 h-3.5'
/>
</div>
))
}
</div>
)
}
{
!showWeightedScorePanel && (
<div className='mt-2'>
<div className='flex items-center'>
{
selectedDatasetsMode.allEconomic && !selectedDatasetsMode.mixtureInternalAndExternal && (
<Switch
size='md'
defaultValue={showRerankModel}
disabled={!canManuallyToggleRerank}
onChange={handleDisabledSwitchClick}
/>
)
}
<div className='system-sm-semibold ml-1 leading-[32px] text-text-secondary'>{t('common.modelProvider.rerankModel.key')}</div>
<Tooltip
popupContent={
<div className="w-[200px]">
{t('common.modelProvider.rerankModel.tip')}
</div>
}
popupClassName='ml-1'
triggerClassName='ml-1 w-4 h-4'
/>
</div>
{
showRerankModel && (
<div>
<ModelSelector
defaultModel={rerankModel && { provider: rerankModel?.provider_name, model: rerankModel?.model_name }}
onSelect={(v) => {
onChange({
...datasetConfigs,
reranking_model: {
reranking_provider_name: v.provider,
reranking_model_name: v.model,
},
})
}}
modelList={rerankModelList}
/>
</div>
)}
</div>
)
}
{
showWeightedScorePanel
&& (
<div className='mt-2 space-y-4'>
<WeightedScore
value={{
value: [
datasetConfigs.weights!.vector_setting.vector_weight,
datasetConfigs.weights!.keyword_setting.keyword_weight,
],
}}
onChange={handleWeightedScoreChange}
/>
<TopKItem
value={datasetConfigs.top_k}
onChange={handleParamChange}
enable={true}
/>
<ScoreThresholdItem
value={datasetConfigs.score_threshold as number}
onChange={handleParamChange}
enable={datasetConfigs.score_threshold_enabled}
hasSwitch={true}
onSwitchChange={handleSwitch}
/>
</div>
)
}
{
!showWeightedScorePanel
&& (
<div className='mt-4 space-y-4'>
<TopKItem
value={datasetConfigs.top_k}
onChange={handleParamChange}
enable={true}
/>
{
showRerankModel && (
<ScoreThresholdItem
value={datasetConfigs.score_threshold as number}
onChange={handleParamChange}
enable={datasetConfigs.score_threshold_enabled}
hasSwitch={true}
onSwitchChange={handleSwitch}
/>
)
}
</div>
)
}
</>
)}
{isInWorkflow && type === RETRIEVE_TYPE.oneWay && (
<div className='mt-4'>
<div className='flex items-center space-x-0.5'>
<div className='text-[13px] font-medium leading-[32px] text-text-primary'>{t('common.modelProvider.systemReasoningModel.key')}</div>
<Tooltip
popupContent={t('common.modelProvider.systemReasoningModel.tip')}
/>
</div>
<ModelParameterModal
isInWorkflow={isInWorkflow}
popupClassName='!w-[387px]'
portalToFollowElemContentClassName='!z-[1002]'
isAdvancedMode={true}
mode={model?.mode}
provider={model?.provider}
completionParams={model?.completion_params}
modelId={model?.name}
setModel={onSingleRetrievalModelChange as any}
onCompletionParamsChange={onSingleRetrievalModelParamsChange as any}
hideDebugWithMultipleModel
debugWithMultipleModel={false}
/>
</div>
)
}
</div >
)
}
export default memo(ConfigContent)

View File

@@ -0,0 +1,156 @@
'use client'
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiEqualizer2Line } from '@remixicon/react'
import ConfigContent from './config-content'
import cn from '@/utils/classnames'
import ConfigContext from '@/context/debug-configuration'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { RETRIEVE_TYPE } from '@/types/app'
import Toast from '@/app/components/base/toast'
import { useCurrentProviderAndModel, useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { RerankingModeEnum } from '@/models/datasets'
import type { DataSet } from '@/models/datasets'
import type { DatasetConfigs } from '@/models/debug'
import {
getMultipleRetrievalConfig,
} from '@/app/components/workflow/nodes/knowledge-retrieval/utils'
type ParamsConfigProps = {
disabled?: boolean
selectedDatasets: DataSet[]
}
const ParamsConfig = ({
disabled,
selectedDatasets,
}: ParamsConfigProps) => {
const { t } = useTranslation()
const {
datasetConfigs,
setDatasetConfigs,
rerankSettingModalOpen,
setRerankSettingModalOpen,
} = useContext(ConfigContext)
const [tempDataSetConfigs, setTempDataSetConfigs] = useState(datasetConfigs)
useEffect(() => {
setTempDataSetConfigs(datasetConfigs)
}, [datasetConfigs])
const {
modelList: rerankModelList,
currentModel: rerankDefaultModel,
currentProvider: rerankDefaultProvider,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const {
currentModel: isCurrentRerankModelValid,
} = useCurrentProviderAndModel(
rerankModelList,
{
provider: tempDataSetConfigs.reranking_model?.reranking_provider_name ?? '',
model: tempDataSetConfigs.reranking_model?.reranking_model_name ?? '',
},
)
const isValid = () => {
let errMsg = ''
if (tempDataSetConfigs.retrieval_model === RETRIEVE_TYPE.multiWay) {
if (tempDataSetConfigs.reranking_enable
&& tempDataSetConfigs.reranking_mode === RerankingModeEnum.RerankingModel
&& !isCurrentRerankModelValid
)
errMsg = t('appDebug.datasetConfig.rerankModelRequired')
}
if (errMsg) {
Toast.notify({
type: 'error',
message: errMsg,
})
}
return !errMsg
}
const handleSave = () => {
if (!isValid())
return
setDatasetConfigs(tempDataSetConfigs)
setRerankSettingModalOpen(false)
}
const handleSetTempDataSetConfigs = (newDatasetConfigs: DatasetConfigs) => {
const { datasets, retrieval_model, score_threshold_enabled, ...restConfigs } = newDatasetConfigs
const retrievalConfig = getMultipleRetrievalConfig({
top_k: restConfigs.top_k,
score_threshold: restConfigs.score_threshold,
reranking_model: restConfigs.reranking_model && {
provider: restConfigs.reranking_model.reranking_provider_name,
model: restConfigs.reranking_model.reranking_model_name,
},
reranking_mode: restConfigs.reranking_mode,
weights: restConfigs.weights,
reranking_enable: restConfigs.reranking_enable,
}, selectedDatasets, selectedDatasets, {
provider: rerankDefaultProvider?.provider,
model: rerankDefaultModel?.model,
})
setTempDataSetConfigs({
...retrievalConfig,
reranking_model: {
reranking_provider_name: retrievalConfig.reranking_model?.provider || '',
reranking_model_name: retrievalConfig.reranking_model?.model || '',
},
retrieval_model,
score_threshold_enabled,
datasets,
})
}
return (
<div>
<Button
variant='ghost'
size='small'
className={cn('h-7', rerankSettingModalOpen && 'bg-components-button-ghost-bg-hover')}
onClick={() => {
setRerankSettingModalOpen(true)
}}
disabled={disabled}
>
<RiEqualizer2Line className='mr-1 h-3.5 w-3.5' />
{t('dataset.retrievalSettings')}
</Button>
{
rerankSettingModalOpen && (
<Modal
isShow={rerankSettingModalOpen}
onClose={() => {
setRerankSettingModalOpen(false)
}}
className='sm:min-w-[528px]'
>
<ConfigContent
datasetConfigs={tempDataSetConfigs}
onChange={handleSetTempDataSetConfigs}
selectedDatasets={selectedDatasets}
/>
<div className='mt-6 flex justify-end'>
<Button className='mr-2 shrink-0' onClick={() => {
setTempDataSetConfigs(datasetConfigs)
setRerankSettingModalOpen(false)
}}>{t('common.operation.cancel')}</Button>
<Button variant='primary' className='shrink-0' onClick={handleSave} >{t('common.operation.save')}</Button>
</div>
</Modal>
)
}
</div>
)
}
export default memo(ParamsConfig)

View File

@@ -0,0 +1,7 @@
.weightedScoreSliderTrack {
background: var(--color-util-colors-blue-light-blue-light-500) !important;
}
.weightedScoreSliderTrack-1 {
background: transparent !important;
}

View File

@@ -0,0 +1,62 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import './weighted-score.css'
import Slider from '@/app/components/base/slider'
import cn from '@/utils/classnames'
import { noop } from 'lodash-es'
const formatNumber = (value: number) => {
if (value > 0 && value < 1)
return `0.${value * 10}`
else if (value === 1)
return '1.0'
return value
}
type Value = {
value: number[]
}
type WeightedScoreProps = {
value: Value
onChange: (value: Value) => void
}
const WeightedScore = ({
value,
onChange = noop,
}: WeightedScoreProps) => {
const { t } = useTranslation()
return (
<div>
<div className='space-x-3 rounded-lg border border-components-panel-border px-3 pb-2 pt-5'>
<Slider
className={cn('h-0.5 grow rounded-full !bg-util-colors-teal-teal-500')}
max={1.0}
min={0}
step={0.1}
value={value.value[0]}
onChange={v => onChange({ value: [v, (10 - v * 10) / 10] })}
trackClassName='weightedScoreSliderTrack'
/>
<div className='mt-3 flex justify-between'>
<div className='system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center text-util-colors-blue-light-blue-light-500'>
<div className='mr-1 truncate uppercase' title={t('dataset.weightedScore.semantic') || ''}>
{t('dataset.weightedScore.semantic')}
</div>
{formatNumber(value.value[0])}
</div>
<div className='system-xs-semibold-uppercase flex w-[90px] shrink-0 items-center justify-end text-util-colors-teal-teal-500'>
{formatNumber(value.value[1])}
<div className='ml-1 truncate uppercase' title={t('dataset.weightedScore.keyword') || ''}>
{t('dataset.weightedScore.keyword')}
</div>
</div>
</div>
</div>
</div>
)
}
export default memo(WeightedScore)

View File

@@ -0,0 +1,176 @@
'use client'
import type { FC } from 'react'
import React, { useRef, useState } from 'react'
import { useGetState, useInfiniteScroll } from 'ahooks'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import produce from 'immer'
import TypeIcon from '../type-icon'
import Modal from '@/app/components/base/modal'
import type { DataSet } from '@/models/datasets'
import Button from '@/app/components/base/button'
import { fetchDatasets } from '@/service/datasets'
import Loading from '@/app/components/base/loading'
import Badge from '@/app/components/base/badge'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
export type ISelectDataSetProps = {
isShow: boolean
onClose: () => void
selectedIds: string[]
onSelect: (dataSet: DataSet[]) => void
}
const SelectDataSet: FC<ISelectDataSetProps> = ({
isShow,
onClose,
selectedIds,
onSelect,
}) => {
const { t } = useTranslation()
const [selected, setSelected] = React.useState<DataSet[]>(selectedIds.map(id => ({ id }) as any))
const [loaded, setLoaded] = React.useState(false)
const [datasets, setDataSets] = React.useState<DataSet[] | null>(null)
const hasNoData = !datasets || datasets?.length === 0
const canSelectMulti = true
const listRef = useRef<HTMLDivElement>(null)
const [page, setPage, getPage] = useGetState(1)
const [isNoMore, setIsNoMore] = useState(false)
const { formatIndexingTechniqueAndMethod } = useKnowledge()
useInfiniteScroll(
async () => {
if (!isNoMore) {
const { data, has_more } = await fetchDatasets({ url: '/datasets', params: { page } })
setPage(getPage() + 1)
setIsNoMore(!has_more)
const newList = [...(datasets || []), ...data.filter(item => item.indexing_technique || item.provider === 'external')]
setDataSets(newList)
setLoaded(true)
if (!selected.find(item => !item.name))
return { list: [] }
const newSelected = produce(selected, (draft) => {
selected.forEach((item, index) => {
if (!item.name) { // not fetched database
const newItem = newList.find(i => i.id === item.id)
if (newItem)
draft[index] = newItem
}
})
})
setSelected(newSelected)
}
return { list: [] }
},
{
target: listRef,
isNoMore: () => {
return isNoMore
},
reloadDeps: [isNoMore],
},
)
const toggleSelect = (dataSet: DataSet) => {
const isSelected = selected.some(item => item.id === dataSet.id)
if (isSelected) {
setSelected(selected.filter(item => item.id !== dataSet.id))
}
else {
if (canSelectMulti)
setSelected([...selected, dataSet])
else
setSelected([dataSet])
}
}
const handleSelect = () => {
onSelect(selected)
}
return (
<Modal
isShow={isShow}
onClose={onClose}
className='w-[400px]'
title={t('appDebug.feature.dataSet.selectTitle')}
>
{!loaded && (
<div className='flex h-[200px]'>
<Loading type='area' />
</div>
)}
{(loaded && hasNoData) && (
<div className='mt-6 flex h-[128px] items-center justify-center space-x-1 rounded-lg border text-[13px]'
style={{
background: 'rgba(0, 0, 0, 0.02)',
borderColor: 'rgba(0, 0, 0, 0.02',
}}
>
<span className='text-text-tertiary'>{t('appDebug.feature.dataSet.noDataSet')}</span>
<Link href="/datasets/create" className='font-normal text-text-accent'>{t('appDebug.feature.dataSet.toCreate')}</Link>
</div>
)}
{datasets && datasets?.length > 0 && (
<>
<div ref={listRef} className='mt-7 max-h-[286px] space-y-1 overflow-y-auto'>
{datasets.map(item => (
<div
key={item.id}
className={cn(
'flex h-10 cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 shadow-xs hover:border-components-panel-border hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm',
selected.some(i => i.id === item.id) && 'border-[1.5px] border-components-option-card-option-selected-border bg-state-accent-hover shadow-xs hover:border-components-option-card-option-selected-border hover:bg-state-accent-hover hover:shadow-xs',
!item.embedding_available && 'hover:border-components-panel-border-subtle hover:bg-components-panel-on-panel-item-bg hover:shadow-xs',
)}
onClick={() => {
if (!item.embedding_available)
return
toggleSelect(item)
}}
>
<div className='mr-1 flex items-center overflow-hidden'>
<div className={cn('mr-2', !item.embedding_available && 'opacity-30')}>
<TypeIcon type="upload_file" size='md' />
</div>
<div className={cn('max-w-[200px] truncate text-[13px] font-medium text-text-secondary', !item.embedding_available && '!max-w-[120px] opacity-30')}>{item.name}</div>
{!item.embedding_available && (
<span className='ml-1 shrink-0 rounded-md border border-divider-deep px-1 text-xs font-normal leading-[18px] text-text-tertiary'>{t('dataset.unavailable')}</span>
)}
</div>
{
item.indexing_technique && (
<Badge
className='shrink-0'
text={formatIndexingTechniqueAndMethod(item.indexing_technique, item.retrieval_model_dict?.search_method)}
/>
)
}
{
item.provider === 'external' && (
<Badge className='shrink-0' text={t('dataset.externalTag')} />
)
}
</div>
))}
</div>
</>
)}
{loaded && (
<div className='mt-8 flex items-center justify-between'>
<div className='text-sm font-medium text-text-secondary'>
{selected.length > 0 && `${selected.length} ${t('appDebug.feature.dataSet.selected')}`}
</div>
<div className='flex space-x-2'>
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
<Button variant='primary' onClick={handleSelect} disabled={hasNoData}>{t('common.operation.add')}</Button>
</div>
</div>
)}
</Modal>
)
}
export default React.memo(SelectDataSet)

View File

@@ -0,0 +1,387 @@
import type { FC } from 'react'
import { useRef, useState } from 'react'
import { useMount } from 'ahooks'
import { useTranslation } from 'react-i18next'
import { isEqual } from 'lodash-es'
import { RiCloseLine } from '@remixicon/react'
import { BookOpenIcon } from '@heroicons/react/24/outline'
import { ApiConnectionMod } from '@/app/components/base/icons/src/vender/solid/development'
import cn from '@/utils/classnames'
import IndexMethodRadio from '@/app/components/datasets/settings/index-method-radio'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Textarea from '@/app/components/base/textarea'
import { type DataSet, DatasetPermission } from '@/models/datasets'
import { useToastContext } from '@/app/components/base/toast'
import { updateDatasetSetting } from '@/service/datasets'
import { useAppContext } from '@/context/app-context'
import { useModalContext } from '@/context/modal-context'
import type { RetrievalConfig } from '@/types/app'
import RetrievalSettings from '@/app/components/datasets/external-knowledge-base/create/RetrievalSettings'
import RetrievalMethodConfig from '@/app/components/datasets/common/retrieval-method-config'
import EconomicalRetrievalMethodConfig from '@/app/components/datasets/common/economical-retrieval-method-config'
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
import { AlertTriangle } from '@/app/components/base/icons/src/vender/solid/alertsAndFeedback'
import PermissionSelector from '@/app/components/datasets/settings/permission-selector'
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
import {
useModelList,
useModelListAndDefaultModelAndCurrentProviderAndModel,
} from '@/app/components/header/account-setting/model-provider-page/hooks'
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { fetchMembers } from '@/service/common'
import type { Member } from '@/models/common'
type SettingsModalProps = {
currentDataset: DataSet
onCancel: () => void
onSave: (newDataset: DataSet) => void
}
const rowClass = `
flex justify-between py-4 flex-wrap gap-y-2
`
const labelClass = `
flex w-[168px] shrink-0
`
const SettingsModal: FC<SettingsModalProps> = ({
currentDataset,
onCancel,
onSave,
}) => {
const { data: embeddingsModelList } = useModelList(ModelTypeEnum.textEmbedding)
const {
modelList: rerankModelList,
defaultModel: rerankDefaultModel,
currentModel: isRerankDefaultModelValid,
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
const { t } = useTranslation()
const { notify } = useToastContext()
const ref = useRef(null)
const isExternal = currentDataset.provider === 'external'
const [topK, setTopK] = useState(currentDataset?.external_retrieval_model.top_k ?? 2)
const [scoreThreshold, setScoreThreshold] = useState(currentDataset?.external_retrieval_model.score_threshold ?? 0.5)
const [scoreThresholdEnabled, setScoreThresholdEnabled] = useState(currentDataset?.external_retrieval_model.score_threshold_enabled ?? false)
const { setShowAccountSettingModal } = useModalContext()
const [loading, setLoading] = useState(false)
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const [localeCurrentDataset, setLocaleCurrentDataset] = useState({ ...currentDataset })
const [selectedMemberIDs, setSelectedMemberIDs] = useState<string[]>(currentDataset.partial_member_list || [])
const [memberList, setMemberList] = useState<Member[]>([])
const [indexMethod, setIndexMethod] = useState(currentDataset.indexing_technique)
const [retrievalConfig, setRetrievalConfig] = useState(localeCurrentDataset?.retrieval_model_dict as RetrievalConfig)
const handleValueChange = (type: string, value: string) => {
setLocaleCurrentDataset({ ...localeCurrentDataset, [type]: value })
}
const [isHideChangedTip, setIsHideChangedTip] = useState(false)
const isRetrievalChanged = !isEqual(retrievalConfig, localeCurrentDataset?.retrieval_model_dict) || indexMethod !== localeCurrentDataset?.indexing_technique
const handleSettingsChange = (data: { top_k?: number; score_threshold?: number; score_threshold_enabled?: boolean }) => {
if (data.top_k !== undefined)
setTopK(data.top_k)
if (data.score_threshold !== undefined)
setScoreThreshold(data.score_threshold)
if (data.score_threshold_enabled !== undefined)
setScoreThresholdEnabled(data.score_threshold_enabled)
}
const handleSave = async () => {
if (loading)
return
if (!localeCurrentDataset.name?.trim()) {
notify({ type: 'error', message: t('datasetSettings.form.nameError') })
return
}
if (
!isReRankModelSelected({
rerankModelList,
retrievalConfig,
indexMethod,
})
) {
notify({ type: 'error', message: t('appDebug.datasetConfig.rerankModelRequired') })
return
}
try {
setLoading(true)
const { id, name, description, permission } = localeCurrentDataset
const requestParams = {
datasetId: id,
body: {
name,
description,
permission,
indexing_technique: indexMethod,
retrieval_model: {
...retrievalConfig,
score_threshold: retrievalConfig.score_threshold_enabled ? retrievalConfig.score_threshold : 0,
},
embedding_model: localeCurrentDataset.embedding_model,
embedding_model_provider: localeCurrentDataset.embedding_model_provider,
...(isExternal && {
external_knowledge_id: currentDataset!.external_knowledge_info.external_knowledge_id,
external_knowledge_api_id: currentDataset!.external_knowledge_info.external_knowledge_api_id,
external_retrieval_model: {
top_k: topK,
score_threshold: scoreThreshold,
score_threshold_enabled: scoreThresholdEnabled,
},
}),
},
} as any
if (permission === DatasetPermission.partialMembers) {
requestParams.body.partial_member_list = selectedMemberIDs.map((id) => {
return {
user_id: id,
role: memberList.find(member => member.id === id)?.role,
}
})
}
await updateDatasetSetting(requestParams)
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
onSave({
...localeCurrentDataset,
indexing_technique: indexMethod,
retrieval_model_dict: retrievalConfig,
})
}
catch (e) {
notify({ type: 'error', message: t('common.actionMsg.modifiedUnsuccessfully') })
}
finally {
setLoading(false)
}
}
const getMembers = async () => {
const { accounts } = await fetchMembers({ url: '/workspaces/current/members', params: {} })
if (!accounts)
setMemberList([])
else
setMemberList(accounts)
}
useMount(() => {
getMembers()
})
return (
<div
className='flex w-full flex-col overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-xl'
style={{
height: 'calc(100vh - 72px)',
}}
ref={ref}
>
<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('datasetSettings.title')}</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='overflow-y-auto border-b border-divider-regular p-6 pb-[68px] pt-5'>
<div className={cn(rowClass, 'items-center')}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.name')}</div>
</div>
<Input
value={localeCurrentDataset.name}
onChange={e => handleValueChange('name', e.target.value)}
className='block h-9'
placeholder={t('datasetSettings.form.namePlaceholder') || ''}
/>
</div>
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.desc')}</div>
</div>
<div className='w-full'>
<Textarea
value={localeCurrentDataset.description || ''}
onChange={e => handleValueChange('description', e.target.value)}
className='resize-none'
placeholder={t('datasetSettings.form.descPlaceholder') || ''}
/>
<a className='mt-2 flex h-[18px] items-center px-3 text-xs text-text-tertiary' href="https://docs.dify.ai/features/datasets#how-to-write-a-good-dataset-description" target='_blank' rel='noopener noreferrer'>
<BookOpenIcon className='mr-1 h-[18px] w-3' />
{t('datasetSettings.form.descWrite')}
</a>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.permissions')}</div>
</div>
<div className='w-full'>
<PermissionSelector
disabled={!localeCurrentDataset?.embedding_available || isCurrentWorkspaceDatasetOperator}
permission={localeCurrentDataset.permission}
value={selectedMemberIDs}
onChange={v => handleValueChange('permission', v!)}
onMemberSelect={setSelectedMemberIDs}
memberList={memberList}
/>
</div>
</div>
{currentDataset && currentDataset.indexing_technique && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.indexMethod')}</div>
</div>
<div className='grow'>
<IndexMethodRadio
disable={!localeCurrentDataset?.embedding_available}
value={indexMethod}
onChange={v => setIndexMethod(v!)}
docForm={currentDataset.doc_form}
currentValue={currentDataset.indexing_technique}
/>
</div>
</div>
)}
{indexMethod === 'high_quality' && (
<div className={cn(rowClass)}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.embeddingModel')}</div>
</div>
<div className='w-full'>
<div className='h-8 w-full rounded-lg bg-components-input-bg-normal opacity-60'>
<ModelSelector
readonly
defaultModel={{
provider: localeCurrentDataset.embedding_model_provider,
model: localeCurrentDataset.embedding_model,
}}
modelList={embeddingsModelList}
/>
</div>
<div className='mt-2 w-full text-xs leading-6 text-text-tertiary'>
{t('datasetSettings.form.embeddingModelTip')}
<span className='cursor-pointer text-text-accent' onClick={() => setShowAccountSettingModal({ payload: 'provider' })}>{t('datasetSettings.form.embeddingModelTipLink')}</span>
</div>
</div>
</div>
)}
{/* Retrieval Method Config */}
{currentDataset?.provider === 'external'
? <>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
</div>
<RetrievalSettings
topK={topK}
scoreThreshold={scoreThreshold}
scoreThresholdEnabled={scoreThresholdEnabled}
onChange={handleSettingsChange}
isInRetrievalSetting={true}
/>
</div>
<div className={rowClass}><Divider /></div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeAPI')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<ApiConnectionMod className='h-4 w-4 text-text-secondary' />
<div className='system-sm-medium overflow-hidden text-ellipsis text-text-secondary'>
{currentDataset?.external_knowledge_info.external_knowledge_api_name}
</div>
<div className='system-xs-regular text-text-tertiary'>·</div>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_api_endpoint}</div>
</div>
</div>
</div>
<div className={rowClass}>
<div className={labelClass}>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.externalKnowledgeID')}</div>
</div>
<div className='w-full max-w-[480px]'>
<div className='flex h-full items-center gap-1 rounded-lg bg-components-input-bg-normal px-3 py-2'>
<div className='system-xs-regular text-text-tertiary'>{currentDataset?.external_knowledge_info.external_knowledge_id}</div>
</div>
</div>
</div>
<div className={rowClass}><Divider /></div>
</>
: <div className={rowClass}>
<div className={cn(labelClass, 'w-auto min-w-[168px]')}>
<div>
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
<a target='_blank' rel='noopener noreferrer' href='https://docs.dify.ai/guides/knowledge-base/create-knowledge-and-upload-documents#id-4-retrieval-settings' className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
{t('datasetSettings.form.retrievalSetting.description')}
</div>
</div>
</div>
<div>
{indexMethod === 'high_quality'
? (
<RetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)
: (
<EconomicalRetrievalMethodConfig
value={retrievalConfig}
onChange={setRetrievalConfig}
/>
)}
</div>
</div>}
</div>
{isRetrievalChanged && !isHideChangedTip && (
<div className='absolute bottom-[76px] left-[30px] right-[30px] z-10 flex h-10 items-center justify-between rounded-lg border border-[#FEF0C7] bg-[#FFFAEB] px-3 shadow-lg'>
<div className='flex items-center'>
<AlertTriangle className='mr-1 h-3 w-3 text-[#F79009]' />
<div className='text-xs font-medium leading-[18px] text-gray-700'>{t('appDebug.datasetConfig.retrieveChangeTip')}</div>
</div>
<div className='cursor-pointer p-1' onClick={(e) => {
setIsHideChangedTip(true)
e.stopPropagation()
e.nativeEvent.stopImmediatePropagation()
}}>
<RiCloseLine className='h-4 w-4 text-gray-500' />
</div>
</div>
)}
<div
className='sticky bottom-0 z-[5] flex w-full justify-end border-t border-divider-regular bg-background-section px-6 py-4'
>
<Button
onClick={onCancel}
className='mr-2'
>
{t('common.operation.cancel')}
</Button>
<Button
variant='primary'
disabled={loading}
onClick={handleSave}
>
{t('common.operation.save')}
</Button>
</div>
</div>
)
}
export default SettingsModal

View File

@@ -0,0 +1,33 @@
'use client'
import type { FC } from 'react'
import React from 'react'
export type ITypeIconProps = {
type: 'upload_file'
size?: 'md' | 'lg'
}
// data_source_type: current only support upload_file
const Icon = ({ type, size = 'lg' }: ITypeIconProps) => {
const len = size === 'lg' ? 32 : 24
const iconMap = {
upload_file: (
<svg width={len} height={len} viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="0.25" y="0.25" width="31.5" height="31.5" rx="7.75" fill="#F5F8FF" />
<path fillRule="evenodd" clipRule="evenodd" d="M8.66669 12.1078C8.66668 11.7564 8.66667 11.4532 8.68707 11.2035C8.7086 10.9399 8.75615 10.6778 8.88468 10.4255C9.07642 10.0492 9.38238 9.74322 9.75871 9.55147C10.011 9.42294 10.2731 9.3754 10.5367 9.35387C10.7864 9.33346 11.0896 9.33347 11.441 9.33349L14.0978 9.33341C14.4935 9.33289 14.8415 9.33243 15.1615 9.4428C15.4417 9.53946 15.697 9.69722 15.9087 9.90465C16.1506 10.1415 16.3058 10.4529 16.4823 10.8071L17.0786 12H19.4942C20.0309 12 20.4738 12 20.8346 12.0295C21.2093 12.0601 21.5538 12.1258 21.8773 12.2907C22.3791 12.5463 22.787 12.9543 23.0427 13.456C23.2076 13.7796 23.2733 14.1241 23.3039 14.4988C23.3334 14.8596 23.3334 15.3025 23.3334 15.8391V18.8276C23.3334 19.3642 23.3334 19.8071 23.3039 20.1679C23.2733 20.5426 23.2076 20.8871 23.0427 21.2107C22.787 21.7124 22.3791 22.1204 21.8773 22.376C21.5538 22.5409 21.2093 22.6066 20.8346 22.6372C20.4738 22.6667 20.0309 22.6667 19.4942 22.6667H12.5058C11.9692 22.6667 11.5263 22.6667 11.1655 22.6372C10.7907 22.6066 10.4463 22.5409 10.1227 22.376C9.62095 22.1204 9.213 21.7124 8.95734 21.2107C8.79248 20.8871 8.72677 20.5426 8.69615 20.1679C8.66667 19.8071 8.66668 19.3642 8.66669 18.8276V12.1078ZM14.0149 10.6668C14.5418 10.6668 14.6463 10.6755 14.7267 10.7033C14.8201 10.7355 14.9052 10.7881 14.9758 10.8572C15.0366 10.9167 15.0911 11.0063 15.3267 11.4776L15.5879 12L10.0001 12C10.0004 11.69 10.0024 11.4781 10.016 11.312C10.0308 11.1309 10.0559 11.0638 10.0727 11.0308C10.1366 10.9054 10.2386 10.8034 10.364 10.7395C10.397 10.7227 10.4641 10.6976 10.6452 10.6828C10.8341 10.6673 11.0823 10.6668 11.4667 10.6668H14.0149Z" fill="#444CE7" />
<rect x="0.25" y="0.25" width="31.5" height="31.5" rx="7.75" stroke="#E0EAFF" strokeWidth="0.5" />
</svg>
),
}
return iconMap[type]
}
const TypeIcon: FC<ITypeIconProps> = ({
type,
size = 'lg',
}) => {
return (
<Icon type={type} size={size} ></Icon>
)
}
export default React.memo(TypeIcon)

View File

@@ -0,0 +1,108 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import ConfigContext from '@/context/debug-configuration'
import Input from '@/app/components/base/input'
import Select from '@/app/components/base/select'
import Textarea from '@/app/components/base/textarea'
import { DEFAULT_VALUE_MAX_LEN } from '@/config'
import type { Inputs } from '@/models/debug'
import cn from '@/utils/classnames'
type Props = {
inputs: Inputs
}
const ChatUserInput = ({
inputs,
}: Props) => {
const { t } = useTranslation()
const { modelConfig, setInputs } = useContext(ConfigContext)
const promptVariables = modelConfig.configs.prompt_variables.filter(({ key, name }) => {
return key && key?.trim() && name && name?.trim()
})
const promptVariableObj = (() => {
const obj: Record<string, boolean> = {}
promptVariables.forEach((input) => {
obj[input.key] = true
})
return obj
})()
const handleInputValueChange = (key: string, value: string) => {
if (!(key in promptVariableObj))
return
const newInputs = { ...inputs }
promptVariables.forEach((input) => {
if (input.key === key)
newInputs[key] = value
})
setInputs(newInputs)
}
if (!promptVariables.length)
return null
return (
<div className={cn('z-[1] rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg shadow-xs')}>
<div className='px-4 pb-4 pt-3'>
{promptVariables.map(({ key, name, type, options, max_length, required }, index) => (
<div
key={key}
className='mb-4 last-of-type:mb-0'
>
<div>
<div className='system-sm-semibold mb-1 flex h-6 items-center gap-1 text-text-secondary'>
<div className='truncate'>{name || key}</div>
{!required && <span className='system-xs-regular text-text-tertiary'>{t('workflow.panel.optional')}</span>}
</div>
<div className='grow'>
{type === 'string' && (
<Input
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
{type === 'paragraph' && (
<Textarea
className='h-[120px] grow'
placeholder={name}
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
/>
)}
{type === 'select' && (
<Select
className='w-full'
defaultValue={inputs[key] as string}
onSelect={(i) => { handleInputValueChange(key, i.value as string) }}
items={(options || []).map(i => ({ name: i, value: i }))}
allowSearch={false}
/>
)}
{type === 'number' && (
<Input
type='number'
value={inputs[key] ? `${inputs[key]}` : ''}
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
placeholder={name}
autoFocus={index === 0}
maxLength={max_length || DEFAULT_VALUE_MAX_LEN}
/>
)}
</div>
</div>
</div>
))}
</div>
</div>
)
}
export default ChatUserInput

View File

@@ -0,0 +1,160 @@
import type { FC } from 'react'
import {
memo,
useCallback,
useMemo,
} from 'react'
import type { ModelAndParameter } from '../types'
import {
APP_CHAT_WITH_MULTIPLE_MODEL,
APP_CHAT_WITH_MULTIPLE_MODEL_RESTART,
} from '../types'
import {
useConfigFromDebugContext,
useFormattingChangedSubscription,
} from '../hooks'
import Chat from '@/app/components/base/chat/chat'
import { useChat } from '@/app/components/base/chat/chat/hooks'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import type { ChatConfig, OnSend } from '@/app/components/base/chat/types'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import { useProviderContext } from '@/context/provider-context'
import {
fetchConversationMessages,
fetchSuggestedQuestions,
stopChatMessageResponding,
} from '@/service/debug'
import Avatar from '@/app/components/base/avatar'
import { useAppContext } from '@/context/app-context'
import { ModelFeatureEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
import { useFeatures } from '@/app/components/base/features/hooks'
import type { InputForm } from '@/app/components/base/chat/chat/type'
import { getLastAnswer } from '@/app/components/base/chat/utils'
import { canFindTool } from '@/utils'
type ChatItemProps = {
modelAndParameter: ModelAndParameter
}
const ChatItem: FC<ChatItemProps> = ({
modelAndParameter,
}) => {
const { userProfile } = useAppContext()
const {
modelConfig,
appId,
inputs,
collectionList,
} = useDebugConfigurationContext()
const { textGenerationModelList } = useProviderContext()
const features = useFeatures(s => s.features)
const configTemplate = useConfigFromDebugContext()
const config = useMemo(() => {
return {
...configTemplate,
more_like_this: features.moreLikeThis,
opening_statement: features.opening?.enabled ? (features.opening?.opening_statement || '') : '',
suggested_questions: features.opening?.enabled ? (features.opening?.suggested_questions || []) : [],
sensitive_word_avoidance: features.moderation,
speech_to_text: features.speech2text,
text_to_speech: features.text2speech,
file_upload: features.file,
suggested_questions_after_answer: features.suggested,
retriever_resource: features.citation,
annotation_reply: features.annotationReply,
} as ChatConfig
}, [configTemplate, features])
const inputsForm = useMemo(() => {
return modelConfig.configs.prompt_variables.filter(item => item.type !== 'api').map(item => ({ ...item, label: item.name, variable: item.key })) as InputForm[]
}, [modelConfig.configs.prompt_variables])
const {
chatList,
isResponding,
handleSend,
suggestedQuestions,
handleRestart,
} = useChat(
config,
{
inputs,
inputsForm,
},
[],
taskId => stopChatMessageResponding(appId, taskId),
)
useFormattingChangedSubscription(chatList)
const doSend: OnSend = useCallback((message, files) => {
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
const currentModel = currentProvider?.models.find(model => model.model === modelAndParameter.model)
const supportVision = currentModel?.features?.includes(ModelFeatureEnum.vision)
const configData = {
...config,
model: {
provider: modelAndParameter.provider,
name: modelAndParameter.model,
mode: currentModel?.model_properties.mode,
completion_params: modelAndParameter.parameters,
},
}
const data: any = {
query: message,
inputs,
model_config: configData,
parent_message_id: getLastAnswer(chatList)?.id || null,
}
if ((config.file_upload as any).enabled && files?.length && supportVision)
data.files = files
handleSend(
`apps/${appId}/chat-messages`,
data,
{
onGetConversationMessages: (conversationId, getAbortController) => fetchConversationMessages(appId, conversationId, getAbortController),
onGetSuggestedQuestions: (responseItemId, getAbortController) => fetchSuggestedQuestions(appId, responseItemId, getAbortController),
},
)
}, [appId, chatList, config, handleSend, inputs, modelAndParameter.model, modelAndParameter.parameters, modelAndParameter.provider, textGenerationModelList])
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL)
doSend(v.payload.message, v.payload.files)
if (v.type === APP_CHAT_WITH_MULTIPLE_MODEL_RESTART)
handleRestart()
})
const allToolIcons = useMemo(() => {
const icons: Record<string, any> = {}
modelConfig.agentConfig.tools?.forEach((item: any) => {
icons[item.tool_name] = collectionList.find((collection: any) => canFindTool(collection.id, item.provider_id))?.icon
})
return icons
}, [collectionList, modelConfig.agentConfig.tools])
if (!chatList.length)
return null
return (
<Chat
config={config}
chatList={chatList}
isResponding={isResponding}
noChatInput
noStopResponding
chatContainerClassName='p-4'
chatFooterClassName='p-4 pb-0'
suggestedQuestions={suggestedQuestions}
onSend={doSend}
showPromptLog
questionIcon={<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={40} />}
allToolIcons={allToolIcons}
hideLogModal
noSpacing
/>
)
}
export default memo(ChatItem)

View File

@@ -0,0 +1,43 @@
'use client'
import { createContext, useContext } from 'use-context-selector'
import type { ModelAndParameter } from '../types'
import { noop } from 'lodash-es'
export type DebugWithMultipleModelContextType = {
multipleModelConfigs: ModelAndParameter[]
onMultipleModelConfigsChange: (multiple: boolean, modelConfigs: ModelAndParameter[]) => void
onDebugWithMultipleModelChange: (singleModelConfig: ModelAndParameter) => void
checkCanSend?: () => boolean
}
const DebugWithMultipleModelContext = createContext<DebugWithMultipleModelContextType>({
multipleModelConfigs: [],
onMultipleModelConfigsChange: noop,
onDebugWithMultipleModelChange: noop,
})
export const useDebugWithMultipleModelContext = () => useContext(DebugWithMultipleModelContext)
type DebugWithMultipleModelContextProviderProps = {
children: React.ReactNode
} & DebugWithMultipleModelContextType
export const DebugWithMultipleModelContextProvider = ({
children,
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}: DebugWithMultipleModelContextProviderProps) => {
return (
<DebugWithMultipleModelContext.Provider value={{
onMultipleModelConfigsChange,
multipleModelConfigs,
onDebugWithMultipleModelChange,
checkCanSend,
}}>
{children}
</DebugWithMultipleModelContext.Provider>
)
}
export default DebugWithMultipleModelContext

View File

@@ -0,0 +1,129 @@
import type { CSSProperties, FC } from 'react'
import { useTranslation } from 'react-i18next'
import { memo } from 'react'
import type { ModelAndParameter } from '../types'
import ModelParameterTrigger from './model-parameter-trigger'
import ChatItem from './chat-item'
import TextGenerationItem from './text-generation-item'
import { useDebugWithMultipleModelContext } from './context'
import { useDebugConfigurationContext } from '@/context/debug-configuration'
import Dropdown from '@/app/components/base/dropdown'
import type { Item } from '@/app/components/base/dropdown'
import { useProviderContext } from '@/context/provider-context'
import { ModelStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
type DebugItemProps = {
modelAndParameter: ModelAndParameter
className?: string
style?: CSSProperties
}
const DebugItem: FC<DebugItemProps> = ({
modelAndParameter,
className,
style,
}) => {
const { t } = useTranslation()
const { mode } = useDebugConfigurationContext()
const {
multipleModelConfigs,
onMultipleModelConfigsChange,
onDebugWithMultipleModelChange,
} = useDebugWithMultipleModelContext()
const { textGenerationModelList } = useProviderContext()
const index = multipleModelConfigs.findIndex(v => v.id === modelAndParameter.id)
const currentProvider = textGenerationModelList.find(item => item.provider === modelAndParameter.provider)
const currentModel = currentProvider?.models.find(item => item.model === modelAndParameter.model)
const handleSelect = (item: Item) => {
if (item.value === 'duplicate') {
if (multipleModelConfigs.length >= 4)
return
onMultipleModelConfigsChange(
true,
[
...multipleModelConfigs.slice(0, index + 1),
{
...modelAndParameter,
id: `${Date.now()}`,
},
...multipleModelConfigs.slice(index + 1),
],
)
}
if (item.value === 'debug-as-single-model')
onDebugWithMultipleModelChange(modelAndParameter)
if (item.value === 'remove') {
onMultipleModelConfigsChange(
true,
multipleModelConfigs.filter(item => item.id !== modelAndParameter.id),
)
}
}
return (
<div
className={`flex min-w-[320px] flex-col rounded-xl bg-background-section-burn ${className}`}
style={style}
>
<div className='flex h-10 shrink-0 items-center justify-between border-b-[0.5px] border-divider-regular px-3'>
<div className='flex h-5 w-6 items-center justify-center font-medium italic text-text-tertiary'>
#{index + 1}
</div>
<ModelParameterTrigger
modelAndParameter={modelAndParameter}
/>
<Dropdown
onSelect={handleSelect}
items={[
...(
multipleModelConfigs.length <= 3
? [
{
value: 'duplicate',
text: t('appDebug.duplicateModel'),
},
]
: []
),
...(
(modelAndParameter.provider && modelAndParameter.model)
? [
{
value: 'debug-as-single-model',
text: t('appDebug.debugAsSingleModel'),
},
]
: []
),
]}
secondItems={
multipleModelConfigs.length > 2
? [
{
value: 'remove',
text: t('common.operation.remove') as string,
},
]
: undefined
}
/>
</div>
<div style={{ height: 'calc(100% - 40px)' }}>
{
(mode === 'chat' || mode === 'agent-chat') && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && (
<ChatItem modelAndParameter={modelAndParameter} />
)
}
{
mode === 'completion' && currentProvider && currentModel && currentModel.status === ModelStatusEnum.active && (
<TextGenerationItem modelAndParameter={modelAndParameter}/>
)
}
</div>
</div>
)
}
export default memo(DebugItem)

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