feat: tracing fe (#5487)

This commit is contained in:
Joel 2024-06-26 17:33:57 +08:00 committed by GitHub
parent 4e2de638af
commit 8fa6cb5e03
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 2222 additions and 14 deletions

View File

@ -1,7 +1,7 @@
import React from 'react'
import ChartView from './chartView'
import CardView from './cardView'
import { getLocaleOnServer, useTranslation as translate } from '@/i18n/server'
import TracingPanel from './tracing/panel'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
export type IDevelopProps = {
@ -11,18 +11,10 @@ export type IDevelopProps = {
const Overview = async ({
params: { appId },
}: IDevelopProps) => {
const locale = getLocaleOnServer()
/*
rename useTranslation to avoid lint error
please check: https://github.com/i18next/next-13-app-dir-i18next-example/issues/24
*/
const { t } = await translate(locale, 'app-overview')
return (
<div className="h-full px-4 sm:px-16 py-6 overflow-scroll">
<ApikeyInfoPanel />
<div className='flex flex-row items-center justify-between mb-4 text-xl text-gray-900'>
{t('overview.title')}
</div>
<TracingPanel />
<CardView appId={appId} />
<ChartView appId={appId} />
</div>

View File

@ -0,0 +1,87 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import type { PopupProps } from './config-popup'
import ConfigPopup from './config-popup'
import Button from '@/app/components/base/button'
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
const I18N_PREFIX = 'app.tracing'
type Props = {
readOnly: boolean
className?: string
hasConfigured: boolean
controlShowPopup?: number
} & PopupProps
const ConfigBtn: FC<Props> = ({
className,
hasConfigured,
controlShowPopup,
...popupProps
}) => {
const { t } = useTranslation()
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
useEffect(() => {
if (controlShowPopup)
// setOpen(!openRef.current)
setOpen(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [controlShowPopup])
if (popupProps.readOnly && !hasConfigured)
return null
const triggerContent = hasConfigured
? (
<div className={cn(className, 'p-1 rounded-md hover:bg-black/5 cursor-pointer')}>
<Settings04 className='w-4 h-4 text-gray-500' />
</div>
)
: (
<Button variant='primary'
className={cn(className, '!h-8 !px-3 select-none')}
>
<Settings04 className='mr-1 w-4 h-4' />
<span className='text-[13px]'>{t(`${I18N_PREFIX}.config`)}</span>
</Button>
)
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={{
mainAxis: 12,
crossAxis: hasConfigured ? 8 : 0,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
{triggerContent}
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<ConfigPopup {...popupProps} />
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(ConfigBtn)

View File

@ -0,0 +1,179 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import TracingIcon from './tracing-icon'
import ProviderPanel from './provider-panel'
import type { LangFuseConfig, LangSmithConfig } from './type'
import { TracingProvider } from './type'
import ProviderConfigModal from './provider-config-modal'
import Indicator from '@/app/components/header/indicator'
import Switch from '@/app/components/base/switch'
import TooltipPlus from '@/app/components/base/tooltip-plus'
const I18N_PREFIX = 'app.tracing'
export type PopupProps = {
appId: string
readOnly: boolean
enabled: boolean
onStatusChange: (enabled: boolean) => void
chosenProvider: TracingProvider | null
onChooseProvider: (provider: TracingProvider) => void
langSmithConfig: LangSmithConfig | null
langFuseConfig: LangFuseConfig | null
onConfigUpdated: (provider: TracingProvider, payload: LangSmithConfig | LangFuseConfig) => void
onConfigRemoved: (provider: TracingProvider) => void
}
const ConfigPopup: FC<PopupProps> = ({
appId,
readOnly,
enabled,
onStatusChange,
chosenProvider,
onChooseProvider,
langSmithConfig,
langFuseConfig,
onConfigUpdated,
onConfigRemoved,
}) => {
const { t } = useTranslation()
const [currentProvider, setCurrentProvider] = useState<TracingProvider | null>(TracingProvider.langfuse)
const [isShowConfigModal, {
setTrue: showConfigModal,
setFalse: hideConfigModal,
}] = useBoolean(false)
const handleOnConfig = useCallback((provider: TracingProvider) => {
return () => {
setCurrentProvider(provider)
showConfigModal()
}
}, [showConfigModal])
const handleOnChoose = useCallback((provider: TracingProvider) => {
return () => {
onChooseProvider(provider)
}
}, [onChooseProvider])
const handleConfigUpdated = useCallback((payload: LangSmithConfig | LangFuseConfig) => {
onConfigUpdated(currentProvider!, payload)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigUpdated])
const handleConfigRemoved = useCallback(() => {
onConfigRemoved(currentProvider!)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigRemoved])
const providerAllConfigured = langSmithConfig && langFuseConfig
const providerAllNotConfigured = !langSmithConfig && !langFuseConfig
const switchContent = (
<Switch
className='ml-3'
defaultValue={enabled}
onChange={onStatusChange}
size='l'
disabled={providerAllNotConfigured}
/>
)
const langSmithPanel = (
<ProviderPanel
type={TracingProvider.langSmith}
readOnly={readOnly}
hasConfigured={!!langSmithConfig}
onConfig={handleOnConfig(TracingProvider.langSmith)}
isChosen={chosenProvider === TracingProvider.langSmith}
onChoose={handleOnChoose(TracingProvider.langSmith)}
/>
)
const langfusePanel = (
<ProviderPanel
type={TracingProvider.langfuse}
readOnly={readOnly}
hasConfigured={!!langFuseConfig}
onConfig={handleOnConfig(TracingProvider.langfuse)}
isChosen={chosenProvider === TracingProvider.langfuse}
onChoose={handleOnChoose(TracingProvider.langfuse)}
/>
)
return (
<div className='w-[420px] p-4 rounded-2xl bg-white border-[0.5px] border-black/5 shadow-lg'>
<div className='flex justify-between items-center'>
<div className='flex items-center'>
<TracingIcon size='md' className='mr-2' />
<div className='leading-[120%] text-[18px] font-semibold text-gray-900'>{t(`${I18N_PREFIX}.tracing`)}</div>
</div>
<div className='flex items-center'>
<Indicator color={enabled ? 'green' : 'gray'} />
<div className='ml-1.5 text-xs font-semibold text-gray-500 uppercase'>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
</div>
{!readOnly && (
<>
{providerAllNotConfigured
? (
<TooltipPlus
popupContent={t(`${I18N_PREFIX}.disabledTip`)}
>
{switchContent}
</TooltipPlus>
)
: switchContent}
</>
)}
</div>
</div>
<div className='mt-2 leading-4 text-xs font-normal text-gray-500'>
{t(`${I18N_PREFIX}.tracingDescription`)}
</div>
<div className='mt-3 h-px bg-gray-100'></div>
<div className='mt-3'>
{(providerAllConfigured || providerAllNotConfigured)
? (
<>
<div className='leading-4 text-xs font-medium text-gray-500 uppercase'>{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`)}</div>
<div className='mt-2 space-y-2'>
{langSmithPanel}
{langfusePanel}
</div>
</>
)
: (
<>
<div className='leading-4 text-xs font-medium text-gray-500 uppercase'>{t(`${I18N_PREFIX}.configProviderTitle.configured`)}</div>
<div className='mt-2'>
{langSmithConfig ? langSmithPanel : langfusePanel}
</div>
<div className='mt-3 leading-4 text-xs font-medium text-gray-500 uppercase'>{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`)}</div>
<div className='mt-2'>
{!langSmithConfig ? langSmithPanel : langfusePanel}
</div>
</>
)}
</div>
{isShowConfigModal && (
<ProviderConfigModal
appId={appId}
type={currentProvider!}
payload={currentProvider === TracingProvider.langSmith ? langSmithConfig : langFuseConfig}
onCancel={hideConfigModal}
onSaved={handleConfigUpdated}
onChosen={onChooseProvider}
onRemoved={handleConfigRemoved}
/>
)}
</div>
)
}
export default React.memo(ConfigPopup)

View File

@ -0,0 +1,6 @@
import { TracingProvider } from './type'
export const docURL = {
[TracingProvider.langSmith]: 'https://docs.smith.langchain.com/',
[TracingProvider.langfuse]: 'https://docs.langfuse.com',
}

View File

@ -0,0 +1,41 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
type Props = {
className?: string
label: string
labelClassName?: string
value: string | number
onChange: (value: string) => void
isRequired?: boolean
placeholder?: string
}
const Field: FC<Props> = ({
className,
label,
labelClassName,
value,
onChange,
isRequired = false,
placeholder = '',
}) => {
return (
<div className={cn(className)}>
<div className='flex py-[7px]'>
<div className={cn(labelClassName, 'flex items-center h-[18px] text-[13px] font-medium text-gray-900')}>{label} </div>
{isRequired && <span className='ml-0.5 text-xs font-semibold text-[#D92D20]'>*</span>}
</div>
<input
type='text'
value={value}
onChange={e => onChange(e.target.value)}
className='flex h-9 w-full py-1 px-2 rounded-lg text-xs leading-normal bg-gray-100 caret-primary-600 hover:bg-gray-100 focus:ring-1 focus:ring-inset focus:ring-gray-200 focus-visible:outline-none focus:bg-gray-50 placeholder:text-gray-400'
placeholder={placeholder}
/>
</div>
)
}
export default React.memo(Field)

View File

@ -0,0 +1,227 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { usePathname } from 'next/navigation'
import { useBoolean } from 'ahooks'
import type { LangFuseConfig, LangSmithConfig } from './type'
import { TracingProvider } from './type'
import TracingIcon from './tracing-icon'
import ToggleExpandBtn from './toggle-fold-btn'
import ConfigButton from './config-button'
import { LangfuseIcon, LangsmithIcon } from '@/app/components/base/icons/src/public/tracing'
import Indicator from '@/app/components/header/indicator'
import { fetchTracingConfig as doFetchTracingConfig, fetchTracingStatus, updateTracingStatus } from '@/service/apps'
import type { TracingStatus } from '@/models/app'
import Toast from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import Loading from '@/app/components/base/loading'
const I18N_PREFIX = 'app.tracing'
const Title = ({
className,
}: {
className?: string
}) => {
const { t } = useTranslation()
return (
<div className={cn(className, 'flex items-center text-lg font-semibold text-gray-900')}>
{t('common.appMenus.overview')}
</div>
)
}
const Panel: FC = () => {
const { t } = useTranslation()
const pathname = usePathname()
const matched = pathname.match(/\/app\/([^/]+)/)
const appId = (matched?.length && matched[1]) ? matched[1] : ''
const { isCurrentWorkspaceEditor } = useAppContext()
const readOnly = !isCurrentWorkspaceEditor
const [isLoaded, {
setTrue: setLoaded,
}] = useBoolean(false)
const [tracingStatus, setTracingStatus] = useState<TracingStatus | null>(null)
const enabled = tracingStatus?.enabled || false
const handleTracingStatusChange = async (tracingStatus: TracingStatus, noToast?: boolean) => {
await updateTracingStatus({ appId, body: tracingStatus })
setTracingStatus(tracingStatus)
if (!noToast) {
Toast.notify({
type: 'success',
message: t('common.api.success'),
})
}
}
const handleTracingEnabledChange = (enabled: boolean) => {
handleTracingStatusChange({
tracing_provider: tracingStatus?.tracing_provider || null,
enabled,
})
}
const handleChooseProvider = (provider: TracingProvider) => {
handleTracingStatusChange({
tracing_provider: provider,
enabled: true,
})
}
const inUseTracingProvider: TracingProvider | null = tracingStatus?.tracing_provider || null
const InUseProviderIcon = inUseTracingProvider === TracingProvider.langSmith ? LangsmithIcon : LangfuseIcon
const [langSmithConfig, setLangSmithConfig] = useState<LangSmithConfig | null>(null)
const [langFuseConfig, setLangFuseConfig] = useState<LangFuseConfig | null>(null)
const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig)
const fetchTracingConfig = async () => {
const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith })
if (!langSmithHasNotConfig)
setLangSmithConfig(langSmithConfig as LangSmithConfig)
const { tracing_config: langFuseConfig, has_not_configured: langFuseHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langfuse })
if (!langFuseHasNotConfig)
setLangFuseConfig(langFuseConfig as LangFuseConfig)
}
const handleTracingConfigUpdated = async (provider: TracingProvider) => {
// call api to hide secret key value
const { tracing_config } = await doFetchTracingConfig({ appId, provider })
if (provider === TracingProvider.langSmith)
setLangSmithConfig(tracing_config as LangSmithConfig)
else
setLangFuseConfig(tracing_config as LangFuseConfig)
}
const handleTracingConfigRemoved = (provider: TracingProvider) => {
if (provider === TracingProvider.langSmith)
setLangSmithConfig(null)
else
setLangFuseConfig(null)
if (provider === inUseTracingProvider) {
handleTracingStatusChange({
enabled: false,
tracing_provider: null,
}, true)
}
}
useEffect(() => {
(async () => {
const tracingStatus = await fetchTracingStatus({ appId })
setTracingStatus(tracingStatus)
await fetchTracingConfig()
setLoaded()
})()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const [isFold, setFold] = useState(false)
const [controlShowPopup, setControlShowPopup] = useState<number>(0)
const showPopup = useCallback(() => {
setControlShowPopup(Date.now())
}, [setControlShowPopup])
if (!isLoaded) {
return (
<div className='flex items-center justify-between mb-3'>
<Title className='h-[41px]' />
<div className='w-[200px]'>
<Loading />
</div>
</div>
)
}
if (!isFold && !hasConfiguredTracing) {
return (
<div className={cn('mb-3')}>
<Title />
<div className='mt-2 flex justify-between p-3 pr-4 items-center bg-white border-[0.5px] border-black/8 rounded-xl shadow-md'>
<div className='flex space-x-2'>
<TracingIcon size='lg' className='m-1' />
<div>
<div className='mb-0.5 leading-6 text-base font-semibold text-gray-900'>{t(`${I18N_PREFIX}.title`)}</div>
<div className='flex justify-between leading-4 text-xs font-normal text-gray-500'>
<span className='mr-2'>{t(`${I18N_PREFIX}.description`)}</span>
<div className='flex space-x-3'>
<LangsmithIcon className='h-4' />
<LangfuseIcon className='h-4' />
</div>
</div>
</div>
</div>
<div className='flex items-center space-x-1'>
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured={false}
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
/>
<ToggleExpandBtn isFold={isFold} onFoldChange={setFold} />
</div>
</div>
</div>
)
}
return (
<div className={cn('mb-3 flex justify-between items-center cursor-pointer')} onClick={showPopup}>
<Title className='h-[41px]' />
<div className='flex items-center p-2 rounded-xl border-[0.5px] border-gray-200 shadow-xs hover:bg-gray-100'>
{!inUseTracingProvider
? <>
<TracingIcon size='md' className='mr-2' />
<div className='leading-5 text-sm font-semibold text-gray-700'>{t(`${I18N_PREFIX}.title`)}</div>
</>
: <InUseProviderIcon className='ml-1 h-4' />}
{hasConfiguredTracing && (
<div className='ml-4 mr-1 flex items-center'>
<Indicator color={enabled ? 'green' : 'gray'} />
<div className='ml-1.5 text-xs font-semibold text-gray-500 uppercase'>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
</div>
</div>
)}
{hasConfiguredTracing && (
<div className='ml-2 w-px h-3.5 bg-gray-200'></div>
)}
<div className='flex items-center' onClick={e => e.stopPropagation()}>
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured
className='ml-2'
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
controlShowPopup={controlShowPopup}
/>
</div>
{!hasConfiguredTracing && (
<div className='flex items-center' onClick={e => e.stopPropagation()}>
<div className='mx-2 w-px h-3.5 bg-gray-200'></div>
<ToggleExpandBtn isFold={isFold} onFoldChange={setFold} />
</div>
)}
</div>
</div>
)
}
export default React.memo(Panel)

View File

@ -0,0 +1,292 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useBoolean } from 'ahooks'
import Field from './field'
import type { LangFuseConfig, LangSmithConfig } from './type'
import { TracingProvider } from './type'
import { docURL } from './config'
import {
PortalToFollowElem,
PortalToFollowElemContent,
} from '@/app/components/base/portal-to-follow-elem'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Button from '@/app/components/base/button'
import { LinkExternal02 } from '@/app/components/base/icons/src/vender/line/general'
import ConfirmUi from '@/app/components/base/confirm'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import Toast from '@/app/components/base/toast'
type Props = {
appId: string
type: TracingProvider
payload?: LangSmithConfig | LangFuseConfig | null
onRemoved: () => void
onCancel: () => void
onSaved: (payload: LangSmithConfig | LangFuseConfig) => void
onChosen: (provider: TracingProvider) => void
}
const I18N_PREFIX = 'app.tracing.configProvider'
const langSmithConfigTemplate = {
api_key: '',
project: '',
endpoint: '',
}
const langFuseConfigTemplate = {
public_key: '',
secret_key: '',
host: '',
}
const ProviderConfigModal: FC<Props> = ({
appId,
type,
payload,
onRemoved,
onCancel,
onSaved,
onChosen,
}) => {
const { t } = useTranslation()
const isEdit = !!payload
const isAdd = !isEdit
const [isSaving, setIsSaving] = useState(false)
const [config, setConfig] = useState<LangSmithConfig | LangFuseConfig>((() => {
if (isEdit)
return payload
if (type === TracingProvider.langSmith)
return langSmithConfigTemplate
return langFuseConfigTemplate
})())
const [isShowRemoveConfirm, {
setTrue: showRemoveConfirm,
setFalse: hideRemoveConfirm,
}] = useBoolean(false)
const handleRemove = useCallback(async () => {
await removeTracingConfig({
appId,
provider: type,
})
Toast.notify({
type: 'success',
message: t('common.api.remove'),
})
onRemoved()
hideRemoveConfirm()
}, [hideRemoveConfirm, appId, type, t, onRemoved])
const handleConfigChange = useCallback((key: string) => {
return (value: string) => {
setConfig({
...config,
[key]: value,
})
}
}, [config])
const checkValid = useCallback(() => {
let errorMessage = ''
if (type === TracingProvider.langSmith) {
const postData = config as LangSmithConfig
if (!postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
}
if (type === TracingProvider.langfuse) {
const postData = config as LangFuseConfig
if (!errorMessage && !postData.secret_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.secretKey`) })
if (!errorMessage && !postData.public_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.publicKey`) })
if (!errorMessage && !postData.host)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Host' })
}
return errorMessage
}, [config, t, type])
const handleSave = useCallback(async () => {
if (isSaving)
return
const errorMessage = checkValid()
if (errorMessage) {
Toast.notify({
type: 'error',
message: errorMessage,
})
return
}
const action = isEdit ? updateTracingConfig : addTracingConfig
try {
await action({
appId,
body: {
tracing_provider: type,
tracing_config: config,
},
})
Toast.notify({
type: 'success',
message: t('common.api.success'),
})
onSaved(config)
if (isAdd)
onChosen(type)
}
finally {
setIsSaving(false)
}
}, [appId, checkValid, config, isAdd, isEdit, isSaving, onChosen, onSaved, t, type])
return (
<>
{!isShowRemoveConfirm
? (
<PortalToFollowElem open>
<PortalToFollowElemContent className='w-full h-full z-[60]'>
<div className='fixed inset-0 flex items-center justify-center bg-black/[.25]'>
<div className='mx-2 w-[640px] max-h-[calc(100vh-120px)] bg-white shadow-xl rounded-2xl overflow-y-auto'>
<div className='px-8 pt-8'>
<div className='flex justify-between items-center mb-4'>
<div className='text-xl font-semibold text-gray-900'>{t(`${I18N_PREFIX}.title`)}{t(`app.tracing.${type}.title`)}</div>
</div>
<div className='space-y-4'>
{type === TracingProvider.langSmith && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as LangSmithConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as LangSmithConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as LangSmithConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://api.smith.langchain.com'}
/>
</>
)}
{type === TracingProvider.langfuse && (
<>
<Field
label={t(`${I18N_PREFIX}.secretKey`)!}
labelClassName='!text-sm'
value={(config as LangFuseConfig).secret_key}
isRequired
onChange={handleConfigChange('secret_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.secretKey`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.publicKey`)!}
labelClassName='!text-sm'
isRequired
value={(config as LangFuseConfig).public_key}
onChange={handleConfigChange('public_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.publicKey`) })!}
/>
<Field
label='Host'
labelClassName='!text-sm'
isRequired
value={(config as LangFuseConfig).host}
onChange={handleConfigChange('host')}
placeholder='https://cloud.langfuse.com'
/>
</>
)}
</div>
<div className='my-8 flex justify-between items-center h-8'>
<a
className='flex items-center space-x-1 leading-[18px] text-xs font-normal text-[#155EEF]'
target='_blank'
href={docURL[type]}
>
<span>{t(`${I18N_PREFIX}.viewDocsLink`, { key: t(`app.tracing.${type}.title`) })}</span>
<LinkExternal02 className='w-3 h-3' />
</a>
<div className='flex items-center'>
{isEdit && (
<>
<Button
className='h-9 text-sm font-medium text-gray-700'
onClick={showRemoveConfirm}
>
<span className='text-[#D92D20]'>{t('common.operation.remove')}</span>
</Button>
<div className='mx-3 w-px h-[18px] bg-gray-200'></div>
</>
)}
<Button
className='mr-2 h-9 text-sm font-medium text-gray-700'
onClick={onCancel}
>
{t('common.operation.cancel')}
</Button>
<Button
className='h-9 text-sm font-medium'
variant='primary'
onClick={handleSave}
loading={isSaving}
>
{t(`common.operation.${isAdd ? 'saveAndEnable' : 'save'}`)}
</Button>
</div>
</div>
</div>
<div className='border-t-[0.5px] border-t-black/5'>
<div className='flex justify-center items-center py-3 bg-gray-50 text-xs text-gray-500'>
<Lock01 className='mr-1 w-3 h-3 text-gray-500' />
{t('common.modelProvider.encrypted.front')}
<a
className='text-primary-600 mx-1'
target='_blank' rel='noopener noreferrer'
href='https://pycryptodome.readthedocs.io/en/latest/src/cipher/oaep.html'
>
PKCS1_OAEP
</a>
{t('common.modelProvider.encrypted.back')}
</div>
</div>
</div>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)
: (
<ConfirmUi
isShow
onClose={hideRemoveConfirm}
type='warning'
title={t(`${I18N_PREFIX}.removeConfirmTitle`, { key: t(`app.tracing.${type}.title`) })!}
content={t(`${I18N_PREFIX}.removeConfirmContent`)}
onConfirm={handleRemove}
onCancel={hideRemoveConfirm}
/>
)}
</>
)
}
export default React.memo(ProviderConfigModal)

View File

@ -0,0 +1,77 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import cn from 'classnames'
import { TracingProvider } from './type'
import { LangfuseIconBig, LangsmithIconBig } from '@/app/components/base/icons/src/public/tracing'
import { Settings04 } from '@/app/components/base/icons/src/vender/line/general'
const I18N_PREFIX = 'app.tracing'
type Props = {
type: TracingProvider
readOnly: boolean
isChosen: boolean
onChoose: () => void
hasConfigured: boolean
onConfig: () => void
}
const getIcon = (type: TracingProvider) => {
return ({
[TracingProvider.langSmith]: LangsmithIconBig,
[TracingProvider.langfuse]: LangfuseIconBig,
})[type]
}
const ProviderPanel: FC<Props> = ({
type,
readOnly,
isChosen,
onChoose,
hasConfigured,
onConfig,
}) => {
const { t } = useTranslation()
const Icon = getIcon(type)
const handleConfigBtnClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onConfig()
}, [onConfig])
const handleChosen = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (isChosen || !hasConfigured || readOnly)
return
onChoose()
}, [hasConfigured, isChosen, onChoose, readOnly])
return (
<div
className={cn(isChosen ? 'border-primary-400' : 'border-transparent', !isChosen && hasConfigured && !readOnly && 'cursor-pointer', 'px-4 py-3 rounded-xl border-[1.5px] bg-gray-100')}
onClick={handleChosen}
>
<div className={'flex justify-between items-center space-x-1'}>
<div className='flex items-center'>
<Icon className='h-6' />
{isChosen && <div className='ml-1 flex items-center h-4 px-1 rounded-[4px] border border-primary-500 leading-4 text-xs font-medium text-primary-500 uppercase '>{t(`${I18N_PREFIX}.inUse`)}</div>}
</div>
{!readOnly && (
<div
className='flex px-2 items-center h-6 bg-white rounded-md border-[0.5px] border-gray-200 shadow-xs cursor-pointer text-gray-700 space-x-1'
onClick={handleConfigBtnClick}
>
<Settings04 className='w-3 h-3' />
<div className='text-xs font-medium'>{t(`${I18N_PREFIX}.config`)}</div>
</div>
)}
</div>
<div className='mt-2 leading-4 text-xs font-normal text-gray-500'>
{t(`${I18N_PREFIX}.${type}.description`)}
</div>
</div>
)
}
export default React.memo(ProviderPanel)

View File

@ -0,0 +1,46 @@
'use client'
import { ChevronDoubleDownIcon } from '@heroicons/react/20/solid'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import React, { useCallback } from 'react'
import TooltipPlus from '@/app/components/base/tooltip-plus'
const I18N_PREFIX = 'app.tracing'
type Props = {
isFold: boolean
onFoldChange: (isFold: boolean) => void
}
const ToggleFoldBtn: FC<Props> = ({
isFold,
onFoldChange,
}) => {
const { t } = useTranslation()
const handleFoldChange = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation()
onFoldChange(!isFold)
}, [isFold, onFoldChange])
return (
// text-[0px] to hide spacing between tooltip elements
<div className='shrink-0 cursor-pointer text-[0px]' onClick={handleFoldChange}>
<TooltipPlus
popupContent={t(`${I18N_PREFIX}.${isFold ? 'expand' : 'collapse'}`)}
hideArrow
>
{isFold && (
<div className='p-1 rounded-md text-gray-500 hover:text-gray-800 hover:bg-black/5'>
<ChevronDoubleDownIcon className='w-4 h-4' />
</div>
)}
{!isFold && (
<div className='p-2 rounded-lg text-gray-500 border-[0.5px] border-gray-200 hover:text-gray-800 hover:bg-black/5'>
<ChevronDoubleDownIcon className='w-4 h-4 transform rotate-180' />
</div>
)}
</TooltipPlus>
</div>
)
}
export default React.memo(ToggleFoldBtn)

View File

@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from 'classnames'
import { TracingIcon as Icon } from '@/app/components/base/icons/src/public/tracing'
type Props = {
className?: string
size: 'lg' | 'md'
}
const sizeClassMap = {
lg: 'w-9 h-9 p-2 rounded-[10px]',
md: 'w-6 h-6 p-1 rounded-lg',
}
const TracingIcon: FC<Props> = ({
className,
size,
}) => {
const sizeClass = sizeClassMap[size]
return (
<div className={cn(className, sizeClass, 'bg-primary-500 shadow-md')}>
<Icon className='w-full h-full' />
</div>
)
}
export default React.memo(TracingIcon)

View File

@ -0,0 +1,16 @@
export enum TracingProvider {
langSmith = 'langsmith',
langfuse = 'langfuse',
}
export type LangSmithConfig = {
api_key: string
project: string
endpoint: string
}
export type LangFuseConfig = {
public_key: string
secret_key: string
host: string
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 18 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 13 KiB

View File

@ -0,0 +1,6 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="analytics-fill">
<path id="Vector" opacity="0.6" d="M5 2.5C3.61929 2.5 2.5 3.61929 2.5 5V9.16667H6.15164C6.78293 9.16667 7.36003 9.52333 7.64235 10.088L8.33333 11.4699L10.9213 6.29399C11.0625 6.01167 11.351 5.83333 11.6667 5.83333C11.9823 5.83333 12.2708 6.01167 12.412 6.29399L13.8483 9.16667H17.5V5C17.5 3.61929 16.3807 2.5 15 2.5H5Z" fill="white"/>
<path id="Vector_2" d="M2.5 14.9999C2.5 16.3807 3.61929 17.4999 5 17.4999H15C16.3807 17.4999 17.5 16.3807 17.5 14.9999V10.8333H13.8483C13.2171 10.8333 12.64 10.4766 12.3577 9.91195L11.6667 8.53003L9.07867 13.7059C8.9375 13.9883 8.649 14.1666 8.33333 14.1666C8.01769 14.1666 7.72913 13.9883 7.58798 13.7059L6.15164 10.8333H2.5V14.9999Z" fill="white"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 818 B

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LangfuseIcon.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LangfuseIcon'
export default Icon

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LangfuseIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LangfuseIconBig'
export default Icon

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LangsmithIcon.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LangsmithIcon'
export default Icon

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './LangsmithIconBig.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'LangsmithIconBig'
export default Icon

View File

@ -0,0 +1,47 @@
{
"icon": {
"type": "element",
"isRootNode": true,
"name": "svg",
"attributes": {
"width": "20",
"height": "20",
"viewBox": "0 0 20 20",
"fill": "none",
"xmlns": "http://www.w3.org/2000/svg"
},
"children": [
{
"type": "element",
"name": "g",
"attributes": {
"id": "analytics-fill"
},
"children": [
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector",
"opacity": "0.6",
"d": "M5 2.5C3.61929 2.5 2.5 3.61929 2.5 5V9.16667H6.15164C6.78293 9.16667 7.36003 9.52333 7.64235 10.088L8.33333 11.4699L10.9213 6.29399C11.0625 6.01167 11.351 5.83333 11.6667 5.83333C11.9823 5.83333 12.2708 6.01167 12.412 6.29399L13.8483 9.16667H17.5V5C17.5 3.61929 16.3807 2.5 15 2.5H5Z",
"fill": "white"
},
"children": []
},
{
"type": "element",
"name": "path",
"attributes": {
"id": "Vector_2",
"d": "M2.5 14.9999C2.5 16.3807 3.61929 17.4999 5 17.4999H15C16.3807 17.4999 17.5 16.3807 17.5 14.9999V10.8333H13.8483C13.2171 10.8333 12.64 10.4766 12.3577 9.91195L11.6667 8.53003L9.07867 13.7059C8.9375 13.9883 8.649 14.1666 8.33333 14.1666C8.01769 14.1666 7.72913 13.9883 7.58798 13.7059L6.15164 10.8333H2.5V14.9999Z",
"fill": "white"
},
"children": []
}
]
}
]
},
"name": "TracingIcon"
}

View File

@ -0,0 +1,16 @@
// GENERATE BY script
// DON NOT EDIT IT MANUALLY
import * as React from 'react'
import data from './TracingIcon.json'
import IconBase from '@/app/components/base/icons/IconBase'
import type { IconBaseProps, IconData } from '@/app/components/base/icons/IconBase'
const Icon = React.forwardRef<React.MutableRefObject<SVGElement>, Omit<IconBaseProps, 'data'>>((
props,
ref,
) => <IconBase {...props} ref={ref} data={data as IconData} />)
Icon.displayName = 'TracingIcon'
export default Icon

View File

@ -0,0 +1,5 @@
export { default as LangfuseIconBig } from './LangfuseIconBig'
export { default as LangfuseIcon } from './LangfuseIcon'
export { default as LangsmithIconBig } from './LangsmithIconBig'
export { default as LangsmithIcon } from './LangsmithIcon'
export { default as TracingIcon } from './TracingIcon'

View File

@ -160,6 +160,10 @@ const CodeEditor: FC<Props> = ({
// lineNumbers: (num) => {
// return <div>{num}</div>
// }
// hide ambiguousCharacters warning
unicodeHighlight: {
ambiguousCharacters: false,
},
}}
onMount={handleEditorDidMount}
/>

View File

@ -85,6 +85,42 @@ const translation = {
workflow: 'Workflow',
completion: 'Completion',
},
tracing: {
title: 'Tracing app performance',
description: 'Configuring a Third-Party LLMOps provider and tracing app performance.',
config: 'Config',
collapse: 'Collapse',
expand: 'Expand',
tracing: 'Tracing',
disabled: 'Disabled',
disabledTip: 'Please config provider first',
enabled: 'In Service',
tracingDescription: 'Capture the full context of app execution, including LLM calls, context, prompts, HTTP requests, and more, to a third-party tracing platform.',
configProviderTitle: {
configured: 'Configured',
notConfigured: 'Config provider to enable tracing',
moreProvider: 'More Provider',
},
langsmith: {
title: 'LangSmith',
description: 'An all-in-one developer platform for every step of the LLM-powered application lifecycle.',
},
langfuse: {
title: 'Langfuse',
description: 'Traces, evals, prompt management and metrics to debug and improve your LLM application.',
},
inUse: 'In use',
configProvider: {
title: 'Config ',
placeholder: 'Enter your {{key}}',
project: 'Project',
publicKey: 'Public Key',
secretKey: 'Secret Key',
viewDocsLink: 'View {{key}} docs',
removeConfirmTitle: 'Remove {{key}} configuration?',
removeConfirmContent: 'The current configuration is in use, removing it will turn off the Tracing feature.',
},
},
}
export default translation

View File

@ -12,6 +12,7 @@ const translation = {
cancel: 'Cancel',
clear: 'Clear',
save: 'Save',
saveAndEnable: 'Save & Enable',
edit: 'Edit',
add: 'Add',
added: 'Added',
@ -439,7 +440,7 @@ const translation = {
latestAvailable: 'Dify {{version}} is the latest version available.',
},
appMenus: {
overview: 'Overview',
overview: 'Monitoring',
promptEng: 'Orchestrate',
apiAccess: 'API Access',
logAndAnn: 'Logs & Ann.',

View File

@ -84,6 +84,42 @@ const translation = {
workflow: '工作流',
completion: '文本生成',
},
tracing: {
title: '追踪应用性能',
description: '配置第三方 LLMOps 提供商并跟踪应用程序性能。',
config: '配置',
collapse: '折叠',
expand: '展开',
tracing: '追踪',
disabled: '已禁用',
disabledTip: '请先配置提供商',
enabled: '已启用',
tracingDescription: '捕获应用程序执行的完整上下文,包括 LLM 调用、上下文、提示、HTTP 请求等,发送到第三方跟踪平台。',
configProviderTitle: {
configured: '已配置',
notConfigured: '配置提供商以启用追踪',
moreProvider: '更多提供商',
},
langsmith: {
title: 'LangSmith',
description: '一个全方位的开发者平台,适用于 LLM 驱动应用程序生命周期的每个步骤。',
},
langfuse: {
title: 'Langfuse',
description: '跟踪、评估、提示管理和指标,以调试和改进您的 LLM 应用程序。',
},
inUse: '使用中',
configProvider: {
title: '配置 ',
placeholder: '输入你的{{key}}',
project: '项目',
publicKey: '公钥',
secretKey: '密钥',
viewDocsLink: '查看 {{key}} 的文档',
removeConfirmTitle: '删除 {{key}} 配置?',
removeConfirmContent: '当前配置正在使用中,删除它将关闭追踪功能。',
},
},
}
export default translation

View File

@ -12,6 +12,7 @@ const translation = {
cancel: '取消',
clear: '清空',
save: '保存',
saveAndEnable: '保存并启用',
edit: '编辑',
add: '添加',
added: '已添加',
@ -435,7 +436,7 @@ const translation = {
latestAvailable: 'Dify {{version}} 已是最新版本。',
},
appMenus: {
overview: '概览',
overview: '监测',
promptEng: '编排',
apiAccess: '访问 API',
logAndAnn: '日志与标注',

View File

@ -1,3 +1,4 @@
import type { LangFuseConfig, LangSmithConfig, TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
import type { App, AppTemplate, SiteConfig } from '@/types/app'
/* export type App = {
@ -129,3 +130,13 @@ export type AppVoicesListResponse = [{
name: string
value: string
}]
export type TracingStatus = {
enabled: boolean
tracing_provider: TracingProvider | null
}
export type TracingConfig = {
tracing_provider: TracingProvider
tracing_config: LangSmithConfig | LangFuseConfig
}

View File

@ -1,8 +1,9 @@
import type { Fetcher } from 'swr'
import { del, get, post, put } from './base'
import type { ApikeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, GenerationIntroductionResponse, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app'
import { del, get, patch, post, put } from './base'
import type { ApikeysListResponse, AppDailyConversationsResponse, AppDailyEndUsersResponse, AppDetailResponse, AppListResponse, AppStatisticsResponse, AppTemplatesResponse, AppTokenCostsResponse, AppVoicesListResponse, CreateApiKeyResponse, GenerationIntroductionResponse, TracingConfig, TracingStatus, UpdateAppModelConfigResponse, UpdateAppSiteCodeResponse, UpdateOpenAIKeyResponse, ValidateOpenAIKeyResponse, WorkflowDailyConversationsResponse } from '@/models/app'
import type { CommonResponse } from '@/models/common'
import type { AppMode, ModelConfig } from '@/types/app'
import type { TracingProvider } from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/tracing/type'
export const fetchAppList: Fetcher<AppListResponse, { url: string; params?: Record<string, any> }> = ({ url, params }) => {
return get<AppListResponse>(url, { params })
@ -121,3 +122,32 @@ export const generationIntroduction: Fetcher<GenerationIntroductionResponse, { u
export const fetchAppVoices: Fetcher<AppVoicesListResponse, { appId: string; language?: string }> = ({ appId, language }) => {
return get<AppVoicesListResponse>(`apps/${appId}/text-to-audio/voices?language=${language}`)
}
// Tracing
export const fetchTracingStatus: Fetcher<TracingStatus, { appId: string }> = ({ appId }) => {
return get(`/apps/${appId}/trace`)
}
export const updateTracingStatus: Fetcher<CommonResponse, { appId: string; body: Record<string, any> }> = ({ appId, body }) => {
return post(`/apps/${appId}/trace`, { body })
}
export const fetchTracingConfig: Fetcher<TracingConfig & { has_not_configured: true }, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => {
return get(`/apps/${appId}/trace-config`, {
params: {
tracing_provider: provider,
},
})
}
export const addTracingConfig: Fetcher<CommonResponse, { appId: string; body: TracingConfig }> = ({ appId, body }) => {
return post(`/apps/${appId}/trace-config`, { body })
}
export const updateTracingConfig: Fetcher<CommonResponse, { appId: string; body: TracingConfig }> = ({ appId, body }) => {
return patch(`/apps/${appId}/trace-config`, { body })
}
export const removeTracingConfig: Fetcher<CommonResponse, { appId: string; provider: TracingProvider }> = ({ appId, provider }) => {
return del(`/apps/${appId}/trace-config?tracing_provider=${provider}`)
}