This commit is contained in:
2025-12-01 17:21:38 +08:00
parent 32fee2b8ab
commit fab8c13cb3
7511 changed files with 996300 additions and 0 deletions

View File

@@ -0,0 +1,15 @@
import React from 'react'
import Main from '@/app/components/app/log-annotation'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
export type IProps = {
params: Promise<{ appId: string }>
}
const Logs = async () => {
return (
<Main pageType={PageType.annotation} />
)
}
export default Logs

View File

@@ -0,0 +1,10 @@
import React from 'react'
import Configuration from '@/app/components/app/configuration'
const IConfiguration = async () => {
return (
<Configuration />
)
}
export default IConfiguration

View File

@@ -0,0 +1,19 @@
import React from 'react'
import type { Locale } from '@/i18n-config'
import DevelopMain from '@/app/components/develop'
export type IDevelopProps = {
params: Promise<{ locale: Locale; appId: string }>
}
const Develop = async (props: IDevelopProps) => {
const params = await props.params
const {
appId,
} = params
return <DevelopMain appId={appId} />
}
export default Develop

View File

@@ -0,0 +1,181 @@
'use client'
import type { FC } from 'react'
import { useUnmount } from 'ahooks'
import React, { useCallback, useEffect, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation'
import {
RiDashboard2Fill,
RiDashboard2Line,
RiFileList3Fill,
RiFileList3Line,
RiTerminalBoxFill,
RiTerminalBoxLine,
RiTerminalWindowFill,
RiTerminalWindowLine,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useShallow } from 'zustand/react/shallow'
import s from './style.module.css'
import cn from '@/utils/classnames'
import { useStore } from '@/app/components/app/store'
import AppSideBar from '@/app/components/app-sidebar'
import type { NavIcon } from '@/app/components/app-sidebar/navLink'
import { fetchAppDetailDirect } from '@/service/apps'
import { useAppContext } from '@/context/app-context'
import Loading from '@/app/components/base/loading'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { type App, AppModeEnum } from '@/types/app'
import useDocumentTitle from '@/hooks/use-document-title'
import { useStore as useTagStore } from '@/app/components/base/tag-management/store'
import dynamic from 'next/dynamic'
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
ssr: false,
})
export type IAppDetailLayoutProps = {
children: React.ReactNode
appId: string
}
const AppDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
appId, // get appId in path
} = props
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { isCurrentWorkspaceEditor, isLoadingCurrentWorkspace, currentWorkspace } = useAppContext()
const { appDetail, setAppDetail, setAppSidebarExpand } = useStore(useShallow(state => ({
appDetail: state.appDetail,
setAppDetail: state.setAppDetail,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
const [isLoadingAppDetail, setIsLoadingAppDetail] = useState(false)
const [appDetailRes, setAppDetailRes] = useState<App | null>(null)
const [navigation, setNavigation] = useState<Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>>([])
const getNavigationConfig = useCallback((appId: string, isCurrentWorkspaceEditor: boolean, mode: AppModeEnum) => {
const navConfig = [
...(isCurrentWorkspaceEditor
? [{
name: t('common.appMenus.promptEng'),
href: `/app/${appId}/${(mode === AppModeEnum.WORKFLOW || mode === AppModeEnum.ADVANCED_CHAT) ? 'workflow' : 'configuration'}`,
icon: RiTerminalWindowLine,
selectedIcon: RiTerminalWindowFill,
}]
: []
),
{
name: t('common.appMenus.apiAccess'),
href: `/app/${appId}/develop`,
icon: RiTerminalBoxLine,
selectedIcon: RiTerminalBoxFill,
},
...(isCurrentWorkspaceEditor
? [{
name: mode !== AppModeEnum.WORKFLOW
? t('common.appMenus.logAndAnn')
: t('common.appMenus.logs'),
href: `/app/${appId}/logs`,
icon: RiFileList3Line,
selectedIcon: RiFileList3Fill,
}]
: []
),
{
name: t('common.appMenus.overview'),
href: `/app/${appId}/overview`,
icon: RiDashboard2Line,
selectedIcon: RiDashboard2Fill,
},
]
return navConfig
}, [t])
useDocumentTitle(appDetail?.name || t('common.menus.appDetail'))
useEffect(() => {
if (appDetail) {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
// TODO: consider screen size and mode
// if ((appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === 'workflow') && (pathname).endsWith('workflow'))
// setAppSidebarExpand('collapse')
}
}, [appDetail, isMobile])
useEffect(() => {
setAppDetail()
setIsLoadingAppDetail(true)
fetchAppDetailDirect({ url: '/apps', id: appId }).then((res: App) => {
setAppDetailRes(res)
}).catch((e: any) => {
if (e.status === 404)
router.replace('/apps')
}).finally(() => {
setIsLoadingAppDetail(false)
})
}, [appId, pathname])
useEffect(() => {
if (!appDetailRes || !currentWorkspace.id || isLoadingCurrentWorkspace || isLoadingAppDetail)
return
const res = appDetailRes
// redirection
const canIEditApp = isCurrentWorkspaceEditor
if (!canIEditApp && (pathname.endsWith('configuration') || pathname.endsWith('workflow') || pathname.endsWith('logs'))) {
router.replace(`/app/${appId}/overview`)
return
}
if ((res.mode === AppModeEnum.WORKFLOW || res.mode === AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('configuration')) {
router.replace(`/app/${appId}/workflow`)
}
else if ((res.mode !== AppModeEnum.WORKFLOW && res.mode !== AppModeEnum.ADVANCED_CHAT) && (pathname).endsWith('workflow')) {
router.replace(`/app/${appId}/configuration`)
}
else {
setAppDetail({ ...res, enable_sso: false })
setNavigation(getNavigationConfig(appId, isCurrentWorkspaceEditor, res.mode))
}
}, [appDetailRes, isCurrentWorkspaceEditor, isLoadingAppDetail, isLoadingCurrentWorkspace])
useUnmount(() => {
setAppDetail()
})
if (!appDetail) {
return (
<div className='flex h-full items-center justify-center bg-background-body'>
<Loading />
</div>
)
}
return (
<div className={cn(s.app, 'relative flex', 'overflow-hidden')}>
{appDetail && (
<AppSideBar
navigation={navigation}
/>
)}
<div className="grow overflow-hidden bg-components-panel-bg">
{children}
</div>
{showTagManagementModal && (
<TagManagementModal type='app' show={showTagManagementModal} />
)}
</div>
)
}
export default React.memo(AppDetailLayout)

View File

@@ -0,0 +1,14 @@
import Main from './layout-main'
const AppDetailLayout = async (props: {
children: React.ReactNode
params: Promise<{ appId: string }>
}) => {
const {
children,
params,
} = props
return <Main appId={(await params).appId}>{children}</Main>
}
export default AppDetailLayout

View File

@@ -0,0 +1,11 @@
import React from 'react'
import Main from '@/app/components/app/log-annotation'
import { PageType } from '@/app/components/base/features/new-feature-panel/annotation-reply/type'
const Logs = async () => {
return (
<Main pageType={PageType.log} />
)
}
export default Logs

View File

@@ -0,0 +1,204 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import AppCard from '@/app/components/app/overview/app-card'
import Loading from '@/app/components/base/loading'
import MCPServiceCard from '@/app/components/tools/mcp/mcp-service-card'
import TriggerCard from '@/app/components/app/overview/trigger-card'
import { ToastContext } from '@/app/components/base/toast'
import {
fetchAppDetail,
updateAppSiteAccessToken,
updateAppSiteConfig,
updateAppSiteStatus,
} from '@/service/apps'
import type { App } from '@/types/app'
import { AppModeEnum } from '@/types/app'
import type { UpdateAppSiteCodeResponse } from '@/models/app'
import { asyncRunSafe } from '@/utils'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import type { IAppCardProps } from '@/app/components/app/overview/app-card'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useAppWorkflow } from '@/service/use-workflow'
import type { BlockEnum } from '@/app/components/workflow/types'
import { isTriggerNode } from '@/app/components/workflow/types'
import { useDocLink } from '@/context/i18n'
export type ICardViewProps = {
appId: string
isInPanel?: boolean
className?: string
}
const CardView: FC<ICardViewProps> = ({ appId, isInPanel, className }) => {
const { t } = useTranslation()
const docLink = useDocLink()
const { notify } = useContext(ToastContext)
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const isWorkflowApp = appDetail?.mode === AppModeEnum.WORKFLOW
const showMCPCard = isInPanel
const showTriggerCard = isInPanel && isWorkflowApp
const { data: currentWorkflow } = useAppWorkflow(isWorkflowApp ? appDetail.id : '')
const hasTriggerNode = useMemo<boolean | null>(() => {
if (!isWorkflowApp)
return false
if (!currentWorkflow)
return null
const nodes = currentWorkflow.graph?.nodes || []
return nodes.some((node) => {
const nodeType = node.data?.type as BlockEnum | undefined
return !!nodeType && isTriggerNode(nodeType)
})
}, [isWorkflowApp, currentWorkflow])
const shouldRenderAppCards = !isWorkflowApp || hasTriggerNode === false
const disableAppCards = !shouldRenderAppCards
const triggerDocUrl = docLink('/guides/workflow/node/start')
const buildTriggerModeMessage = useCallback((featureName: string) => (
<div className='flex flex-col gap-1'>
<div className='text-xs text-text-secondary'>
{t('appOverview.overview.disableTooltip.triggerMode', { feature: featureName })}
</div>
<div
className='cursor-pointer text-xs font-medium text-text-accent hover:underline'
onClick={(event) => {
event.stopPropagation()
window.open(triggerDocUrl, '_blank')
}}
>
{t('appOverview.overview.appInfo.enableTooltip.learnMore')}
</div>
</div>
), [t, triggerDocUrl])
const disableWebAppTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.appInfo.title'))
: null
const disableApiTooltip = disableAppCards
? buildTriggerModeMessage(t('appOverview.overview.apiInfo.title'))
: null
const disableMcpTooltip = disableAppCards
? buildTriggerModeMessage(t('tools.mcp.server.title'))
: null
const updateAppDetail = async () => {
try {
const res = await fetchAppDetail({ url: '/apps', id: appId })
setAppDetail({ ...res })
}
catch (error) { console.error(error) }
}
const handleCallbackResult = (err: Error | null, message?: string) => {
const type = err ? 'error' : 'success'
message ||= (type === 'success' ? 'modifiedSuccessfully' : 'modifiedUnsuccessfully')
if (type === 'success')
updateAppDetail()
notify({
type,
message: t(`common.actionMsg.${message}`),
})
}
const onChangeSiteStatus = async (value: boolean) => {
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/site-enable`,
body: { enable_site: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onChangeApiStatus = async (value: boolean) => {
const [err] = await asyncRunSafe<App>(
updateAppSiteStatus({
url: `/apps/${appId}/api-enable`,
body: { enable_api: value },
}) as Promise<App>,
)
handleCallbackResult(err)
}
const onSaveSiteConfig: IAppCardProps['onSaveSiteConfig'] = async (params) => {
const [err] = await asyncRunSafe<App>(
updateAppSiteConfig({
url: `/apps/${appId}/site`,
body: params,
}) as Promise<App>,
)
if (!err)
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
handleCallbackResult(err)
}
const onGenerateCode = async () => {
const [err] = await asyncRunSafe<UpdateAppSiteCodeResponse>(
updateAppSiteAccessToken({
url: `/apps/${appId}/site/access-token-reset`,
}) as Promise<UpdateAppSiteCodeResponse>,
)
handleCallbackResult(err, err ? 'generatedUnsuccessfully' : 'generatedSuccessfully')
}
if (!appDetail)
return <Loading />
const appCards = (
<>
<AppCard
appInfo={appDetail}
cardType="webapp"
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableWebAppTooltip}
onChangeStatus={onChangeSiteStatus}
onGenerateCode={onGenerateCode}
onSaveSiteConfig={onSaveSiteConfig}
/>
<AppCard
cardType="api"
appInfo={appDetail}
isInPanel={isInPanel}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableApiTooltip}
onChangeStatus={onChangeApiStatus}
/>
{showMCPCard && (
<MCPServiceCard
appInfo={appDetail}
triggerModeDisabled={disableAppCards}
triggerModeMessage={disableMcpTooltip}
/>
)}
</>
)
const triggerCardNode = showTriggerCard ? (
<TriggerCard
appInfo={appDetail}
onToggleResult={handleCallbackResult}
/>
) : null
return (
<div className={className || 'mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'}>
{disableAppCards && triggerCardNode}
{appCards}
{!disableAppCards && triggerCardNode}
</div>
)
}
export default CardView

View File

@@ -0,0 +1,109 @@
'use client'
import React, { useState } from 'react'
import dayjs from 'dayjs'
import quarterOfYear from 'dayjs/plugin/quarterOfYear'
import { useTranslation } from 'react-i18next'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import { AvgResponseTime, AvgSessionInteractions, AvgUserInteractions, ConversationsChart, CostChart, EndUsersChart, MessagesChart, TokenPerSecond, UserSatisfactionRate, WorkflowCostChart, WorkflowDailyTerminalsChart, WorkflowMessagesChart } from '@/app/components/app/overview/app-chart'
import { useStore as useAppStore } from '@/app/components/app/store'
import TimeRangePicker from './time-range-picker'
import { TIME_PERIOD_MAPPING as LONG_TIME_PERIOD_MAPPING } from '@/app/components/app/log/filter'
import { IS_CLOUD_EDITION } from '@/config'
import LongTimeRangePicker from './long-time-range-picker'
dayjs.extend(quarterOfYear)
const today = dayjs()
const TIME_PERIOD_MAPPING = [
{ value: 0, name: 'today' },
{ value: 7, name: 'last7days' },
{ value: 30, name: 'last30days' },
]
const queryDateFormat = 'YYYY-MM-DD HH:mm'
export type IChartViewProps = {
appId: string
headerRight: React.ReactNode
}
export default function ChartView({ appId, headerRight }: IChartViewProps) {
const { t } = useTranslation()
const appDetail = useAppStore(state => state.appDetail)
const isChatApp = appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow'
const isWorkflow = appDetail?.mode === 'workflow'
const [period, setPeriod] = useState<PeriodParams>(IS_CLOUD_EDITION
? { name: t('appLog.filter.period.today'), query: { start: today.startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } }
: { name: t('appLog.filter.period.last7days'), query: { start: today.subtract(7, 'day').startOf('day').format(queryDateFormat), end: today.endOf('day').format(queryDateFormat) } },
)
if (!appDetail)
return null
return (
<div>
<div className='mb-4'>
<div className='system-xl-semibold mb-2 text-text-primary'>{t('common.appMenus.overview')}</div>
<div className='flex items-center justify-between'>
{IS_CLOUD_EDITION ? (
<TimeRangePicker
ranges={TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
) : (
<LongTimeRangePicker
periodMapping={LONG_TIME_PERIOD_MAPPING}
onSelect={setPeriod}
queryDateFormat={queryDateFormat}
/>
)}
{headerRight}
</div>
</div>
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<ConversationsChart period={period} id={appId} />
<EndUsersChart period={period} id={appId} />
</div>
)}
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
{isChatApp
? (
<AvgSessionInteractions period={period} id={appId} />
)
: (
<AvgResponseTime period={period} id={appId} />
)}
<TokenPerSecond period={period} id={appId} />
</div>
)}
{!isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<UserSatisfactionRate period={period} id={appId} />
<CostChart period={period} id={appId} />
</div>
)}
{!isWorkflow && isChatApp && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<MessagesChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<WorkflowMessagesChart period={period} id={appId} />
<WorkflowDailyTerminalsChart period={period} id={appId} />
</div>
)}
{isWorkflow && (
<div className='mb-6 grid w-full grid-cols-1 gap-6 xl:grid-cols-2'>
<WorkflowCostChart period={period} id={appId} />
<AvgUserInteractions period={period} id={appId} />
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,63 @@
'use client'
import type { PeriodParams } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React from 'react'
import type { Item } from '@/app/components/base/select'
import { SimpleSelect } from '@/app/components/base/select'
import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs'
type Props = {
periodMapping: { [key: string]: { value: number; name: string } }
onSelect: (payload: PeriodParams) => void
queryDateFormat: string
}
const today = dayjs()
const LongTimeRangePicker: FC<Props> = ({
periodMapping,
onSelect,
queryDateFormat,
}) => {
const { t } = useTranslation()
const handleSelect = React.useCallback((item: Item) => {
const id = item.value
const value = periodMapping[id]?.value ?? '-1'
const name = item.name || t('appLog.filter.period.allTime')
if (value === -1) {
onSelect({ name: t('appLog.filter.period.allTime'), query: undefined })
}
else if (value === 0) {
const startOfToday = today.startOf('day').format(queryDateFormat)
const endOfToday = today.endOf('day').format(queryDateFormat)
onSelect({
name,
query: {
start: startOfToday,
end: endOfToday,
},
})
}
else {
onSelect({
name,
query: {
start: today.subtract(value as number, 'day').startOf('day').format(queryDateFormat),
end: today.endOf('day').format(queryDateFormat),
},
})
}
}, [onSelect, periodMapping, queryDateFormat, t])
return (
<SimpleSelect
items={Object.entries(periodMapping).map(([k, v]) => ({ value: k, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
notClearable={true}
onSelect={handleSelect}
defaultValue={'2'}
/>
)
}
export default React.memo(LongTimeRangePicker)

View File

@@ -0,0 +1,28 @@
import React from 'react'
import ChartView from './chart-view'
import TracingPanel from './tracing/panel'
import ApikeyInfoPanel from '@/app/components/app/overview/apikey-info-panel'
export type IDevelopProps = {
params: Promise<{ appId: string }>
}
const Overview = async (props: IDevelopProps) => {
const params = await props.params
const {
appId,
} = params
return (
<div className="h-full overflow-y-auto bg-chatbot-bg px-4 py-6 sm:px-12">
<ApikeyInfoPanel />
<ChartView
appId={appId}
headerRight={<TracingPanel />}
/>
</div>
)
}
export default Overview

View File

@@ -0,0 +1,80 @@
'use client'
import { RiCalendarLine } from '@remixicon/react'
import type { Dayjs } from 'dayjs'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import cn from '@/utils/classnames'
import { formatToLocalTime } from '@/utils/format'
import { useI18N } from '@/context/i18n'
import Picker from '@/app/components/base/date-and-time-picker/date-picker'
import type { TriggerProps } from '@/app/components/base/date-and-time-picker/types'
import { noop } from 'lodash-es'
import dayjs from 'dayjs'
type Props = {
start: Dayjs
end: Dayjs
onStartChange: (date?: Dayjs) => void
onEndChange: (date?: Dayjs) => void
}
const today = dayjs()
const DatePicker: FC<Props> = ({
start,
end,
onStartChange,
onEndChange,
}) => {
const { locale } = useI18N()
const renderDate = useCallback(({ value, handleClickTrigger, isOpen }: TriggerProps) => {
return (
<div className={cn('system-sm-regular flex h-7 cursor-pointer items-center rounded-lg px-1 text-components-input-text-filled hover:bg-state-base-hover', isOpen && 'bg-state-base-hover')} onClick={handleClickTrigger}>
{value ? formatToLocalTime(value, locale, 'MMM D') : ''}
</div>
)
}, [locale])
const availableStartDate = end.subtract(30, 'day')
const startDateDisabled = useCallback((date: Dayjs) => {
if (date.isAfter(today, 'date'))
return true
return !((date.isAfter(availableStartDate, 'date') || date.isSame(availableStartDate, 'date')) && (date.isBefore(end, 'date') || date.isSame(end, 'date')))
}, [availableStartDate, end])
const availableEndDate = start.add(30, 'day')
const endDateDisabled = useCallback((date: Dayjs) => {
if (date.isAfter(today, 'date'))
return true
return !((date.isAfter(start, 'date') || date.isSame(start, 'date')) && (date.isBefore(availableEndDate, 'date') || date.isSame(availableEndDate, 'date')))
}, [availableEndDate, start])
return (
<div className='flex h-8 items-center space-x-0.5 rounded-lg bg-components-input-bg-normal px-2'>
<div className='p-px'>
<RiCalendarLine className='size-3.5 text-text-tertiary' />
</div>
<Picker
value={start}
onChange={onStartChange}
renderTrigger={renderDate}
needTimePicker={false}
onClear={noop}
noConfirm
getIsDateDisabled={startDateDisabled}
/>
<span className='system-sm-regular text-text-tertiary'>-</span>
<Picker
value={end}
onChange={onEndChange}
renderTrigger={renderDate}
needTimePicker={false}
onClear={noop}
noConfirm
getIsDateDisabled={endDateDisabled}
/>
</div>
)
}
export default React.memo(DatePicker)

View File

@@ -0,0 +1,86 @@
'use client'
import type { PeriodParams, PeriodParamsWithTimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback, useState } from 'react'
import type { Dayjs } from 'dayjs'
import { HourglassShape } from '@/app/components/base/icons/src/vender/other'
import RangeSelector from './range-selector'
import DatePicker from './date-picker'
import dayjs from 'dayjs'
import { useI18N } from '@/context/i18n'
import { formatToLocalTime } from '@/utils/format'
const today = dayjs()
type Props = {
ranges: { value: number; name: string }[]
onSelect: (payload: PeriodParams) => void
queryDateFormat: string
}
const TimeRangePicker: FC<Props> = ({
ranges,
onSelect,
queryDateFormat,
}) => {
const { locale } = useI18N()
const [isCustomRange, setIsCustomRange] = useState(false)
const [start, setStart] = useState<Dayjs>(today)
const [end, setEnd] = useState<Dayjs>(today)
const handleRangeChange = useCallback((payload: PeriodParamsWithTimeRange) => {
setIsCustomRange(false)
setStart(payload.query!.start)
setEnd(payload.query!.end)
onSelect({
name: payload.name,
query: {
start: payload.query!.start.format(queryDateFormat),
end: payload.query!.end.format(queryDateFormat),
},
})
}, [onSelect, queryDateFormat])
const handleDateChange = useCallback((type: 'start' | 'end') => {
return (date?: Dayjs) => {
if (!date) return
if (type === 'start' && date.isSame(start)) return
if (type === 'end' && date.isSame(end)) return
if (type === 'start')
setStart(date)
else
setEnd(date)
const currStart = type === 'start' ? date : start
const currEnd = type === 'end' ? date : end
onSelect({
name: `${formatToLocalTime(currStart, locale, 'MMM D')} - ${formatToLocalTime(currEnd, locale, 'MMM D')}`,
query: {
start: currStart.format(queryDateFormat),
end: currEnd.format(queryDateFormat),
},
})
setIsCustomRange(true)
}
}, [start, end, onSelect, locale, queryDateFormat])
return (
<div className='flex items-center'>
<RangeSelector
isCustomRange={isCustomRange}
ranges={ranges}
onSelect={handleRangeChange}
/>
<HourglassShape className='h-3.5 w-2 text-components-input-bg-normal' />
<DatePicker
start={start}
end={end}
onStartChange={handleDateChange('start')}
onEndChange={handleDateChange('end')}
/>
</div>
)
}
export default React.memo(TimeRangePicker)

View File

@@ -0,0 +1,81 @@
'use client'
import type { PeriodParamsWithTimeRange, TimeRange } from '@/app/components/app/overview/app-chart'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { SimpleSelect } from '@/app/components/base/select'
import type { Item } from '@/app/components/base/select'
import dayjs from 'dayjs'
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react'
import cn from '@/utils/classnames'
import { useTranslation } from 'react-i18next'
const today = dayjs()
type Props = {
isCustomRange: boolean
ranges: { value: number; name: string }[]
onSelect: (payload: PeriodParamsWithTimeRange) => void
}
const RangeSelector: FC<Props> = ({
isCustomRange,
ranges,
onSelect,
}) => {
const { t } = useTranslation()
const handleSelectRange = useCallback((item: Item) => {
const { name, value } = item
let period: TimeRange | null = null
if (value === 0) {
const startOfToday = today.startOf('day')
const endOfToday = today.endOf('day')
period = { start: startOfToday, end: endOfToday }
}
else {
period = { start: today.subtract(item.value as number, 'day').startOf('day'), end: today.endOf('day') }
}
onSelect({ query: period!, name })
}, [onSelect])
const renderTrigger = useCallback((item: Item | null, isOpen: boolean) => {
return (
<div className={cn('flex h-8 cursor-pointer items-center space-x-1.5 rounded-lg bg-components-input-bg-normal pl-3 pr-2', isOpen && 'bg-state-base-hover-alt')}>
<div className='system-sm-regular text-components-input-text-filled'>{isCustomRange ? t('appLog.filter.period.custom') : item?.name}</div>
<RiArrowDownSLine className={cn('size-4 text-text-quaternary', isOpen && 'text-text-secondary')} />
</div>
)
}, [isCustomRange])
const renderOption = useCallback(({ item, selected }: { item: Item; selected: boolean }) => {
return (
<>
{selected && (
<span
className={cn(
'absolute left-2 top-[9px] flex items-center text-text-accent',
)}
>
<RiCheckLine className="h-4 w-4" aria-hidden="true" />
</span>
)}
<span className={cn('system-md-regular block truncate')}>{item.name}</span>
</>
)
}, [])
return (
<SimpleSelect
items={ranges.map(v => ({ ...v, name: t(`appLog.filter.period.${v.name}`) }))}
className='mt-0 !w-40'
notClearable={true}
onSelect={handleSelectRange}
defaultValue={0}
wrapperClassName='h-8'
optionWrapClassName='w-[200px] translate-x-[-24px]'
renderTrigger={renderTrigger}
optionClassName='flex items-center py-0 pl-7 pr-2 h-8'
renderOption={renderOption}
/>
)
}
export default React.memo(RangeSelector)

View File

@@ -0,0 +1,156 @@
import React from 'react'
import { render } from '@testing-library/react'
import '@testing-library/jest-dom'
import { OpikIconBig } from '@/app/components/base/icons/src/public/tracing'
// Mock dependencies to isolate the SVG rendering issue
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}))
describe('SVG Attribute Error Reproduction', () => {
// Capture console errors
const originalError = console.error
let errorMessages: string[] = []
beforeEach(() => {
errorMessages = []
console.error = jest.fn((message) => {
errorMessages.push(message)
originalError(message)
})
})
afterEach(() => {
console.error = originalError
})
it('should reproduce inkscape attribute errors when rendering OpikIconBig', () => {
console.log('\n=== TESTING OpikIconBig SVG ATTRIBUTE ERRORS ===')
// Test multiple renders to check for inconsistency
for (let i = 0; i < 5; i++) {
console.log(`\nRender attempt ${i + 1}:`)
const { unmount } = render(<OpikIconBig />)
// Check for specific inkscape attribute errors
const inkscapeErrors = errorMessages.filter(msg =>
typeof msg === 'string' && msg.includes('inkscape'),
)
if (inkscapeErrors.length > 0) {
console.log(`Found ${inkscapeErrors.length} inkscape errors:`)
inkscapeErrors.forEach((error, index) => {
console.log(` ${index + 1}. ${error.substring(0, 100)}...`)
})
}
else {
console.log('No inkscape errors found in this render')
}
unmount()
// Clear errors for next iteration
errorMessages = []
}
})
it('should analyze the SVG structure causing the errors', () => {
console.log('\n=== ANALYZING SVG STRUCTURE ===')
// Import the JSON data directly
const iconData = require('@/app/components/base/icons/src/public/tracing/OpikIconBig.json')
console.log('Icon structure analysis:')
console.log('- Root element:', iconData.icon.name)
console.log('- Children count:', iconData.icon.children?.length || 0)
// Find problematic elements
const findProblematicElements = (node: any, path = '') => {
const problematicElements: any[] = []
if (node.name && (node.name.includes(':') || node.name.startsWith('sodipodi'))) {
problematicElements.push({
path,
name: node.name,
attributes: Object.keys(node.attributes || {}),
})
}
// Check attributes for inkscape/sodipodi properties
if (node.attributes) {
const problematicAttrs = Object.keys(node.attributes).filter(attr =>
attr.startsWith('inkscape:') || attr.startsWith('sodipodi:'),
)
if (problematicAttrs.length > 0) {
problematicElements.push({
path,
name: node.name,
problematicAttributes: problematicAttrs,
})
}
}
if (node.children) {
node.children.forEach((child: any, index: number) => {
problematicElements.push(
...findProblematicElements(child, `${path}/${node.name}[${index}]`),
)
})
}
return problematicElements
}
const problematicElements = findProblematicElements(iconData.icon, 'root')
console.log(`\n🚨 Found ${problematicElements.length} problematic elements:`)
problematicElements.forEach((element, index) => {
console.log(`\n${index + 1}. Element: ${element.name}`)
console.log(` Path: ${element.path}`)
if (element.problematicAttributes)
console.log(` Problematic attributes: ${element.problematicAttributes.join(', ')}`)
})
})
it('should test the normalizeAttrs function behavior', () => {
console.log('\n=== TESTING normalizeAttrs FUNCTION ===')
const { normalizeAttrs } = require('@/app/components/base/icons/utils')
const testAttributes = {
'inkscape:showpageshadow': '2',
'inkscape:pageopacity': '0.0',
'inkscape:pagecheckerboard': '0',
'inkscape:deskcolor': '#d1d1d1',
'sodipodi:docname': 'opik-icon-big.svg',
'xmlns:inkscape': 'https://www.inkscape.org/namespaces/inkscape',
'xmlns:sodipodi': 'https://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd',
'xmlns:svg': 'https://www.w3.org/2000/svg',
'data-name': 'Layer 1',
'normal-attr': 'value',
'class': 'test-class',
}
console.log('Input attributes:', Object.keys(testAttributes))
const normalized = normalizeAttrs(testAttributes)
console.log('Normalized attributes:', Object.keys(normalized))
console.log('Normalized values:', normalized)
// Check if problematic attributes are still present
const problematicKeys = Object.keys(normalized).filter(key =>
key.toLowerCase().includes('inkscape') || key.toLowerCase().includes('sodipodi'),
)
if (problematicKeys.length > 0)
console.log(`🚨 PROBLEM: Still found problematic attributes: ${problematicKeys.join(', ')}`)
else
console.log('✅ No problematic attributes found after normalization')
})
})

View File

@@ -0,0 +1,59 @@
'use client'
import type { FC } from 'react'
import React, { useCallback, useRef, useState } from 'react'
import type { PopupProps } from './config-popup'
import ConfigPopup from './config-popup'
import cn from '@/utils/classnames'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
type Props = {
readOnly: boolean
className?: string
hasConfigured: boolean
children?: React.ReactNode
} & PopupProps
const ConfigBtn: FC<Props> = ({
className,
hasConfigured,
children,
...popupProps
}) => {
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
if (popupProps.readOnly && !hasConfigured)
return null
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-end'
offset={12}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('select-none', className)}>
{children}
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[11]'>
<ConfigPopup {...popupProps} />
</PortalToFollowElemContent>
</PortalToFollowElem>
)
}
export default React.memo(ConfigBtn)

View File

@@ -0,0 +1,403 @@
'use client'
import type { FC, JSX } 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 { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } 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 Tooltip from '@/app/components/base/tooltip'
import Divider from '@/app/components/base/divider'
import cn from '@/utils/classnames'
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
arizeConfig: ArizeConfig | null
phoenixConfig: PhoenixConfig | null
langSmithConfig: LangSmithConfig | null
langFuseConfig: LangFuseConfig | null
opikConfig: OpikConfig | null
weaveConfig: WeaveConfig | null
aliyunConfig: AliyunConfig | null
mlflowConfig: MLflowConfig | null
databricksConfig: DatabricksConfig | null
tencentConfig: TencentConfig | null
onConfigUpdated: (provider: TracingProvider, payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | TencentConfig | MLflowConfig | DatabricksConfig) => void
onConfigRemoved: (provider: TracingProvider) => void
}
const ConfigPopup: FC<PopupProps> = ({
appId,
readOnly,
enabled,
onStatusChange,
chosenProvider,
onChooseProvider,
arizeConfig,
phoenixConfig,
langSmithConfig,
langFuseConfig,
opikConfig,
weaveConfig,
aliyunConfig,
mlflowConfig,
databricksConfig,
tencentConfig,
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: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig) => {
onConfigUpdated(currentProvider!, payload)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigUpdated])
const handleConfigRemoved = useCallback(() => {
onConfigRemoved(currentProvider!)
hideConfigModal()
}, [currentProvider, hideConfigModal, onConfigRemoved])
const providerAllConfigured = arizeConfig && phoenixConfig && langSmithConfig && langFuseConfig && opikConfig && weaveConfig && aliyunConfig && mlflowConfig && databricksConfig && tencentConfig
const providerAllNotConfigured = !arizeConfig && !phoenixConfig && !langSmithConfig && !langFuseConfig && !opikConfig && !weaveConfig && !aliyunConfig && !mlflowConfig && !databricksConfig && !tencentConfig
const switchContent = (
<Switch
className='ml-3'
defaultValue={enabled}
onChange={onStatusChange}
disabled={providerAllNotConfigured}
/>
)
const arizePanel = (
<ProviderPanel
type={TracingProvider.arize}
readOnly={readOnly}
config={arizeConfig}
hasConfigured={!!arizeConfig}
onConfig={handleOnConfig(TracingProvider.arize)}
isChosen={chosenProvider === TracingProvider.arize}
onChoose={handleOnChoose(TracingProvider.arize)}
key="arize-provider-panel"
/>
)
const phoenixPanel = (
<ProviderPanel
type={TracingProvider.phoenix}
readOnly={readOnly}
config={phoenixConfig}
hasConfigured={!!phoenixConfig}
onConfig={handleOnConfig(TracingProvider.phoenix)}
isChosen={chosenProvider === TracingProvider.phoenix}
onChoose={handleOnChoose(TracingProvider.phoenix)}
key="phoenix-provider-panel"
/>
)
const langSmithPanel = (
<ProviderPanel
type={TracingProvider.langSmith}
readOnly={readOnly}
config={langSmithConfig}
hasConfigured={!!langSmithConfig}
onConfig={handleOnConfig(TracingProvider.langSmith)}
isChosen={chosenProvider === TracingProvider.langSmith}
onChoose={handleOnChoose(TracingProvider.langSmith)}
key="langSmith-provider-panel"
/>
)
const langfusePanel = (
<ProviderPanel
type={TracingProvider.langfuse}
readOnly={readOnly}
config={langFuseConfig}
hasConfigured={!!langFuseConfig}
onConfig={handleOnConfig(TracingProvider.langfuse)}
isChosen={chosenProvider === TracingProvider.langfuse}
onChoose={handleOnChoose(TracingProvider.langfuse)}
key="langfuse-provider-panel"
/>
)
const opikPanel = (
<ProviderPanel
type={TracingProvider.opik}
readOnly={readOnly}
config={opikConfig}
hasConfigured={!!opikConfig}
onConfig={handleOnConfig(TracingProvider.opik)}
isChosen={chosenProvider === TracingProvider.opik}
onChoose={handleOnChoose(TracingProvider.opik)}
key="opik-provider-panel"
/>
)
const weavePanel = (
<ProviderPanel
type={TracingProvider.weave}
readOnly={readOnly}
config={weaveConfig}
hasConfigured={!!weaveConfig}
onConfig={handleOnConfig(TracingProvider.weave)}
isChosen={chosenProvider === TracingProvider.weave}
onChoose={handleOnChoose(TracingProvider.weave)}
key="weave-provider-panel"
/>
)
const aliyunPanel = (
<ProviderPanel
type={TracingProvider.aliyun}
readOnly={readOnly}
config={aliyunConfig}
hasConfigured={!!aliyunConfig}
onConfig={handleOnConfig(TracingProvider.aliyun)}
isChosen={chosenProvider === TracingProvider.aliyun}
onChoose={handleOnChoose(TracingProvider.aliyun)}
key="aliyun-provider-panel"
/>
)
const mlflowPanel = (
<ProviderPanel
type={TracingProvider.mlflow}
readOnly={readOnly}
config={mlflowConfig}
hasConfigured={!!mlflowConfig}
onConfig={handleOnConfig(TracingProvider.mlflow)}
isChosen={chosenProvider === TracingProvider.mlflow}
onChoose={handleOnChoose(TracingProvider.mlflow)}
key="mlflow-provider-panel"
/>
)
const databricksPanel = (
<ProviderPanel
type={TracingProvider.databricks}
readOnly={readOnly}
config={databricksConfig}
hasConfigured={!!databricksConfig}
onConfig={handleOnConfig(TracingProvider.databricks)}
isChosen={chosenProvider === TracingProvider.databricks}
onChoose={handleOnChoose(TracingProvider.databricks)}
key="databricks-provider-panel"
/>
)
const tencentPanel = (
<ProviderPanel
type={TracingProvider.tencent}
readOnly={readOnly}
config={tencentConfig}
hasConfigured={!!tencentConfig}
onConfig={handleOnConfig(TracingProvider.tencent)}
isChosen={chosenProvider === TracingProvider.tencent}
onChoose={handleOnChoose(TracingProvider.tencent)}
key="tencent-provider-panel"
/>
)
const configuredProviderPanel = () => {
const configuredPanels: JSX.Element[] = []
if (langFuseConfig)
configuredPanels.push(langfusePanel)
if (langSmithConfig)
configuredPanels.push(langSmithPanel)
if (opikConfig)
configuredPanels.push(opikPanel)
if (weaveConfig)
configuredPanels.push(weavePanel)
if (arizeConfig)
configuredPanels.push(arizePanel)
if (phoenixConfig)
configuredPanels.push(phoenixPanel)
if (aliyunConfig)
configuredPanels.push(aliyunPanel)
if (mlflowConfig)
configuredPanels.push(mlflowPanel)
if (databricksConfig)
configuredPanels.push(databricksPanel)
if (tencentConfig)
configuredPanels.push(tencentPanel)
return configuredPanels
}
const moreProviderPanel = () => {
const notConfiguredPanels: JSX.Element[] = []
if (!arizeConfig)
notConfiguredPanels.push(arizePanel)
if (!phoenixConfig)
notConfiguredPanels.push(phoenixPanel)
if (!langFuseConfig)
notConfiguredPanels.push(langfusePanel)
if (!langSmithConfig)
notConfiguredPanels.push(langSmithPanel)
if (!opikConfig)
notConfiguredPanels.push(opikPanel)
if (!weaveConfig)
notConfiguredPanels.push(weavePanel)
if (!aliyunConfig)
notConfiguredPanels.push(aliyunPanel)
if (!mlflowConfig)
notConfiguredPanels.push(mlflowPanel)
if (!databricksConfig)
notConfiguredPanels.push(databricksPanel)
if (!tencentConfig)
notConfiguredPanels.push(tencentPanel)
return notConfiguredPanels
}
const configuredProviderConfig = () => {
if (currentProvider === TracingProvider.mlflow)
return mlflowConfig
if (currentProvider === TracingProvider.databricks)
return databricksConfig
if (currentProvider === TracingProvider.arize)
return arizeConfig
if (currentProvider === TracingProvider.phoenix)
return phoenixConfig
if (currentProvider === TracingProvider.langSmith)
return langSmithConfig
if (currentProvider === TracingProvider.langfuse)
return langFuseConfig
if (currentProvider === TracingProvider.opik)
return opikConfig
if (currentProvider === TracingProvider.aliyun)
return aliyunConfig
if (currentProvider === TracingProvider.tencent)
return tencentConfig
return weaveConfig
}
return (
<div className='w-[420px] rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-4 shadow-xl'>
<div className='flex items-center justify-between'>
<div className='flex items-center'>
<TracingIcon size='md' className='mr-2' />
<div className='title-2xl-semi-bold text-text-primary'>{t(`${I18N_PREFIX}.tracing`)}</div>
</div>
<div className='flex items-center'>
<Indicator color={enabled ? 'green' : 'gray'} />
<div className={cn('system-xs-semibold-uppercase ml-1 text-text-tertiary', enabled && 'text-util-colors-green-green-600')}>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
</div>
{!readOnly && (
<>
{providerAllNotConfigured
? (
<Tooltip
popupContent={t(`${I18N_PREFIX}.disabledTip`)}
>
{switchContent}
</Tooltip>
)
: switchContent}
</>
)}
</div>
</div>
<div className='system-xs-regular mt-2 text-text-tertiary'>
{t(`${I18N_PREFIX}.tracingDescription`)}
</div>
<Divider className='my-3' />
<div className='relative'>
{(providerAllConfigured || providerAllNotConfigured)
? (
<>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.${providerAllConfigured ? 'configured' : 'notConfigured'}`)}</div>
<div className='mt-2 max-h-96 space-y-2 overflow-y-auto'>
{langfusePanel}
{langSmithPanel}
{opikPanel}
{mlflowPanel}
{databricksPanel}
{weavePanel}
{arizePanel}
{phoenixPanel}
{aliyunPanel}
{tencentPanel}
</div>
</>
)
: (
<>
<div className='system-xs-medium-uppercase text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.configured`)}</div>
<div className='mt-2 max-h-40 space-y-2 overflow-y-auto'>
{configuredProviderPanel()}
</div>
<div className='system-xs-medium-uppercase mt-3 text-text-tertiary'>{t(`${I18N_PREFIX}.configProviderTitle.moreProvider`)}</div>
<div className='mt-2 max-h-40 space-y-2 overflow-y-auto'>
{moreProviderPanel()}
</div>
</>
)}
</div>
{isShowConfigModal && (
<ProviderConfigModal
appId={appId}
type={currentProvider!}
payload={configuredProviderConfig()}
onCancel={hideConfigModal}
onSaved={handleConfigUpdated}
onChosen={onChooseProvider}
onRemoved={handleConfigRemoved}
/>
)}
</div>
)
}
export default React.memo(ConfigPopup)

View File

@@ -0,0 +1,14 @@
import { TracingProvider } from './type'
export const docURL = {
[TracingProvider.arize]: 'https://docs.arize.com/arize',
[TracingProvider.phoenix]: 'https://docs.arize.com/phoenix',
[TracingProvider.langSmith]: 'https://docs.smith.langchain.com/',
[TracingProvider.langfuse]: 'https://docs.langfuse.com',
[TracingProvider.opik]: 'https://www.comet.com/docs/opik/tracing/integrations/dify#setup-instructions',
[TracingProvider.weave]: 'https://weave-docs.wandb.ai/',
[TracingProvider.aliyun]: 'https://help.aliyun.com/zh/arms/tracing-analysis/untitled-document-1750672984680',
[TracingProvider.mlflow]: 'https://mlflow.org/docs/latest/genai/',
[TracingProvider.databricks]: 'https://docs.databricks.com/aws/en/mlflow3/genai/tracing/',
[TracingProvider.tencent]: 'https://cloud.tencent.com/document/product/248/116531',
}

View File

@@ -0,0 +1,41 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/classnames'
import Input from '@/app/components/base/input'
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 h-[18px] items-center text-[13px] font-medium text-text-primary')}>{label} </div>
{isRequired && <span className='ml-0.5 text-xs font-semibold text-[#D92D20]'>*</span>}
</div>
<Input
value={value}
onChange={e => onChange(e.target.value)}
className='h-9'
placeholder={placeholder}
/>
</div>
)
}
export default React.memo(Field)

View File

@@ -0,0 +1,311 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useState } from 'react'
import {
RiArrowDownDoubleLine,
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { usePathname } from 'next/navigation'
import { useBoolean } from 'ahooks'
import type { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } from './type'
import { TracingProvider } from './type'
import TracingIcon from './tracing-icon'
import ConfigButton from './config-button'
import cn from '@/utils/classnames'
import { AliyunIcon, ArizeIcon, DatabricksIcon, LangfuseIcon, LangsmithIcon, MlflowIcon, OpikIcon, PhoenixIcon, TencentIcon, WeaveIcon } 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'
import Divider from '@/app/components/base/divider'
const I18N_PREFIX = 'app.tracing'
const Panel: FC = () => {
const { t } = useTranslation()
const pathname = usePathname()
const matched = /\/app\/([^/]+)/.exec(pathname)
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 providerIconMap: Record<TracingProvider, React.FC<{ className?: string }>> = {
[TracingProvider.arize]: ArizeIcon,
[TracingProvider.phoenix]: PhoenixIcon,
[TracingProvider.langSmith]: LangsmithIcon,
[TracingProvider.langfuse]: LangfuseIcon,
[TracingProvider.opik]: OpikIcon,
[TracingProvider.weave]: WeaveIcon,
[TracingProvider.aliyun]: AliyunIcon,
[TracingProvider.mlflow]: MlflowIcon,
[TracingProvider.databricks]: DatabricksIcon,
[TracingProvider.tencent]: TencentIcon,
}
const InUseProviderIcon = inUseTracingProvider ? providerIconMap[inUseTracingProvider] : undefined
const [arizeConfig, setArizeConfig] = useState<ArizeConfig | null>(null)
const [phoenixConfig, setPhoenixConfig] = useState<PhoenixConfig | null>(null)
const [langSmithConfig, setLangSmithConfig] = useState<LangSmithConfig | null>(null)
const [langFuseConfig, setLangFuseConfig] = useState<LangFuseConfig | null>(null)
const [opikConfig, setOpikConfig] = useState<OpikConfig | null>(null)
const [weaveConfig, setWeaveConfig] = useState<WeaveConfig | null>(null)
const [aliyunConfig, setAliyunConfig] = useState<AliyunConfig | null>(null)
const [mlflowConfig, setMLflowConfig] = useState<MLflowConfig | null>(null)
const [databricksConfig, setDatabricksConfig] = useState<DatabricksConfig | null>(null)
const [tencentConfig, setTencentConfig] = useState<TencentConfig | null>(null)
const hasConfiguredTracing = !!(langSmithConfig || langFuseConfig || opikConfig || weaveConfig || arizeConfig || phoenixConfig || aliyunConfig || mlflowConfig || databricksConfig || tencentConfig)
const fetchTracingConfig = async () => {
const getArizeConfig = async () => {
const { tracing_config: arizeConfig, has_not_configured: arizeHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.arize })
if (!arizeHasNotConfig)
setArizeConfig(arizeConfig as ArizeConfig)
}
const getPhoenixConfig = async () => {
const { tracing_config: phoenixConfig, has_not_configured: phoenixHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.phoenix })
if (!phoenixHasNotConfig)
setPhoenixConfig(phoenixConfig as PhoenixConfig)
}
const getLangSmithConfig = async () => {
const { tracing_config: langSmithConfig, has_not_configured: langSmithHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langSmith })
if (!langSmithHasNotConfig)
setLangSmithConfig(langSmithConfig as LangSmithConfig)
}
const getLangFuseConfig = async () => {
const { tracing_config: langFuseConfig, has_not_configured: langFuseHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.langfuse })
if (!langFuseHasNotConfig)
setLangFuseConfig(langFuseConfig as LangFuseConfig)
}
const getOpikConfig = async () => {
const { tracing_config: opikConfig, has_not_configured: OpikHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.opik })
if (!OpikHasNotConfig)
setOpikConfig(opikConfig as OpikConfig)
}
const getWeaveConfig = async () => {
const { tracing_config: weaveConfig, has_not_configured: weaveHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.weave })
if (!weaveHasNotConfig)
setWeaveConfig(weaveConfig as WeaveConfig)
}
const getAliyunConfig = async () => {
const { tracing_config: aliyunConfig, has_not_configured: aliyunHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.aliyun })
if (!aliyunHasNotConfig)
setAliyunConfig(aliyunConfig as AliyunConfig)
}
const getMLflowConfig = async () => {
const { tracing_config: mlflowConfig, has_not_configured: mlflowHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.mlflow })
if (!mlflowHasNotConfig)
setMLflowConfig(mlflowConfig as MLflowConfig)
}
const getDatabricksConfig = async () => {
const { tracing_config: databricksConfig, has_not_configured: databricksHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.databricks })
if (!databricksHasNotConfig)
setDatabricksConfig(databricksConfig as DatabricksConfig)
}
const getTencentConfig = async () => {
const { tracing_config: tencentConfig, has_not_configured: tencentHasNotConfig } = await doFetchTracingConfig({ appId, provider: TracingProvider.tencent })
if (!tencentHasNotConfig)
setTencentConfig(tencentConfig as TencentConfig)
}
Promise.all([
getArizeConfig(),
getPhoenixConfig(),
getLangSmithConfig(),
getLangFuseConfig(),
getOpikConfig(),
getWeaveConfig(),
getAliyunConfig(),
getMLflowConfig(),
getDatabricksConfig(),
getTencentConfig(),
])
}
const handleTracingConfigUpdated = async (provider: TracingProvider) => {
// call api to hide secret key value
const { tracing_config } = await doFetchTracingConfig({ appId, provider })
if (provider === TracingProvider.arize)
setArizeConfig(tracing_config as ArizeConfig)
else if (provider === TracingProvider.phoenix)
setPhoenixConfig(tracing_config as PhoenixConfig)
else if (provider === TracingProvider.langSmith)
setLangSmithConfig(tracing_config as LangSmithConfig)
else if (provider === TracingProvider.langfuse)
setLangFuseConfig(tracing_config as LangFuseConfig)
else if (provider === TracingProvider.opik)
setOpikConfig(tracing_config as OpikConfig)
else if (provider === TracingProvider.weave)
setWeaveConfig(tracing_config as WeaveConfig)
else if (provider === TracingProvider.aliyun)
setAliyunConfig(tracing_config as AliyunConfig)
else if (provider === TracingProvider.tencent)
setTencentConfig(tracing_config as TencentConfig)
}
const handleTracingConfigRemoved = (provider: TracingProvider) => {
if (provider === TracingProvider.arize)
setArizeConfig(null)
else if (provider === TracingProvider.phoenix)
setPhoenixConfig(null)
else if (provider === TracingProvider.langSmith)
setLangSmithConfig(null)
else if (provider === TracingProvider.langfuse)
setLangFuseConfig(null)
else if (provider === TracingProvider.opik)
setOpikConfig(null)
else if (provider === TracingProvider.weave)
setWeaveConfig(null)
else if (provider === TracingProvider.aliyun)
setAliyunConfig(null)
else if (provider === TracingProvider.mlflow)
setMLflowConfig(null)
else if (provider === TracingProvider.databricks)
setDatabricksConfig(null)
else if (provider === TracingProvider.tencent)
setTencentConfig(null)
if (provider === inUseTracingProvider) {
handleTracingStatusChange({
enabled: false,
tracing_provider: null,
}, true)
}
}
useEffect(() => {
(async () => {
const tracingStatus = await fetchTracingStatus({ appId })
setTracingStatus(tracingStatus)
await fetchTracingConfig()
setLoaded()
})()
}, [])
if (!isLoaded) {
return (
<div className='mb-3 flex items-center justify-between'>
<div className='w-[200px]'>
<Loading />
</div>
</div>
)
}
return (
<div className={cn('flex items-center justify-between')}>
{!inUseTracingProvider && (
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured={false}
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
mlflowConfig={mlflowConfig}
databricksConfig={databricksConfig}
tencentConfig={tencentConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
>
<div
className={cn(
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<TracingIcon size='md' />
<div className='system-sm-semibold mx-2 text-text-secondary'>{t(`${I18N_PREFIX}.title`)}</div>
<div className='rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
<Divider type='vertical' className='h-3.5' />
<div className='rounded-md p-1'>
<RiArrowDownDoubleLine className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</ConfigButton>
)}
{hasConfiguredTracing && (
<ConfigButton
appId={appId}
readOnly={readOnly}
hasConfigured
enabled={enabled}
onStatusChange={handleTracingEnabledChange}
chosenProvider={inUseTracingProvider}
onChooseProvider={handleChooseProvider}
arizeConfig={arizeConfig}
phoenixConfig={phoenixConfig}
langSmithConfig={langSmithConfig}
langFuseConfig={langFuseConfig}
opikConfig={opikConfig}
weaveConfig={weaveConfig}
aliyunConfig={aliyunConfig}
mlflowConfig={mlflowConfig}
databricksConfig={databricksConfig}
tencentConfig={tencentConfig}
onConfigUpdated={handleTracingConfigUpdated}
onConfigRemoved={handleTracingConfigRemoved}
>
<div
className={cn(
'flex cursor-pointer select-none items-center rounded-xl border-l-[0.5px] border-t border-effects-highlight bg-background-default-dodge p-2 shadow-xs hover:border-effects-highlight-lightmode-off hover:bg-background-default-lighter',
)}
>
<div className='ml-4 mr-1 flex items-center'>
<Indicator color={enabled ? 'green' : 'gray'} />
<div className='system-xs-semibold-uppercase ml-1.5 text-text-tertiary'>
{t(`${I18N_PREFIX}.${enabled ? 'enabled' : 'disabled'}`)}
</div>
</div>
{InUseProviderIcon && <InUseProviderIcon className='ml-1 h-4' />}
<div className='ml-2 rounded-md p-1'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
<Divider type='vertical' className='h-3.5' />
</div>
</ConfigButton>
)}
</div>
)
}
export default React.memo(Panel)

View File

@@ -0,0 +1,698 @@
'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 { AliyunConfig, ArizeConfig, DatabricksConfig, LangFuseConfig, LangSmithConfig, MLflowConfig, OpikConfig, PhoenixConfig, TencentConfig, WeaveConfig } 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 Confirm from '@/app/components/base/confirm'
import { addTracingConfig, removeTracingConfig, updateTracingConfig } from '@/service/apps'
import Toast from '@/app/components/base/toast'
import Divider from '@/app/components/base/divider'
type Props = {
appId: string
type: TracingProvider
payload?: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig | null
onRemoved: () => void
onCancel: () => void
onSaved: (payload: ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig) => void
onChosen: (provider: TracingProvider) => void
}
const I18N_PREFIX = 'app.tracing.configProvider'
const arizeConfigTemplate = {
api_key: '',
space_id: '',
project: '',
endpoint: '',
}
const phoenixConfigTemplate = {
api_key: '',
project: '',
endpoint: '',
}
const langSmithConfigTemplate = {
api_key: '',
project: '',
endpoint: '',
}
const langFuseConfigTemplate = {
public_key: '',
secret_key: '',
host: '',
}
const opikConfigTemplate = {
api_key: '',
project: '',
url: '',
workspace: '',
}
const weaveConfigTemplate = {
api_key: '',
entity: '',
project: '',
endpoint: '',
host: '',
}
const aliyunConfigTemplate = {
app_name: '',
license_key: '',
endpoint: '',
}
const mlflowConfigTemplate = {
tracking_uri: '',
experiment_id: '',
username: '',
password: '',
}
const databricksConfigTemplate = {
experiment_id: '',
host: '',
client_id: '',
client_secret: '',
personal_access_token: '',
}
const tencentConfigTemplate = {
token: '',
endpoint: '',
service_name: '',
}
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<ArizeConfig | PhoenixConfig | LangSmithConfig | LangFuseConfig | OpikConfig | WeaveConfig | AliyunConfig | MLflowConfig | DatabricksConfig | TencentConfig>((() => {
if (isEdit)
return payload
if (type === TracingProvider.arize)
return arizeConfigTemplate
else if (type === TracingProvider.phoenix)
return phoenixConfigTemplate
else if (type === TracingProvider.langSmith)
return langSmithConfigTemplate
else if (type === TracingProvider.langfuse)
return langFuseConfigTemplate
else if (type === TracingProvider.opik)
return opikConfigTemplate
else if (type === TracingProvider.aliyun)
return aliyunConfigTemplate
else if (type === TracingProvider.mlflow)
return mlflowConfigTemplate
else if (type === TracingProvider.databricks)
return databricksConfigTemplate
else if (type === TracingProvider.tencent)
return tencentConfigTemplate
return weaveConfigTemplate
})())
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.arize) {
const postData = config as ArizeConfig
if (!postData.api_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'API Key' })
if (!postData.space_id)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Space ID' })
if (!errorMessage && !postData.project)
errorMessage = t('common.errorMsg.fieldRequired', { field: t(`${I18N_PREFIX}.project`) })
}
if (type === TracingProvider.phoenix) {
const postData = config as PhoenixConfig
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.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' })
}
if (type === TracingProvider.opik) {
// todo: check field validity
// const postData = config as OpikConfig
}
if (type === TracingProvider.weave) {
const postData = config as WeaveConfig
if (!errorMessage && !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.aliyun) {
const postData = config as AliyunConfig
if (!errorMessage && !postData.app_name)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'App Name' })
if (!errorMessage && !postData.license_key)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'License Key' })
if (!errorMessage && !postData.endpoint)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' })
}
if (type === TracingProvider.mlflow) {
const postData = config as MLflowConfig
if (!errorMessage && !postData.tracking_uri)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Tracking URI' })
}
if (type === TracingProvider.databricks) {
const postData = config as DatabricksConfig
if (!errorMessage && !postData.experiment_id)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Experiment ID' })
if (!errorMessage && !postData.host)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Host' })
}
if (type === TracingProvider.tencent) {
const postData = config as TencentConfig
if (!errorMessage && !postData.token)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Token' })
if (!errorMessage && !postData.endpoint)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Endpoint' })
if (!errorMessage && !postData.service_name)
errorMessage = t('common.errorMsg.fieldRequired', { field: 'Service Name' })
}
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='z-[60] h-full w-full'>
<div className='fixed inset-0 flex items-center justify-center bg-background-overlay'>
<div className='mx-2 max-h-[calc(100vh-120px)] w-[640px] overflow-y-auto rounded-2xl bg-components-panel-bg shadow-xl'>
<div className='px-8 pt-8'>
<div className='mb-4 flex items-center justify-between'>
<div className='title-2xl-semi-bold text-text-primary'>{t(`${I18N_PREFIX}.title`)}{t(`app.tracing.${type}.title`)}</div>
</div>
<div className='space-y-4'>
{type === TracingProvider.arize && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label='Space ID'
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).space_id}
onChange={handleConfigChange('space_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Space ID' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
isRequired
value={(config as ArizeConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as ArizeConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://otlp.arize.com'}
/>
</>
)}
{type === TracingProvider.phoenix && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as PhoenixConfig).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 PhoenixConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as PhoenixConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://app.phoenix.arize.com'}
/>
</>
)}
{type === TracingProvider.aliyun && (
<>
<Field
label='License Key'
labelClassName='!text-sm'
isRequired
value={(config as AliyunConfig).license_key}
onChange={handleConfigChange('license_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'License Key' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as AliyunConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://tracing.arms.aliyuncs.com'}
/>
<Field
label='App Name'
labelClassName='!text-sm'
value={(config as AliyunConfig).app_name}
onChange={handleConfigChange('app_name')}
/>
</>
)}
{type === TracingProvider.tencent && (
<>
<Field
label='Token'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).token}
onChange={handleConfigChange('token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Token' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder='https://your-region.cls.tencentcs.com'
/>
<Field
label='Service Name'
labelClassName='!text-sm'
isRequired
value={(config as TencentConfig).service_name}
onChange={handleConfigChange('service_name')}
placeholder='dify_app'
/>
</>
)}
{type === TracingProvider.weave && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
isRequired
value={(config as WeaveConfig).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 WeaveConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Entity'
labelClassName='!text-sm'
value={(config as WeaveConfig).entity}
onChange={handleConfigChange('entity')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'Entity' })!}
/>
<Field
label='Endpoint'
labelClassName='!text-sm'
value={(config as WeaveConfig).endpoint}
onChange={handleConfigChange('endpoint')}
placeholder={'https://trace.wandb.ai/'}
/>
<Field
label='Host'
labelClassName='!text-sm'
value={(config as WeaveConfig).host}
onChange={handleConfigChange('host')}
placeholder={'https://api.wandb.ai'}
/>
</>
)}
{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'
/>
</>
)}
{type === TracingProvider.opik && (
<>
<Field
label='API Key'
labelClassName='!text-sm'
value={(config as OpikConfig).api_key}
onChange={handleConfigChange('api_key')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: 'API Key' })!}
/>
<Field
label={t(`${I18N_PREFIX}.project`)!}
labelClassName='!text-sm'
value={(config as OpikConfig).project}
onChange={handleConfigChange('project')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.project`) })!}
/>
<Field
label='Workspace'
labelClassName='!text-sm'
value={(config as OpikConfig).workspace}
onChange={handleConfigChange('workspace')}
placeholder={'default'}
/>
<Field
label='Url'
labelClassName='!text-sm'
value={(config as OpikConfig).url}
onChange={handleConfigChange('url')}
placeholder={'https://www.comet.com/opik/api/'}
/>
</>
)}
{type === TracingProvider.mlflow && (
<>
<Field
label={t(`${I18N_PREFIX}.trackingUri`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).tracking_uri}
isRequired
onChange={handleConfigChange('tracking_uri')}
placeholder={'http://localhost:5000'}
/>
<Field
label={t(`${I18N_PREFIX}.experimentId`)!}
labelClassName='!text-sm'
isRequired
value={(config as MLflowConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.experimentId`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.username`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).username}
onChange={handleConfigChange('username')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.username`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.password`)!}
labelClassName='!text-sm'
value={(config as MLflowConfig).password}
onChange={handleConfigChange('password')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.password`) })!}
/>
</>
)}
{type === TracingProvider.databricks && (
<>
<Field
label={t(`${I18N_PREFIX}.experimentId`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).experiment_id}
onChange={handleConfigChange('experiment_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.experimentId`) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.databricksHost`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).host}
onChange={handleConfigChange('host')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.databricksHost`) })!}
isRequired
/>
<Field
label={t(`${I18N_PREFIX}.clientId`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).client_id}
onChange={handleConfigChange('client_id')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.clientId`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.clientSecret`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).client_secret}
onChange={handleConfigChange('client_secret')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.clientSecret`) })!}
/>
<Field
label={t(`${I18N_PREFIX}.personalAccessToken`)!}
labelClassName='!text-sm'
value={(config as DatabricksConfig).personal_access_token}
onChange={handleConfigChange('personal_access_token')}
placeholder={t(`${I18N_PREFIX}.placeholder`, { key: t(`${I18N_PREFIX}.personalAccessToken`) })!}
/>
</>
)}
</div>
<div className='my-8 flex h-8 items-center justify-between'>
<a
className='flex items-center space-x-1 text-xs font-normal leading-[18px] text-[#155EEF]'
target='_blank'
href={docURL[type]}
>
<span>{t(`${I18N_PREFIX}.viewDocsLink`, { key: t(`app.tracing.${type}.title`) })}</span>
<LinkExternal02 className='h-3 w-3' />
</a>
<div className='flex items-center'>
{isEdit && (
<>
<Button
className='h-9 text-sm font-medium text-text-secondary'
onClick={showRemoveConfirm}
>
<span className='text-[#D92D20]'>{t('common.operation.remove')}</span>
</Button>
<Divider type='vertical' className='mx-3 h-[18px]' />
</>
)}
<Button
className='mr-2 h-9 text-sm font-medium text-text-secondary'
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-divider-regular'>
<div className='flex items-center justify-center bg-background-section-burn py-3 text-xs text-text-tertiary'>
<Lock01 className='mr-1 h-3 w-3 text-text-tertiary' />
{t('common.modelProvider.encrypted.front')}
<a
className='mx-1 text-primary-600'
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>
)
: (
<Confirm
isShow
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,110 @@
'use client'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import {
RiEqualizer2Line,
} from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { TracingProvider } from './type'
import cn from '@/utils/classnames'
import { AliyunIconBig, ArizeIconBig, DatabricksIconBig, LangfuseIconBig, LangsmithIconBig, MlflowIconBig, OpikIconBig, PhoenixIconBig, TencentIconBig, WeaveIconBig } from '@/app/components/base/icons/src/public/tracing'
import { Eye as View } from '@/app/components/base/icons/src/vender/solid/general'
const I18N_PREFIX = 'app.tracing'
type Props = {
type: TracingProvider
readOnly: boolean
isChosen: boolean
config: any
onChoose: () => void
hasConfigured: boolean
onConfig: () => void
}
const getIcon = (type: TracingProvider) => {
return ({
[TracingProvider.arize]: ArizeIconBig,
[TracingProvider.phoenix]: PhoenixIconBig,
[TracingProvider.langSmith]: LangsmithIconBig,
[TracingProvider.langfuse]: LangfuseIconBig,
[TracingProvider.opik]: OpikIconBig,
[TracingProvider.weave]: WeaveIconBig,
[TracingProvider.aliyun]: AliyunIconBig,
[TracingProvider.mlflow]: MlflowIconBig,
[TracingProvider.databricks]: DatabricksIconBig,
[TracingProvider.tencent]: TencentIconBig,
})[type]
}
const ProviderPanel: FC<Props> = ({
type,
readOnly,
isChosen,
config,
onChoose,
hasConfigured,
onConfig,
}) => {
const { t } = useTranslation()
const Icon = getIcon(type)
const handleConfigBtnClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
onConfig()
}, [onConfig])
const viewBtnClick = useCallback((e: React.MouseEvent) => {
e.preventDefault()
e.stopPropagation()
const url = config?.project_url
if (url)
window.open(url, '_blank', 'noopener,noreferrer')
}, [config?.project_url])
const handleChosen = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
if (isChosen || !hasConfigured || readOnly)
return
onChoose()
}, [hasConfigured, isChosen, onChoose, readOnly])
return (
<div
className={cn(
'rounded-xl border-[1.5px] bg-background-section-burn px-4 py-3',
isChosen ? 'border-components-option-card-option-selected-border bg-background-section' : 'border-transparent',
!isChosen && hasConfigured && !readOnly && 'cursor-pointer',
)}
onClick={handleChosen}
>
<div className={'flex items-center justify-between space-x-1'}>
<div className='flex items-center'>
<Icon className='h-6' />
{isChosen && <div className='system-2xs-medium-uppercase ml-1 flex h-4 items-center rounded-[4px] border border-text-accent-secondary px-1 text-text-accent-secondary'>{t(`${I18N_PREFIX}.inUse`)}</div>}
</div>
{!readOnly && (
<div className={'flex items-center justify-between space-x-1'}>
{hasConfigured && (
<div className='flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs' onClick={viewBtnClick} >
<View className='h-3 w-3' />
<div className='text-xs font-medium'>{t(`${I18N_PREFIX}.view`)}</div>
</div>
)}
<div
className='flex h-6 cursor-pointer items-center space-x-1 rounded-md border-[0.5px] border-components-button-secondary-border bg-components-button-secondary-bg px-2 text-text-secondary shadow-xs'
onClick={handleConfigBtnClick}
>
<RiEqualizer2Line className='h-3 w-3' />
<div className='text-xs font-medium'>{t(`${I18N_PREFIX}.config`)}</div>
</div>
</div>
)}
</div>
<div className='system-xs-regular mt-2 text-text-tertiary'>
{t(`${I18N_PREFIX}.${type}.description`)}
</div>
</div>
)
}
export default React.memo(ProviderPanel)

View File

@@ -0,0 +1,28 @@
'use client'
import type { FC } from 'react'
import React from 'react'
import cn from '@/utils/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='h-full w-full' />
</div>
)
}
export default React.memo(TracingIcon)

View File

@@ -0,0 +1,79 @@
export enum TracingProvider {
arize = 'arize',
phoenix = 'phoenix',
langSmith = 'langsmith',
langfuse = 'langfuse',
opik = 'opik',
weave = 'weave',
aliyun = 'aliyun',
mlflow = 'mlflow',
databricks = 'databricks',
tencent = 'tencent',
}
export type ArizeConfig = {
api_key: string
space_id: string
project: string
endpoint: string
}
export type PhoenixConfig = {
api_key: string
project: string
endpoint: string
}
export type LangSmithConfig = {
api_key: string
project: string
endpoint: string
}
export type LangFuseConfig = {
public_key: string
secret_key: string
host: string
}
export type OpikConfig = {
api_key: string
project: string
workspace: string
url: string
}
export type WeaveConfig = {
api_key: string
entity: string
project: string
endpoint: string
host: string
}
export type AliyunConfig = {
app_name: string
license_key: string
endpoint: string
}
export type MLflowConfig = {
tracking_uri: string
experiment_id: string
username: string
password: string
}
export type DatabricksConfig = {
experiment_id: string
host: string
client_id: string
client_secret: string
personal_access_token: string
}
export type TencentConfig = {
token: string
endpoint: string
service_name: string
}

View File

@@ -0,0 +1,6 @@
.app {
flex-grow: 1;
height: 0;
border-radius: 16px 16px 0px 0px;
box-shadow: 0px 0px 5px rgba(0, 0, 0, 0.05), 0px 0px 2px -1px rgba(0, 0, 0, 0.03);
}

View File

@@ -0,0 +1,10 @@
import WorkflowApp from '@/app/components/workflow-app'
const Page = () => {
return (
<div className='h-full w-full overflow-x-auto'>
<WorkflowApp />
</div>
)
}
export default Page

View File

@@ -0,0 +1,31 @@
'use client'
import type { FC } from 'react'
import React, { useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
export type IAppDetail = {
children: React.ReactNode
}
const AppDetail: FC<IAppDetail> = ({ children }) => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('common.menus.appDetail'))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router])
return (
<>
{children}
</>
)
}
export default React.memo(AppDetail)

View File

@@ -0,0 +1,9 @@
import Apps from '@/app/components/apps'
const AppList = () => {
return (
<Apps />
)
}
export default AppList

View File

@@ -0,0 +1,9 @@
import React from 'react'
const page = () => {
return (
<div>dataset detail api</div>
)
}
export default page

View File

@@ -0,0 +1,21 @@
import React from 'react'
import MainDetail from '@/app/components/datasets/documents/detail'
export type IDocumentDetailProps = {
params: Promise<{ datasetId: string; documentId: string }>
}
const DocumentDetail = async (props: IDocumentDetailProps) => {
const params = await props.params
const {
datasetId,
documentId,
} = params
return (
<MainDetail datasetId={datasetId} documentId={documentId} />
)
}
export default DocumentDetail

View File

@@ -0,0 +1,21 @@
import React from 'react'
import Settings from '@/app/components/datasets/documents/detail/settings'
export type IProps = {
params: Promise<{ datasetId: string; documentId: string }>
}
const DocumentSettings = async (props: IProps) => {
const params = await props.params
const {
datasetId,
documentId,
} = params
return (
<Settings datasetId={datasetId} documentId={documentId} />
)
}
export default DocumentSettings

View File

@@ -0,0 +1,10 @@
import React from 'react'
import CreateFromPipeline from '@/app/components/datasets/documents/create-from-pipeline'
const CreateFromPipelinePage = async () => {
return (
<CreateFromPipeline />
)
}
export default CreateFromPipelinePage

View File

@@ -0,0 +1,20 @@
import React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'
export type IProps = {
params: Promise<{ datasetId: string }>
}
const Create = async (props: IProps) => {
const params = await props.params
const {
datasetId,
} = params
return (
<DatasetUpdateForm datasetId={datasetId} />
)
}
export default Create

View File

@@ -0,0 +1,20 @@
import React from 'react'
import Main from '@/app/components/datasets/documents'
export type IProps = {
params: Promise<{ datasetId: string }>
}
const Documents = async (props: IProps) => {
const params = await props.params
const {
datasetId,
} = params
return (
<Main datasetId={datasetId} />
)
}
export default Documents

View File

@@ -0,0 +1,9 @@
.logTable td {
padding: 7px 8px;
box-sizing: border-box;
max-width: 200px;
}
.pagination li {
list-style: none;
}

View File

@@ -0,0 +1,20 @@
import React from 'react'
import Main from '@/app/components/datasets/hit-testing'
type Props = {
params: Promise<{ datasetId: string }>
}
const HitTesting = async (props: Props) => {
const params = await props.params
const {
datasetId,
} = params
return (
<Main datasetId={datasetId} />
)
}
export default HitTesting

View File

@@ -0,0 +1,148 @@
'use client'
import type { FC } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useTranslation } from 'react-i18next'
import type { RemixiconComponentType } from '@remixicon/react'
import {
RiEqualizer2Fill,
RiEqualizer2Line,
RiFileTextFill,
RiFileTextLine,
RiFocus2Fill,
RiFocus2Line,
} from '@remixicon/react'
import AppSideBar from '@/app/components/app-sidebar'
import Loading from '@/app/components/base/loading'
import DatasetDetailContext from '@/context/dataset-detail'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore } from '@/app/components/app/store'
import { useAppContext } from '@/context/app-context'
import { PipelineFill, PipelineLine } from '@/app/components/base/icons/src/vender/pipeline'
import { useDatasetDetail, useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import useDocumentTitle from '@/hooks/use-document-title'
import ExtraInfo from '@/app/components/datasets/extra-info'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
export type IAppDetailLayoutProps = {
children: React.ReactNode
params: { datasetId: string }
}
const DatasetDetailLayout: FC<IAppDetailLayoutProps> = (props) => {
const {
children,
params: { datasetId },
} = props
const { t } = useTranslation()
const pathname = usePathname()
const hideSideBar = pathname.endsWith('documents/create') || pathname.endsWith('documents/create-from-pipeline')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
})
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const { data: datasetRes, error, refetch: mutateDatasetRes } = useDatasetDetail(datasetId)
const { data: relatedApps } = useDatasetRelatedApps(datasetId)
const isButtonDisabledWithPipeline = useMemo(() => {
if (!datasetRes)
return true
if (datasetRes.provider === 'external')
return false
if (datasetRes.runtime_mode === 'general')
return false
return !datasetRes.is_published
}, [datasetRes])
const navigation = useMemo(() => {
const baseNavigation = [
{
name: t('common.datasetMenus.hitTesting'),
href: `/datasets/${datasetId}/hitTesting`,
icon: RiFocus2Line,
selectedIcon: RiFocus2Fill,
disabled: isButtonDisabledWithPipeline,
},
{
name: t('common.datasetMenus.settings'),
href: `/datasets/${datasetId}/settings`,
icon: RiEqualizer2Line,
selectedIcon: RiEqualizer2Fill,
disabled: false,
},
]
if (datasetRes?.provider !== 'external') {
baseNavigation.unshift({
name: t('common.datasetMenus.pipeline'),
href: `/datasets/${datasetId}/pipeline`,
icon: PipelineLine as RemixiconComponentType,
selectedIcon: PipelineFill as RemixiconComponentType,
disabled: false,
})
baseNavigation.unshift({
name: t('common.datasetMenus.documents'),
href: `/datasets/${datasetId}/documents`,
icon: RiFileTextLine,
selectedIcon: RiFileTextFill,
disabled: isButtonDisabledWithPipeline,
})
}
return baseNavigation
}, [t, datasetId, isButtonDisabledWithPipeline, datasetRes?.provider])
useDocumentTitle(datasetRes?.name || t('common.menus.datasets'))
const setAppSidebarExpand = useStore(state => state.setAppSidebarExpand)
useEffect(() => {
const localeMode = localStorage.getItem('app-detail-collapse-or-expand') || 'expand'
const mode = isMobile ? 'collapse' : 'expand'
setAppSidebarExpand(isMobile ? mode : localeMode)
}, [isMobile, setAppSidebarExpand])
if (!datasetRes && !error)
return <Loading type='app' />
return (
<div
className={cn(
'flex grow overflow-hidden',
hideHeader && isPipelineCanvas ? '' : 'rounded-t-2xl border-t border-effects-highlight',
)}
>
<DatasetDetailContext.Provider value={{
indexingTechnique: datasetRes?.indexing_technique,
dataset: datasetRes,
mutateDatasetRes,
}}>
{!hideSideBar && (
<AppSideBar
navigation={navigation}
extraInfo={
!isCurrentWorkspaceDatasetOperator
? mode => <ExtraInfo relatedApps={relatedApps} expand={mode === 'expand'} documentCount={datasetRes?.document_count} />
: undefined
}
iconType='dataset'
/>
)}
<div className='grow overflow-hidden bg-background-default-subtle'>{children}</div>
</DatasetDetailContext.Provider>
</div>
)
}
export default React.memo(DatasetDetailLayout)

View File

@@ -0,0 +1,17 @@
import Main from './layout-main'
const DatasetDetailLayout = async (
props: {
children: React.ReactNode
params: Promise<{ datasetId: string }>
},
) => {
const params = await props.params
const {
children,
} = props
return <Main params={(await params)}>{children}</Main>
}
export default DatasetDetailLayout

View File

@@ -0,0 +1,11 @@
'use client'
import RagPipeline from '@/app/components/rag-pipeline'
const PipelinePage = () => {
return (
<div className='h-full w-full overflow-x-auto'>
<RagPipeline />
</div>
)
}
export default PipelinePage

View File

@@ -0,0 +1,20 @@
import React from 'react'
import { getLocaleOnServer, useTranslation as translate } from '@/i18n-config/server'
import Form from '@/app/components/datasets/settings/form'
const Settings = async () => {
const locale = await getLocaleOnServer()
const { t } = await translate(locale, 'dataset-settings')
return (
<div className='h-full overflow-y-auto'>
<div className='flex flex-col gap-y-0.5 px-6 pb-2 pt-3'>
<div className='system-xl-semibold text-text-primary'>{t('title')}</div>
<div className='system-sm-regular text-text-tertiary'>{t('desc')}</div>
</div>
<Form />
</div>
)
}
export default Settings

View File

@@ -0,0 +1,16 @@
import type { FC } from 'react'
import React from 'react'
export type IDatasetDetail = {
children: React.ReactNode
}
const AppDetail: FC<IDatasetDetail> = ({ children }) => {
return (
<>
{children}
</>
)
}
export default React.memo(AppDetail)

View File

@@ -0,0 +1,8 @@
import React from 'react'
import ExternalKnowledgeBaseConnector from '@/app/components/datasets/external-knowledge-base/connector'
const ExternalKnowledgeBaseCreation = () => {
return <ExternalKnowledgeBaseConnector />
}
export default ExternalKnowledgeBaseCreation

View File

@@ -0,0 +1,10 @@
import React from 'react'
import CreateFromPipeline from '@/app/components/datasets/create-from-pipeline'
const DatasetCreation = async () => {
return (
<CreateFromPipeline />
)
}
export default DatasetCreation

View File

@@ -0,0 +1,10 @@
import React from 'react'
import DatasetUpdateForm from '@/app/components/datasets/create'
const DatasetCreation = async () => {
return (
<DatasetUpdateForm />
)
}
export default DatasetCreation

View File

@@ -0,0 +1,30 @@
'use client'
import Loading from '@/app/components/base/loading'
import { useAppContext } from '@/context/app-context'
import { ExternalApiPanelProvider } from '@/context/external-api-panel-context'
import { ExternalKnowledgeApiProvider } from '@/context/external-knowledge-api-context'
import { useRouter } from 'next/navigation'
import { useEffect } from 'react'
export default function DatasetsLayout({ children }: { children: React.ReactNode }) {
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, currentWorkspace, isLoadingCurrentWorkspace } = useAppContext()
const router = useRouter()
useEffect(() => {
if (isLoadingCurrentWorkspace || !currentWorkspace.id)
return
if (!(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator))
router.replace('/apps')
}, [isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator, isLoadingCurrentWorkspace, currentWorkspace, router])
if (isLoadingCurrentWorkspace || !(isCurrentWorkspaceEditor || isCurrentWorkspaceDatasetOperator))
return <Loading type='app' />
return (
<ExternalKnowledgeApiProvider>
<ExternalApiPanelProvider>
{children}
</ExternalApiPanelProvider>
</ExternalKnowledgeApiProvider>
)
}

View File

@@ -0,0 +1,7 @@
import List from '../../components/datasets/list'
const DatasetList = async () => {
return <List />
}
export default DatasetList

View File

@@ -0,0 +1,29 @@
'use client'
import {
useEffect,
useMemo,
} from 'react'
import {
useRouter,
useSearchParams,
} from 'next/navigation'
import EducationApplyPage from '@/app/education-apply/education-apply-page'
import { useProviderContext } from '@/context/provider-context'
export default function EducationApply() {
const router = useRouter()
const { enableEducationPlan } = useProviderContext()
const searchParams = useSearchParams()
const token = searchParams.get('token')
const showEducationApplyPage = useMemo(() => {
return enableEducationPlan && token
}, [enableEducationPlan, token])
useEffect(() => {
if (!showEducationApplyPage)
router.replace('/')
}, [showEducationApplyPage, router])
return <EducationApplyPage />
}

View File

@@ -0,0 +1,8 @@
import React from 'react'
import AppList from '@/app/components/explore/app-list'
const Apps = () => {
return <AppList />
}
export default React.memo(Apps)

View File

@@ -0,0 +1,18 @@
import React from 'react'
import Main from '@/app/components/explore/installed-app'
export type IInstalledAppProps = {
params?: Promise<{
appId: string
}>
}
// Using Next.js page convention for async server components
async function InstalledApp({ params }: IInstalledAppProps) {
const { appId } = await (params ?? Promise.reject(new Error('Missing params')))
return (
<Main id={appId} />
)
}
export default InstalledApp

View File

@@ -0,0 +1,18 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import React from 'react'
import { useTranslation } from 'react-i18next'
import ExploreClient from '@/app/components/explore'
import useDocumentTitle from '@/hooks/use-document-title'
const ExploreLayout: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.explore'))
return (
<ExploreClient>
{children}
</ExploreClient>
)
}
export default React.memo(ExploreLayout)

View File

@@ -0,0 +1,43 @@
import React from 'react'
import type { ReactNode } from 'react'
import SwrInitializer from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import Header from '@/app/components/header'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
import GotoAnything from '@/app/components/goto-anything'
import Zendesk from '@/app/components/base/zendesk'
import PartnerStack from '../components/billing/partner-stack'
import ReadmePanel from '@/app/components/plugins/readme-panel'
import Splash from '../components/splash'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<SwrInitializer>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
{children}
<PartnerStack />
<ReadmePanel />
<GotoAnything />
<Splash />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<Zendesk />
</SwrInitializer>
</>
)
}
export default Layout

View File

@@ -0,0 +1,16 @@
import PluginPage from '@/app/components/plugins/plugin-page'
import PluginsPanel from '@/app/components/plugins/plugin-page/plugins-panel'
import Marketplace from '@/app/components/plugins/marketplace'
import { getLocaleOnServer } from '@/i18n-config/server'
const PluginList = async () => {
const locale = await getLocaleOnServer()
return (
<PluginPage
plugins={<PluginsPanel />}
marketplace={<Marketplace locale={locale} pluginTypeSwitchClassName='top-[60px]' searchBoxAutoAnimate={false} showSearchParams={false} />}
/>
)
}
export default PluginList

View File

@@ -0,0 +1,22 @@
'use client'
import type { FC } from 'react'
import { useRouter } from 'next/navigation'
import React, { useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import ToolProviderList from '@/app/components/tools/provider-list'
import { useAppContext } from '@/context/app-context'
import useDocumentTitle from '@/hooks/use-document-title'
const ToolsList: FC = () => {
const router = useRouter()
const { isCurrentWorkspaceDatasetOperator } = useAppContext()
const { t } = useTranslation()
useDocumentTitle(t('common.menus.tools'))
useEffect(() => {
if (isCurrentWorkspaceDatasetOperator)
return router.replace('/datasets')
}, [isCurrentWorkspaceDatasetOperator, router])
return <ToolProviderList />
}
export default React.memo(ToolsList)

View File

@@ -0,0 +1,14 @@
'use client'
import React from 'react'
import ChatWithHistoryWrap from '@/app/components/base/chat/chat-with-history'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Chat = () => {
return (
<AuthenticatedLayout>
<ChatWithHistoryWrap />
</AuthenticatedLayout>
)
}
export default React.memo(Chat)

View File

@@ -0,0 +1,14 @@
'use client'
import React from 'react'
import EmbeddedChatbot from '@/app/components/base/chat/embedded-chatbot'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Chatbot = () => {
return (
<AuthenticatedLayout>
<EmbeddedChatbot />
</AuthenticatedLayout>
)
}
export default React.memo(Chatbot)

View File

@@ -0,0 +1,13 @@
import React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Completion = () => {
return (
<AuthenticatedLayout>
<Main />
</AuthenticatedLayout>
)
}
export default React.memo(Completion)

View File

@@ -0,0 +1,85 @@
'use client'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { useWebAppStore } from '@/context/web-app-context'
import { useGetUserCanAccessApp } from '@/service/access-control'
import { useGetWebAppInfo, useGetWebAppMeta, useGetWebAppParams } from '@/service/use-share'
import { webAppLogout } from '@/service/webapp-auth'
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
const AuthenticatedLayout = ({ children }: { children: React.ReactNode }) => {
const { t } = useTranslation()
const shareCode = useWebAppStore(s => s.shareCode)
const updateAppInfo = useWebAppStore(s => s.updateAppInfo)
const updateAppParams = useWebAppStore(s => s.updateAppParams)
const updateWebAppMeta = useWebAppStore(s => s.updateWebAppMeta)
const updateUserCanAccessApp = useWebAppStore(s => s.updateUserCanAccessApp)
const { isFetching: isFetchingAppParams, data: appParams, error: appParamsError } = useGetWebAppParams()
const { isFetching: isFetchingAppInfo, data: appInfo, error: appInfoError } = useGetWebAppInfo()
const { isFetching: isFetchingAppMeta, data: appMeta, error: appMetaError } = useGetWebAppMeta()
const { data: userCanAccessApp, error: useCanAccessAppError } = useGetUserCanAccessApp({ appId: appInfo?.app_id, isInstalledApp: false })
useEffect(() => {
if (appInfo)
updateAppInfo(appInfo)
if (appParams)
updateAppParams(appParams)
if (appMeta)
updateWebAppMeta(appMeta)
updateUserCanAccessApp(Boolean(userCanAccessApp && userCanAccessApp?.result))
}, [appInfo, appMeta, appParams, updateAppInfo, updateAppParams, updateUserCanAccessApp, updateWebAppMeta, userCanAccessApp])
const router = useRouter()
const pathname = usePathname()
const searchParams = useSearchParams()
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.set('redirect_url', pathname)
return `/webapp-signin?${params.toString()}`
}, [searchParams, pathname])
const backToHome = useCallback(async () => {
await webAppLogout(shareCode!)
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, webAppLogout, shareCode])
if (appInfoError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appInfoError.message} />
</div>
}
if (appParamsError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appParamsError.message} />
</div>
}
if (appMetaError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={appMetaError.message} />
</div>
}
if (useCanAccessAppError) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable unknownReason={useCanAccessAppError.message} />
</div>
}
if (userCanAccessApp && !userCanAccessApp.result) {
return <div className='flex h-full flex-col items-center justify-center gap-y-2'>
<AppUnavailable className='h-auto w-auto' code={403} unknownReason='no permission.' />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>
</div>
}
if (isFetchingAppInfo || isFetchingAppParams || isFetchingAppMeta) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
return <>{children}</>
}
export default React.memo(AuthenticatedLayout)

View File

@@ -0,0 +1,110 @@
'use client'
import type { FC, PropsWithChildren } from 'react'
import { useEffect, useState } from 'react'
import { useCallback } from 'react'
import { useWebAppStore } from '@/context/web-app-context'
import { useRouter, useSearchParams } from 'next/navigation'
import AppUnavailable from '@/app/components/base/app-unavailable'
import { useTranslation } from 'react-i18next'
import { webAppLoginStatus, webAppLogout } from '@/service/webapp-auth'
import { fetchAccessToken } from '@/service/share'
import Loading from '@/app/components/base/loading'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
const Splash: FC<PropsWithChildren> = ({ children }) => {
const { t } = useTranslation()
const shareCode = useWebAppStore(s => s.shareCode)
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const message = searchParams.get('message')
const code = searchParams.get('code')
const tokenFromUrl = searchParams.get('web_sso_token')
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams(searchParams)
params.delete('message')
params.delete('code')
return `/webapp-signin?${params.toString()}`
}, [searchParams])
const backToHome = useCallback(async () => {
await webAppLogout(shareCode!)
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, webAppLogout, shareCode])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (message) {
setIsLoading(false)
return
}
if(tokenFromUrl)
setWebAppAccessToken(tokenFromUrl)
const redirectOrFinish = () => {
if (redirectUrl)
router.replace(decodeURIComponent(redirectUrl))
else
setIsLoading(false)
}
const proceedToAuth = () => {
setIsLoading(false)
}
(async () => {
// if access mode is public, user login is always true, but the app login(passport) may be expired
const { userLoggedIn, appLoggedIn } = await webAppLoginStatus(shareCode!, embeddedUserId || undefined)
if (userLoggedIn && appLoggedIn) {
redirectOrFinish()
}
else if (!userLoggedIn && !appLoggedIn) {
proceedToAuth()
}
else if (!userLoggedIn && appLoggedIn) {
redirectOrFinish()
}
else if (userLoggedIn && !appLoggedIn) {
try {
const { access_token } = await fetchAccessToken({
appCode: shareCode!,
userId: embeddedUserId || undefined,
})
setWebAppPassport(shareCode!, access_token)
redirectOrFinish()
}
catch {
await webAppLogout(shareCode!)
proceedToAuth()
}
}
})()
}, [
shareCode,
redirectUrl,
router,
message,
webAppAccessMode,
tokenFromUrl,
embeddedUserId])
if (message) {
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' code={code || t('share.common.appUnavailable')} unknownReason={message} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{code === '403' ? t('common.userProfile.logout') : t('share.login.backToHome')}</span>
</div>
}
if (isLoading) {
return <div className='flex h-full items-center justify-center'>
<Loading />
</div>
}
return <>{children}</>
}
export default Splash

View File

@@ -0,0 +1,17 @@
import type { FC, PropsWithChildren } from 'react'
import WebAppStoreProvider from '@/context/web-app-context'
import Splash from './components/splash'
const Layout: FC<PropsWithChildren> = ({ children }) => {
return (
<div className="h-full min-w-[300px] pb-[env(safe-area-inset-bottom)]">
<WebAppStoreProvider>
<Splash>
{children}
</Splash>
</WebAppStoreProvider>
</div>
)
}
export default Layout

View File

@@ -0,0 +1,99 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppResetPasswordCode, verifyWebAppResetPasswordCode } from '@/service/common'
import I18NContext from '@/context/i18n'
export default function CheckCode() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const email = decodeURIComponent(searchParams.get('email') as string)
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const verify = async () => {
try {
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
})
return
}
setIsLoading(true)
const ret = await verifyWebAppResetPasswordCode({ email, code, token })
if (ret.is_valid) {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(ret.token))
router.push(`/webapp-reset-password/set-password?${params.toString()}`)
}
}
catch (error) { console.error(error) }
finally {
setIsLoading(false)
}
}
const resendCode = async () => {
try {
const res = await sendWebAppResetPasswordCode(email, locale)
if (res.result === 'success') {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
router.replace(`/webapp-reset-password/check-code?${params.toString()}`)
}
}
catch (error) { console.error(error) }
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge text-text-accent-light-mode-only shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span>
{t('login.checkCode.tipsPrefix')}
<strong>{email}</strong>
</span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form action="">
<input type='text' className='hidden' />
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input value={code} onChange={e => setVerifyCode(e.target.value)} maxLength={6} className='mt-1' placeholder={t('login.checkCode.verificationCodePlaceholder') || ''} />
<Button loading={loading} disabled={loading} className='my-3 w-full' variant='primary' onClick={verify}>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
}

View File

@@ -0,0 +1,30 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<div className='flex w-[400px] flex-col'>
{children}
</div>
</div>
{!systemFeatures.branding.enabled && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
}

View File

@@ -0,0 +1,104 @@
'use client'
import Link from 'next/link'
import { RiArrowLeftLine, RiLockPasswordLine } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import { emailRegex } from '@/config'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendResetPasswordCode } from '@/service/common'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
import useDocumentTitle from '@/hooks/use-document-title'
export default function CheckCode() {
const { t } = useTranslation()
useDocumentTitle('')
const searchParams = useSearchParams()
const router = useRouter()
const [email, setEmail] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
setIsLoading(true)
const res = await sendResetPasswordCode(email, locale)
if (res.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(res.data))
params.set('email', encodeURIComponent(email))
router.push(`/webapp-reset-password/check-code?${params.toString()}`)
}
else if (res.code === 'account_not_found') {
Toast.notify({
type: 'error',
message: t('login.error.registrationNotAllowed'),
})
}
else {
Toast.notify({
type: 'error',
message: res.data,
})
}
}
catch (error) {
console.error(error)
}
finally {
setIsLoading(false)
}
}
return <div className='flex flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiLockPasswordLine className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.resetPassword')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.resetPasswordDesc')}
</p>
</div>
<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" disabled={loading} value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.sendVerificationCode')}</Button>
</div>
</div>
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<Link href={`/webapp-signin?${searchParams.toString()}`} className='flex h-9 items-center justify-center text-text-tertiary hover:text-text-primary'>
<div className='inline-block rounded-full bg-background-default-dimmed p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.backToLogin')}</span>
</Link>
</div>
}

View File

@@ -0,0 +1,187 @@
'use client'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from 'classnames'
import { RiCheckboxCircleFill } from '@remixicon/react'
import { useCountDown } from 'ahooks'
import Button from '@/app/components/base/button'
import { changeWebAppPasswordWithToken } from '@/service/common'
import Toast from '@/app/components/base/toast'
import Input from '@/app/components/base/input'
import { validPassword } from '@/config'
const ChangePasswordForm = () => {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const token = decodeURIComponent(searchParams.get('token') || '')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showSuccess, setShowSuccess] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const showErrorMessage = useCallback((message: string) => {
Toast.notify({
type: 'error',
message,
})
}, [])
const getSignInUrl = () => {
return `/webapp-signin?redirect_url=${searchParams.get('redirect_url') || ''}`
}
const AUTO_REDIRECT_TIME = 5000
const [leftTime, setLeftTime] = useState<number | undefined>(undefined)
const [countdown] = useCountDown({
leftTime,
onEnd: () => {
router.replace(getSignInUrl())
},
})
const valid = useCallback(() => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
return false
}
return true
}, [password, confirmPassword, showErrorMessage, t])
const handleChangePassword = useCallback(async () => {
if (!valid())
return
try {
await changeWebAppPasswordWithToken({
url: '/forgot-password/resets',
body: {
token,
new_password: password,
password_confirm: confirmPassword,
},
})
setShowSuccess(true)
setLeftTime(AUTO_REDIRECT_TIME)
}
catch (error) {
console.error(error)
}
}, [password, token, valid, confirmPassword])
return (
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
{!showSuccess && (
<div className='flex flex-col md:w-[400px]'>
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.changePassword')}
</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
{t('login.changePasswordTip')}
</p>
</div>
<div className="mx-auto mt-6 w-full">
<div className="bg-white">
{/* Password */}
<div className='mb-5'>
<label htmlFor="password" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.newPassword')}
</label>
<div className='relative mt-1'>
<Input
id="password" type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
placeholder={t('login.passwordPlaceholder') || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='body-xs-regular mt-1 text-text-secondary'>{t('login.error.passwordInvalid')}</div>
</div>
{/* Confirm Password */}
<div className='mb-5'>
<label htmlFor="confirmPassword" className="system-md-semibold my-2 text-text-secondary">
{t('common.account.confirmPassword')}
</label>
<div className='relative mt-1'>
<Input
id="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
placeholder={t('login.confirmPasswordPlaceholder') || ''}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div>
<Button
variant='primary'
className='w-full'
onClick={handleChangePassword}
>
{t('login.changePasswordBtn')}
</Button>
</div>
</div>
</div>
</div>
)}
{showSuccess && (
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<div className="mb-3 flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle font-bold shadow-lg">
<RiCheckboxCircleFill className='h-6 w-6 text-text-success' />
</div>
<h2 className="title-4xl-semi-bold text-text-primary">
{t('login.passwordChangedTip')}
</h2>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant='primary' className='w-full' onClick={() => {
setLeftTime(undefined)
router.replace(getSignInUrl())
}}>{t('login.passwordChanged')} ({Math.round(countdown / 1000)}) </Button>
</div>
</div>
)}
</div>
)
}
export default ChangePasswordForm

View File

@@ -0,0 +1,144 @@
'use client'
import { RiArrowLeftLine, RiMailSendFill } from '@remixicon/react'
import { useTranslation } from 'react-i18next'
import { type FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Countdown from '@/app/components/signin/countdown'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode, webAppEmailLoginWithCode } from '@/service/common'
import I18NContext from '@/context/i18n'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
import { fetchAccessToken } from '@/service/share'
import { useWebAppStore } from '@/context/web-app-context'
export default function CheckCode() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const email = decodeURIComponent(searchParams.get('email') as string)
const token = decodeURIComponent(searchParams.get('token') as string)
const [code, setVerifyCode] = useState('')
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const codeInputRef = useRef<HTMLInputElement>(null)
const redirectUrl = searchParams.get('redirect_url')
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
const appCode = url.pathname.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const verify = async () => {
try {
const appCode = getAppCodeFromRedirectUrl()
if (!code.trim()) {
Toast.notify({
type: 'error',
message: t('login.checkCode.emptyCode'),
})
return
}
if (!/\d{6}/.test(code)) {
Toast.notify({
type: 'error',
message: t('login.checkCode.invalidCode'),
})
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
})
return
}
setIsLoading(true)
const ret = await webAppEmailLoginWithCode({ email, code, token })
if (ret.result === 'success') {
setWebAppAccessToken(ret.data.access_token)
const { access_token } = await fetchAccessToken({
appCode: appCode!,
userId: embeddedUserId || undefined,
})
setWebAppPassport(appCode!, access_token)
router.replace(decodeURIComponent(redirectUrl))
}
}
catch (error) { console.error(error) }
finally {
setIsLoading(false)
}
}
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
verify()
}
useEffect(() => {
codeInputRef.current?.focus()
}, [])
const resendCode = async () => {
try {
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
const params = new URLSearchParams(searchParams)
params.set('token', encodeURIComponent(ret.data))
router.replace(`/webapp-signin/check-code?${params.toString()}`)
}
}
catch (error) { console.error(error) }
}
return <div className='flex w-[400px] flex-col gap-3'>
<div className='inline-flex h-14 w-14 items-center justify-center rounded-2xl border border-components-panel-border-subtle bg-background-default-dodge shadow-lg'>
<RiMailSendFill className='h-6 w-6 text-2xl text-text-accent-light-mode-only' />
</div>
<div className='pb-4 pt-2'>
<h2 className='title-4xl-semi-bold text-text-primary'>{t('login.checkCode.checkYourEmail')}</h2>
<p className='body-md-regular mt-2 text-text-secondary'>
<span>
{t('login.checkCode.tipsPrefix')}
<strong>{email}</strong>
</span>
<br />
{t('login.checkCode.validTime')}
</p>
</div>
<form onSubmit={handleSubmit}>
<label htmlFor="code" className='system-md-semibold mb-1 text-text-secondary'>{t('login.checkCode.verificationCode')}</label>
<Input
ref={codeInputRef}
id='code'
value={code}
onChange={e => setVerifyCode(e.target.value)}
maxLength={6}
className='mt-1'
placeholder={t('login.checkCode.verificationCodePlaceholder') || ''}
/>
<Button type='submit' loading={loading} disabled={loading} className='my-3 w-full' variant='primary'>{t('login.checkCode.verify')}</Button>
<Countdown onResend={resendCode} />
</form>
<div className='py-2'>
<div className='h-px bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div onClick={() => router.back()} className='flex h-9 cursor-pointer items-center justify-center text-text-tertiary'>
<div className='bg-background-default-dimm inline-block rounded-full p-1'>
<RiArrowLeftLine size={12} />
</div>
<span className='system-xs-regular ml-2'>{t('login.back')}</span>
</div>
</div>
}

View File

@@ -0,0 +1,83 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import React, { useCallback, useEffect } from 'react'
import Toast from '@/app/components/base/toast'
import { fetchWebOAuth2SSOUrl, fetchWebOIDCSSOUrl, fetchWebSAMLSSOUrl } from '@/service/share'
import { useGlobalPublicStore } from '@/context/global-public-context'
import { SSOProtocol } from '@/types/feature'
import Loading from '@/app/components/base/loading'
import AppUnavailable from '@/app/components/base/app-unavailable'
const ExternalMemberSSOAuth = () => {
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const showErrorToast = (message: string) => {
Toast.notify({
type: 'error',
message,
})
}
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
const appCode = url.pathname.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const handleSSOLogin = useCallback(async () => {
const appCode = getAppCodeFromRedirectUrl()
if (!appCode || !redirectUrl) {
showErrorToast('redirect url or app code is invalid.')
return
}
switch (systemFeatures.webapp_auth.sso_config.protocol) {
case SSOProtocol.SAML: {
const samlRes = await fetchWebSAMLSSOUrl(appCode, redirectUrl)
router.push(samlRes.url)
break
}
case SSOProtocol.OIDC: {
const oidcRes = await fetchWebOIDCSSOUrl(appCode, redirectUrl)
router.push(oidcRes.url)
break
}
case SSOProtocol.OAuth2: {
const oauth2Res = await fetchWebOAuth2SSOUrl(appCode, redirectUrl)
router.push(oauth2Res.url)
break
}
case '':
break
default:
showErrorToast('SSO protocol is not supported.')
}
}, [getAppCodeFromRedirectUrl, redirectUrl, router, systemFeatures.webapp_auth.sso_config.protocol])
useEffect(() => {
handleSSOLogin()
}, [handleSSOLogin])
if (!systemFeatures.webapp_auth.sso_config.protocol) {
return <div className="flex h-full items-center justify-center">
<AppUnavailable code={403} unknownReason='sso protocol is invalid.' />
</div>
}
return (
<div className="flex h-full items-center justify-center">
<Loading />
</div>
)
}
export default React.memo(ExternalMemberSSOAuth)

View File

@@ -0,0 +1,68 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import { emailRegex } from '@/config'
import Toast from '@/app/components/base/toast'
import { sendWebAppEMailLoginCode } from '@/service/common'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
import I18NContext from '@/context/i18n'
import { noop } from 'lodash-es'
export default function MailAndCodeAuth() {
const { t } = useTranslation()
const router = useRouter()
const searchParams = useSearchParams()
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [loading, setIsLoading] = useState(false)
const { locale } = useContext(I18NContext)
const handleGetEMailVerificationCode = async () => {
try {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
setIsLoading(true)
const ret = await sendWebAppEMailLoginCode(email, locale)
if (ret.result === 'success') {
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
const params = new URLSearchParams(searchParams)
params.set('email', encodeURIComponent(email))
params.set('token', encodeURIComponent(ret.data))
router.push(`/webapp-signin/check-code?${params.toString()}`)
}
}
catch (error) {
console.error(error)
}
finally {
setIsLoading(false)
}
}
return (<form onSubmit={noop}>
<input type='text' className='hidden' />
<div className='mb-2'>
<label htmlFor="email" className='system-md-semibold my-2 text-text-secondary'>{t('login.email')}</label>
<div className='mt-1'>
<Input id='email' type="email" value={email} placeholder={t('login.emailPlaceholder') as string} onChange={e => setEmail(e.target.value)} />
</div>
<div className='mt-3'>
<Button loading={loading} disabled={loading || !email} variant='primary' className='w-full' onClick={handleGetEMailVerificationCode}>{t('login.signup.verifyMail')}</Button>
</div>
</div>
</form>
)
}

View File

@@ -0,0 +1,176 @@
'use client'
import Link from 'next/link'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import { useContext } from 'use-context-selector'
import Button from '@/app/components/base/button'
import Toast from '@/app/components/base/toast'
import { emailRegex } from '@/config'
import { webAppLogin } from '@/service/common'
import Input from '@/app/components/base/input'
import I18NContext from '@/context/i18n'
import { useWebAppStore } from '@/context/web-app-context'
import { noop } from 'lodash-es'
import { fetchAccessToken } from '@/service/share'
import { setWebAppAccessToken, setWebAppPassport } from '@/service/webapp-auth'
type MailAndPasswordAuthProps = {
isEmailSetup: boolean
}
export default function MailAndPasswordAuth({ isEmailSetup }: MailAndPasswordAuthProps) {
const { t } = useTranslation()
const { locale } = useContext(I18NContext)
const router = useRouter()
const searchParams = useSearchParams()
const [showPassword, setShowPassword] = useState(false)
const emailFromLink = decodeURIComponent(searchParams.get('email') || '')
const [email, setEmail] = useState(emailFromLink)
const [password, setPassword] = useState('')
const [isLoading, setIsLoading] = useState(false)
const redirectUrl = searchParams.get('redirect_url')
const embeddedUserId = useWebAppStore(s => s.embeddedUserId)
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
const appCode = url.pathname.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const appCode = getAppCodeFromRedirectUrl()
const handleEmailPasswordLogin = async () => {
if (!email) {
Toast.notify({ type: 'error', message: t('login.error.emailEmpty') })
return
}
if (!emailRegex.test(email)) {
Toast.notify({
type: 'error',
message: t('login.error.emailInValid'),
})
return
}
if (!password?.trim()) {
Toast.notify({ type: 'error', message: t('login.error.passwordEmpty') })
return
}
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: t('login.error.redirectUrlMissing'),
})
return
}
try {
setIsLoading(true)
const loginData: Record<string, any> = {
email,
password,
language: locale,
remember_me: true,
}
const res = await webAppLogin({
url: '/login',
body: loginData,
})
if (res.result === 'success') {
setWebAppAccessToken(res.data.access_token)
const { access_token } = await fetchAccessToken({
appCode: appCode!,
userId: embeddedUserId || undefined,
})
setWebAppPassport(appCode!, access_token)
router.replace(decodeURIComponent(redirectUrl))
}
else {
Toast.notify({
type: 'error',
message: res.data,
})
}
}
catch (e: any) {
if (e.code === 'authentication_failed')
Toast.notify({ type: 'error', message: e.message })
}
finally {
setIsLoading(false)
}
}
return <form onSubmit={noop}>
<div className='mb-3'>
<label htmlFor="email" className="system-md-semibold my-2 text-text-secondary">
{t('login.email')}
</label>
<div className="mt-1">
<Input
value={email}
onChange={e => setEmail(e.target.value)}
id="email"
type="email"
autoComplete="email"
placeholder={t('login.emailPlaceholder') || ''}
tabIndex={1}
/>
</div>
</div>
<div className='mb-3'>
<label htmlFor="password" className="my-2 flex items-center justify-between">
<span className='system-md-semibold text-text-secondary'>{t('login.password')}</span>
<Link
href={`/webapp-reset-password?${searchParams.toString()}`}
className={`system-xs-regular ${isEmailSetup ? 'text-components-button-secondary-accent-text' : 'pointer-events-none text-components-button-secondary-accent-text-disabled'}`}
tabIndex={isEmailSetup ? 0 : -1}
aria-disabled={!isEmailSetup}
>
{t('login.forget')}
</Link>
</label>
<div className="relative mt-1">
<Input
value={password}
onChange={e => setPassword(e.target.value)}
id="password"
onKeyDown={(e) => {
if (e.key === 'Enter')
handleEmailPasswordLogin()
}}
type={showPassword ? 'text' : 'password'}
autoComplete="current-password"
placeholder={t('login.passwordPlaceholder') || ''}
tabIndex={2}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</div>
<div className='mb-2'>
<Button
tabIndex={2}
variant='primary'
onClick={handleEmailPasswordLogin}
disabled={isLoading || !email || !password}
className="w-full"
>{t('login.signBtn')}</Button>
</div>
</form>
}

View File

@@ -0,0 +1,91 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import { useCallback } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Lock01 } from '@/app/components/base/icons/src/vender/solid/security'
import Toast from '@/app/components/base/toast'
import Button from '@/app/components/base/button'
import { SSOProtocol } from '@/types/feature'
import { fetchMembersOAuth2SSOUrl, fetchMembersOIDCSSOUrl, fetchMembersSAMLSSOUrl } from '@/service/share'
type SSOAuthProps = {
protocol: SSOProtocol | ''
}
const SSOAuth: FC<SSOAuthProps> = ({
protocol,
}) => {
const router = useRouter()
const { t } = useTranslation()
const searchParams = useSearchParams()
const redirectUrl = searchParams.get('redirect_url')
const getAppCodeFromRedirectUrl = useCallback(() => {
if (!redirectUrl)
return null
const url = new URL(`${window.location.origin}${decodeURIComponent(redirectUrl)}`)
const appCode = url.pathname.split('/').pop()
if (!appCode)
return null
return appCode
}, [redirectUrl])
const [isLoading, setIsLoading] = useState(false)
const handleSSOLogin = () => {
const appCode = getAppCodeFromRedirectUrl()
if (!redirectUrl || !appCode) {
Toast.notify({
type: 'error',
message: 'invalid redirect URL or app code',
})
return
}
setIsLoading(true)
if (protocol === SSOProtocol.SAML) {
fetchMembersSAMLSSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else if (protocol === SSOProtocol.OIDC) {
fetchMembersOIDCSSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else if (protocol === SSOProtocol.OAuth2) {
fetchMembersOAuth2SSOUrl(appCode, redirectUrl).then((res) => {
router.push(res.url)
}).finally(() => {
setIsLoading(false)
})
}
else {
Toast.notify({
type: 'error',
message: 'invalid SSO protocol',
})
setIsLoading(false)
}
}
return (
<Button
tabIndex={0}
onClick={() => { handleSSOLogin() }}
disabled={isLoading}
className="w-full"
>
<Lock01 className='mr-2 h-5 w-5 text-text-accent-light-mode-only' />
<span className="truncate">{t('login.withSSO')}</span>
</Button>
)
}
export default SSOAuth

View File

@@ -0,0 +1,28 @@
'use client'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import type { PropsWithChildren } from 'react'
import { useTranslation } from 'react-i18next'
export default function SignInLayout({ children }: PropsWithChildren) {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
useDocumentTitle(t('login.webapp.login'))
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
{/* <Header /> */}
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className='flex justify-center md:w-[440px] lg:w-[600px]'>
{children}
</div>
</div>
{systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
}

View File

@@ -0,0 +1,177 @@
'use client'
import React, { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Link from 'next/link'
import { RiContractLine, RiDoorLockLine, RiErrorWarningFill } from '@remixicon/react'
import Loading from '@/app/components/base/loading'
import MailAndCodeAuth from './components/mail-and-code-auth'
import MailAndPasswordAuth from './components/mail-and-password-auth'
import SSOAuth from './components/sso-auth'
import cn from '@/utils/classnames'
import { LicenseStatus } from '@/types/feature'
import { IS_CE_EDITION } from '@/config'
import { useGlobalPublicStore } from '@/context/global-public-context'
const NormalForm = () => {
const { t } = useTranslation()
const [isLoading, setIsLoading] = useState(true)
const { systemFeatures } = useGlobalPublicStore()
const [authType, updateAuthType] = useState<'code' | 'password'>('password')
const [showORLine, setShowORLine] = useState(false)
const [allMethodsAreDisabled, setAllMethodsAreDisabled] = useState(false)
const init = useCallback(async () => {
try {
setAllMethodsAreDisabled(!systemFeatures.enable_social_oauth_login && !systemFeatures.enable_email_code_login && !systemFeatures.enable_email_password_login && !systemFeatures.sso_enforced_for_signin)
setShowORLine((systemFeatures.enable_social_oauth_login || systemFeatures.sso_enforced_for_signin) && (systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login))
updateAuthType(systemFeatures.enable_email_password_login ? 'password' : 'code')
}
catch (error) {
console.error(error)
setAllMethodsAreDisabled(true)
}
finally { setIsLoading(false) }
}, [systemFeatures])
useEffect(() => {
init()
}, [init])
if (isLoading) {
return <div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
<Loading type='area' />
</div>
}
if (systemFeatures.license?.status === LicenseStatus.LOST) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseLost')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseLostTip')}</p>
</div>
</div>
</div>
}
if (systemFeatures.license?.status === LicenseStatus.EXPIRED) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseExpired')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseExpiredTip')}</p>
</div>
</div>
</div>
}
if (systemFeatures.license?.status === LicenseStatus.INACTIVE) {
return <div className='mx-auto mt-8 w-full'>
<div className='relative'>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg relative mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiContractLine className='h-5 w-5' />
<RiErrorWarningFill className='absolute -right-1 -top-1 h-4 w-4 text-text-warning-secondary' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.licenseInactive')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.licenseInactiveTip')}</p>
</div>
</div>
</div>
}
return (
<>
<div className="mx-auto mt-8 w-full">
<div className="mx-auto w-full">
<h2 className="title-4xl-semi-bold text-text-primary">{t('login.pageTitle')}</h2>
{!systemFeatures.branding.enabled && <p className='body-md-regular mt-2 text-text-tertiary'>{t('login.welcome')}</p>}
</div>
<div className="relative">
<div className="mt-6 flex flex-col gap-3">
{systemFeatures.sso_enforced_for_signin && <div className='w-full'>
<SSOAuth protocol={systemFeatures.sso_enforced_for_signin_protocol} />
</div>}
</div>
{showORLine && <div className="relative mt-6">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
<div className="relative flex justify-center">
<span className="system-xs-medium-uppercase px-2 text-text-tertiary">{t('login.or')}</span>
</div>
</div>}
{
(systemFeatures.enable_email_code_login || systemFeatures.enable_email_password_login) && <>
{systemFeatures.enable_email_code_login && authType === 'code' && <>
<MailAndCodeAuth />
{systemFeatures.enable_email_password_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('password') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.usePassword')}</span>
</div>}
</>}
{systemFeatures.enable_email_password_login && authType === 'password' && <>
<MailAndPasswordAuth isEmailSetup={systemFeatures.is_email_setup} />
{systemFeatures.enable_email_code_login && <div className='cursor-pointer py-1 text-center' onClick={() => { updateAuthType('code') }}>
<span className='system-xs-medium text-components-button-secondary-accent-text'>{t('login.useVerificationCode')}</span>
</div>}
</>}
</>
}
{allMethodsAreDisabled && <>
<div className="rounded-lg bg-gradient-to-r from-workflow-workflow-progress-bg-1 to-workflow-workflow-progress-bg-2 p-4">
<div className='shadows-shadow-lg mb-2 flex h-10 w-10 items-center justify-center rounded-xl bg-components-card-bg shadow'>
<RiDoorLockLine className='h-5 w-5' />
</div>
<p className='system-sm-medium text-text-primary'>{t('login.noLoginMethod')}</p>
<p className='system-xs-regular mt-1 text-text-tertiary'>{t('login.noLoginMethodTip')}</p>
</div>
<div className="relative my-2 py-2">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className='h-px w-full bg-gradient-to-r from-background-gradient-mask-transparent via-divider-regular to-background-gradient-mask-transparent'></div>
</div>
</div>
</>}
{!systemFeatures.branding.enabled && <>
<div className="system-xs-regular mt-2 block w-full text-text-tertiary">
{t('login.tosDesc')}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/terms'
>{t('login.tos')}</Link>
&nbsp;&&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
target='_blank' rel='noopener noreferrer'
href='https://dify.ai/privacy'
>{t('login.pp')}</Link>
</div>
{IS_CE_EDITION && <div className="w-hull system-xs-regular mt-2 block text-text-tertiary">
{t('login.goToInit')}
&nbsp;
<Link
className='system-xs-medium text-text-secondary hover:underline'
href='/install'
>{t('login.setAdminAccount')}</Link>
</div>}
</>}
</div>
</div>
</>
)
}
export default NormalForm

View File

@@ -0,0 +1,62 @@
'use client'
import { useRouter, useSearchParams } from 'next/navigation'
import type { FC } from 'react'
import React, { useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import { useGlobalPublicStore } from '@/context/global-public-context'
import AppUnavailable from '@/app/components/base/app-unavailable'
import NormalForm from './normalForm'
import { AccessMode } from '@/models/access-control'
import ExternalMemberSsoAuth from './components/external-member-sso-auth'
import { useWebAppStore } from '@/context/web-app-context'
import { webAppLogout } from '@/service/webapp-auth'
const WebSSOForm: FC = () => {
const { t } = useTranslation()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const webAppAccessMode = useWebAppStore(s => s.webAppAccessMode)
const searchParams = useSearchParams()
const router = useRouter()
const redirectUrl = searchParams.get('redirect_url')
const getSigninUrl = useCallback(() => {
const params = new URLSearchParams()
params.append('redirect_url', redirectUrl || '')
return `/webapp-signin?${params.toString()}`
}, [redirectUrl])
const shareCode = useWebAppStore(s => s.shareCode)
const backToHome = useCallback(async () => {
await webAppLogout(shareCode!)
const url = getSigninUrl()
router.replace(url)
}, [getSigninUrl, router, webAppLogout, shareCode])
if (!redirectUrl) {
return <div className='flex h-full items-center justify-center'>
<AppUnavailable code={t('share.common.appUnavailable')} unknownReason='redirect url is invalid.' />
</div>
}
if (!systemFeatures.webapp_auth.enabled) {
return <div className="flex h-full items-center justify-center">
<p className='system-xs-regular text-text-tertiary'>{t('login.webapp.disabled')}</p>
</div>
}
if (webAppAccessMode && (webAppAccessMode === AccessMode.ORGANIZATION || webAppAccessMode === AccessMode.SPECIFIC_GROUPS_MEMBERS)) {
return <div className='w-full max-w-[400px]'>
<NormalForm />
</div>
}
if (webAppAccessMode && webAppAccessMode === AccessMode.EXTERNAL_MEMBERS)
return <ExternalMemberSsoAuth />
return <div className='flex h-full flex-col items-center justify-center gap-y-4'>
<AppUnavailable className='h-auto w-auto' isUnknownReason={true} />
<span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('share.login.backToHome')}</span>
</div>
}
export default React.memo(WebSSOForm)

View File

@@ -0,0 +1,14 @@
import React from 'react'
import Main from '@/app/components/share/text-generation'
import AuthenticatedLayout from '../../components/authenticated-layout'
const Workflow = () => {
return (
<AuthenticatedLayout>
<Main isWorkflow />
</AuthenticatedLayout>
)
}
export default React.memo(Workflow)

View File

@@ -0,0 +1,175 @@
'use client'
import type { Area } from 'react-easy-crop'
import React, { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useContext } from 'use-context-selector'
import { RiDeleteBin5Line, RiPencilLine } from '@remixicon/react'
import { updateUserProfile } from '@/service/common'
import { ToastContext } from '@/app/components/base/toast'
import ImageInput, { type OnImageInput } from '@/app/components/base/app-icon-picker/ImageInput'
import Modal from '@/app/components/base/modal'
import Divider from '@/app/components/base/divider'
import Button from '@/app/components/base/button'
import Avatar, { type AvatarProps } from '@/app/components/base/avatar'
import { useLocalFileUploader } from '@/app/components/base/image-uploader/hooks'
import type { ImageFile } from '@/types/app'
import getCroppedImg from '@/app/components/base/app-icon-picker/utils'
import { DISABLE_UPLOAD_IMAGE_AS_ICON } from '@/config'
type InputImageInfo = { file: File } | { tempUrl: string; croppedAreaPixels: Area; fileName: string }
type AvatarWithEditProps = AvatarProps & { onSave?: () => void }
const AvatarWithEdit = ({ onSave, ...props }: AvatarWithEditProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const [inputImageInfo, setInputImageInfo] = useState<InputImageInfo>()
const [isShowAvatarPicker, setIsShowAvatarPicker] = useState(false)
const [uploading, setUploading] = useState(false)
const [isShowDeleteConfirm, setIsShowDeleteConfirm] = useState(false)
const [hoverArea, setHoverArea] = useState<string>('left')
const [onAvatarError, setOnAvatarError] = useState(false)
const handleImageInput: OnImageInput = useCallback(async (isCropped: boolean, fileOrTempUrl: string | File, croppedAreaPixels?: Area, fileName?: string) => {
setInputImageInfo(
isCropped
? { tempUrl: fileOrTempUrl as string, croppedAreaPixels: croppedAreaPixels!, fileName: fileName! }
: { file: fileOrTempUrl as File },
)
}, [setInputImageInfo])
const handleSaveAvatar = useCallback(async (uploadedFileId: string) => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: uploadedFileId } })
setIsShowAvatarPicker(false)
onSave?.()
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
}
}, [notify, onSave, t])
const handleDeleteAvatar = useCallback(async () => {
try {
await updateUserProfile({ url: 'account/avatar', body: { avatar: '' } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
setIsShowDeleteConfirm(false)
onSave?.()
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
}
}, [notify, onSave, t])
const { handleLocalFileUpload } = useLocalFileUploader({
limit: 3,
disabled: false,
onUpload: (imageFile: ImageFile) => {
if (imageFile.progress === 100) {
setUploading(false)
setInputImageInfo(undefined)
handleSaveAvatar(imageFile.fileId)
}
// Error
if (imageFile.progress === -1)
setUploading(false)
},
})
const handleSelect = useCallback(async () => {
if (!inputImageInfo)
return
setUploading(true)
if ('file' in inputImageInfo) {
handleLocalFileUpload(inputImageInfo.file)
return
}
const blob = await getCroppedImg(inputImageInfo.tempUrl, inputImageInfo.croppedAreaPixels, inputImageInfo.fileName)
const file = new File([blob], inputImageInfo.fileName, { type: blob.type })
handleLocalFileUpload(file)
}, [handleLocalFileUpload, inputImageInfo])
if (DISABLE_UPLOAD_IMAGE_AS_ICON)
return <Avatar {...props} />
return (
<>
<div>
<div className="group relative">
<Avatar {...props} onError={(x: boolean) => setOnAvatarError(x)} />
<div
className="absolute inset-0 flex cursor-pointer items-center justify-center rounded-full bg-black/50 opacity-0 transition-opacity group-hover:opacity-100"
onClick={() => {
if (hoverArea === 'right' && !onAvatarError)
setIsShowDeleteConfirm(true)
else
setIsShowAvatarPicker(true)
}}
onMouseMove={(e) => {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const isRight = x > rect.width / 2
setHoverArea(isRight ? 'right' : 'left')
}}
>
{hoverArea === 'right' && !onAvatarError ? (
<span className="text-xs text-white">
<RiDeleteBin5Line />
</span>
) : (
<span className="text-xs text-white">
<RiPencilLine />
</span>
)}
</div>
</div>
</div>
<Modal
closable
className="!w-[362px] !p-0"
isShow={isShowAvatarPicker}
onClose={() => setIsShowAvatarPicker(false)}
>
<ImageInput onImageInput={handleImageInput} cropShape='round' />
<Divider className='m-0' />
<div className='flex w-full items-center justify-center gap-2 p-3'>
<Button className='w-full' onClick={() => setIsShowAvatarPicker(false)}>
{t('app.iconPicker.cancel')}
</Button>
<Button variant="primary" className='w-full' disabled={uploading || !inputImageInfo} loading={uploading} onClick={handleSelect}>
{t('app.iconPicker.ok')}
</Button>
</div>
</Modal>
<Modal
closable
className="!w-[362px] !p-6"
isShow={isShowDeleteConfirm}
onClose={() => setIsShowDeleteConfirm(false)}
>
<div className="title-2xl-semi-bold mb-3 text-text-primary">{t('common.avatar.deleteTitle')}</div>
<p className="mb-8 text-text-secondary">{t('common.avatar.deleteDescription')}</p>
<div className="flex w-full items-center justify-center gap-2">
<Button className="w-full" onClick={() => setIsShowDeleteConfirm(false)}>
{t('common.operation.cancel')}
</Button>
<Button variant="warning" className="w-full" onClick={handleDeleteAvatar}>
{t('common.operation.delete')}
</Button>
</div>
</Modal>
</>
)
}
export default AvatarWithEdit

View File

@@ -0,0 +1,382 @@
import React, { useState } from 'react'
import { Trans, useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import { ToastContext } from '@/app/components/base/toast'
import { RiCloseLine } from '@remixicon/react'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import Input from '@/app/components/base/input'
import {
checkEmailExisted,
resetEmail,
sendVerifyCode,
verifyEmail,
} from '@/service/common'
import { noop } from 'lodash-es'
import { asyncRunSafe } from '@/utils'
import type { ResponseError } from '@/service/fetch'
import { useLogout } from '@/service/use-common'
type Props = {
show: boolean
onClose: () => void
email: string
}
enum STEP {
start = 'start',
verifyOrigin = 'verifyOrigin',
newEmail = 'newEmail',
verifyNew = 'verifyNew',
}
const EmailChangeModal = ({ onClose, email, show }: Props) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const router = useRouter()
const [step, setStep] = useState<STEP>(STEP.start)
const [code, setCode] = useState<string>('')
const [mail, setMail] = useState<string>('')
const [time, setTime] = useState<number>(0)
const [stepToken, setStepToken] = useState<string>('')
const [newEmailExited, setNewEmailExited] = useState<boolean>(false)
const [unAvailableEmail, setUnAvailableEmail] = useState<boolean>(false)
const [isCheckingEmail, setIsCheckingEmail] = useState<boolean>(false)
const startCount = () => {
setTime(60)
const timer = setInterval(() => {
setTime((prev) => {
if (prev <= 0) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
}
const sendEmail = async (email: string, isOrigin: boolean, token?: string) => {
try {
const res = await sendVerifyCode({
email,
phase: isOrigin ? 'old_email' : 'new_email',
token,
})
startCount()
if (res.data)
setStepToken(res.data)
}
catch (error) {
notify({
type: 'error',
message: `Error sending verification code: ${error ? (error as any).message : ''}`,
})
}
}
const verifyEmailAddress = async (email: string, code: string, token: string, callback?: (data?: any) => void) => {
try {
const res = await verifyEmail({
email,
code,
token,
})
if (res.is_valid) {
setStepToken(res.token)
callback?.(res.token)
}
else {
notify({
type: 'error',
message: 'Verifying email failed',
})
}
}
catch (error) {
notify({
type: 'error',
message: `Error verifying email: ${error ? (error as any).message : ''}`,
})
}
}
const sendCodeToOriginEmail = async () => {
await sendEmail(
email,
true,
)
setStep(STEP.verifyOrigin)
}
const handleVerifyOriginEmail = async () => {
await verifyEmailAddress(email, code, stepToken, () => setStep(STEP.newEmail))
setCode('')
}
const isValidEmail = (email: string): boolean => {
const rfc5322emailRegex = /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/
return rfc5322emailRegex.test(email) && email.length <= 254
}
const checkNewEmailExisted = async (email: string) => {
setIsCheckingEmail(true)
try {
await checkEmailExisted({
email,
})
setNewEmailExited(false)
setUnAvailableEmail(false)
}
catch (e: any) {
if (e.status === 400) {
const [, errRespData] = await asyncRunSafe<ResponseError>(e.json())
const { code } = errRespData || {}
if (code === 'email_already_in_use')
setNewEmailExited(true)
if (code === 'account_in_freeze')
setUnAvailableEmail(true)
}
}
finally {
setIsCheckingEmail(false)
}
}
const handleNewEmailValueChange = (mailAddress: string) => {
setMail(mailAddress)
setNewEmailExited(false)
if (isValidEmail(mailAddress))
checkNewEmailExisted(mailAddress)
}
const sendCodeToNewEmail = async () => {
if (!isValidEmail(mail)) {
notify({
type: 'error',
message: 'Invalid email format',
})
return
}
await sendEmail(
mail,
false,
stepToken,
)
setStep(STEP.verifyNew)
}
const { mutateAsync: logout } = useLogout()
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
}
const updateEmail = async (lastToken: string) => {
try {
await resetEmail({
new_email: mail,
token: lastToken,
})
handleLogout()
}
catch (error) {
notify({
type: 'error',
message: `Error changing email: ${error ? (error as any).message : ''}`,
})
}
}
const submitNewEmail = async () => {
await verifyEmailAddress(mail, code, stepToken, updateEmail)
}
return (
<Modal
isShow={show}
onClose={noop}
className='!w-[420px] !p-6'
>
<div className='absolute right-5 top-5 cursor-pointer p-1.5' onClick={onClose}>
<RiCloseLine className='h-5 w-5 text-text-tertiary' />
</div>
{step === STEP.start && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.title')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-medium text-text-warning'>{t('common.account.changeEmail.authTip')}</div>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content1"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'></div>
<div className='space-y-2'>
<Button
className='!w-full'
variant='primary'
onClick={sendCodeToOriginEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyOrigin && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content2"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={handleVerifyOriginEmail}
>
{t('common.account.changeEmail.continue')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToOriginEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
{step === STEP.newEmail && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.newEmail')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>{t('common.account.changeEmail.content3')}</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.emailLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.emailPlaceholder')}
value={mail}
onChange={e => handleNewEmailValueChange(e.target.value)}
destructive={newEmailExited || unAvailableEmail}
/>
{newEmailExited && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.existingEmail')}</div>
)}
{unAvailableEmail && (
<div className='body-xs-regular mt-1 py-0.5 text-text-destructive'>{t('common.account.changeEmail.unAvailableEmail')}</div>
)}
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={!mail || newEmailExited || unAvailableEmail || isCheckingEmail || !isValidEmail(mail)}
className='!w-full'
variant='primary'
onClick={sendCodeToNewEmail}
>
{t('common.account.changeEmail.sendVerifyCode')}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
</>
)}
{step === STEP.verifyNew && (
<>
<div className='title-2xl-semi-bold pb-3 text-text-primary'>{t('common.account.changeEmail.verifyNew')}</div>
<div className='space-y-0.5 pb-2 pt-1'>
<div className='body-md-regular text-text-secondary'>
<Trans
i18nKey="common.account.changeEmail.content4"
components={{ email: <span className='body-md-medium text-text-primary'></span> }}
values={{ email: mail }}
/>
</div>
</div>
<div className='pt-3'>
<div className='system-sm-medium mb-1 flex h-6 items-center text-text-secondary'>{t('common.account.changeEmail.codeLabel')}</div>
<Input
className='!w-full'
placeholder={t('common.account.changeEmail.codePlaceholder')}
value={code}
onChange={e => setCode(e.target.value)}
maxLength={6}
/>
</div>
<div className='mt-3 space-y-2'>
<Button
disabled={code.length !== 6}
className='!w-full'
variant='primary'
onClick={submitNewEmail}
>
{t('common.account.changeEmail.changeTo', { email: mail })}
</Button>
<Button
className='!w-full'
onClick={onClose}
>
{t('common.operation.cancel')}
</Button>
</div>
<div className='system-xs-regular mt-3 flex items-center gap-1 text-text-tertiary'>
<span>{t('common.account.changeEmail.resendTip')}</span>
{time > 0 && (
<span>{t('common.account.changeEmail.resendCount', { count: time })}</span>
)}
{!time && (
<span onClick={sendCodeToNewEmail} className='system-xs-medium cursor-pointer text-text-accent-secondary'>{t('common.account.changeEmail.resend')}</span>
)}
</div>
</>
)}
</Modal>
)
}
export default EmailChangeModal

View File

@@ -0,0 +1,344 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { useTranslation } from 'react-i18next'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { useContext } from 'use-context-selector'
import DeleteAccount from '../delete-account'
import AvatarWithEdit from './AvatarWithEdit'
import Collapse from '@/app/components/header/account-setting/collapse'
import type { IItem } from '@/app/components/header/account-setting/collapse'
import Modal from '@/app/components/base/modal'
import Button from '@/app/components/base/button'
import { updateUserProfile } from '@/service/common'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { ToastContext } from '@/app/components/base/toast'
import AppIcon from '@/app/components/base/app-icon'
import { IS_CE_EDITION } from '@/config'
import Input from '@/app/components/base/input'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useGlobalPublicStore } from '@/context/global-public-context'
import EmailChangeModal from './email-change-modal'
import { validPassword } from '@/config'
import { fetchAppList } from '@/service/apps'
import type { App } from '@/types/app'
const titleClassName = `
system-sm-semibold text-text-secondary
`
const descriptionClassName = `
mt-1 body-xs-regular text-text-tertiary
`
export default function AccountPage() {
const { t } = useTranslation()
const { systemFeatures } = useGlobalPublicStore()
const { data: appList } = useSWR({ url: '/apps', params: { page: 1, limit: 100, name: '' } }, fetchAppList)
const apps = appList?.data || []
const { mutateUserProfile, userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext()
const { notify } = useContext(ToastContext)
const [editNameModalVisible, setEditNameModalVisible] = useState(false)
const [editName, setEditName] = useState('')
const [editing, setEditing] = useState(false)
const [editPasswordModalVisible, setEditPasswordModalVisible] = useState(false)
const [currentPassword, setCurrentPassword] = useState('')
const [password, setPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [showDeleteAccountModal, setShowDeleteAccountModal] = useState(false)
const [showCurrentPassword, setShowCurrentPassword] = useState(false)
const [showPassword, setShowPassword] = useState(false)
const [showConfirmPassword, setShowConfirmPassword] = useState(false)
const [showUpdateEmail, setShowUpdateEmail] = useState(false)
const handleEditName = () => {
setEditNameModalVisible(true)
setEditName(userProfile.name)
}
const handleSaveName = async () => {
try {
setEditing(true)
await updateUserProfile({ url: 'account/name', body: { name: editName } })
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditNameModalVisible(false)
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditing(false)
}
}
const showErrorMessage = (message: string) => {
notify({
type: 'error',
message,
})
}
const valid = () => {
if (!password.trim()) {
showErrorMessage(t('login.error.passwordEmpty'))
return false
}
if (!validPassword.test(password)) {
showErrorMessage(t('login.error.passwordInvalid'))
return false
}
if (password !== confirmPassword) {
showErrorMessage(t('common.account.notEqual'))
return false
}
return true
}
const resetPasswordForm = () => {
setCurrentPassword('')
setPassword('')
setConfirmPassword('')
}
const handleSavePassword = async () => {
if (!valid())
return
try {
setEditing(true)
await updateUserProfile({
url: 'account/password',
body: {
password: currentPassword,
new_password: password,
repeat_new_password: confirmPassword,
},
})
notify({ type: 'success', message: t('common.actionMsg.modifiedSuccessfully') })
mutateUserProfile()
setEditPasswordModalVisible(false)
resetPasswordForm()
setEditing(false)
}
catch (e) {
notify({ type: 'error', message: (e as Error).message })
setEditPasswordModalVisible(false)
setEditing(false)
}
}
const renderAppItem = (item: IItem) => {
const { icon, icon_background, icon_type, icon_url } = item as any
return (
<div className='flex px-3 py-1'>
<div className='mr-3'>
<AppIcon
size='tiny'
iconType={icon_type}
icon={icon}
background={icon_background}
imageUrl={icon_url}
/>
</div>
<div className='system-sm-medium mt-[3px] text-text-secondary'>{item.name}</div>
</div>
)
}
return (
<>
<div className='pb-3 pt-2'>
<h4 className='title-2xl-semi-bold text-text-primary'>{t('common.account.myAccount')}</h4>
</div>
<div className='mb-8 flex items-center rounded-xl bg-gradient-to-r from-background-gradient-bg-fill-chat-bg-2 to-background-gradient-bg-fill-chat-bg-1 p-6'>
<AvatarWithEdit avatar={userProfile.avatar_url} name={userProfile.name} onSave={mutateUserProfile} size={64} />
<div className='ml-4'>
<p className='system-xl-semibold text-text-primary'>
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
</PremiumBadge>
)}
</p>
<p className='system-xs-regular text-text-tertiary'>{userProfile.email}</p>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.name')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.name}</span>
</div>
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={handleEditName}>
{t('common.operation.edit')}
</div>
</div>
</div>
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.email')}</div>
<div className='mt-2 flex w-full items-center justify-between gap-2'>
<div className='system-sm-regular flex-1 rounded-lg bg-components-input-bg-normal p-2 text-components-input-text-filled '>
<span className='pl-1'>{userProfile.email}</span>
</div>
{systemFeatures.enable_change_email && (
<div className='system-sm-medium cursor-pointer rounded-lg bg-components-button-tertiary-bg px-3 py-2 text-components-button-tertiary-text' onClick={() => setShowUpdateEmail(true)}>
{t('common.operation.change')}
</div>
)}
</div>
</div>
{
systemFeatures.enable_email_password_login && (
<div className='mb-8 flex justify-between gap-2'>
<div>
<div className='system-sm-semibold mb-1 text-text-secondary'>{t('common.account.password')}</div>
<div className='body-xs-regular mb-2 text-text-tertiary'>{t('common.account.passwordTip')}</div>
</div>
<Button onClick={() => setEditPasswordModalVisible(true)}>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</Button>
</div>
)
}
<div className='mb-6 border-[1px] border-divider-subtle' />
<div className='mb-8'>
<div className={titleClassName}>{t('common.account.langGeniusAccount')}</div>
<div className={descriptionClassName}>{t('common.account.langGeniusAccountTip')}</div>
{!!apps.length && (
<Collapse
title={`${t('common.account.showAppLength', { length: apps.length })}`}
items={apps.map((app: App) => ({ ...app, key: app.id, name: app.name }))}
renderItem={renderAppItem}
wrapperClassName='mt-2'
/>
)}
{!IS_CE_EDITION && <Button className='mt-2 text-components-button-destructive-secondary-text' onClick={() => setShowDeleteAccountModal(true)}>{t('common.account.delete')}</Button>}
</div>
{
editNameModalVisible && (
<Modal
isShow
onClose={() => setEditNameModalVisible(false)}
className='!w-[420px] !p-6'
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{t('common.account.editName')}</div>
<div className={titleClassName}>{t('common.account.name')}</div>
<Input className='mt-2'
value={editName}
onChange={e => setEditName(e.target.value)}
/>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => setEditNameModalVisible(false)}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing || !editName}
variant='primary'
onClick={handleSaveName}
>
{t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
{
editPasswordModalVisible && (
<Modal
isShow
onClose={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}
className='!w-[420px] !p-6'
>
<div className='title-2xl-semi-bold mb-6 text-text-primary'>{userProfile.is_password_set ? t('common.account.resetPassword') : t('common.account.setPassword')}</div>
{userProfile.is_password_set && (
<>
<div className={titleClassName}>{t('common.account.currentPassword')}</div>
<div className='relative mt-2'>
<Input
type={showCurrentPassword ? 'text' : 'password'}
value={currentPassword}
onChange={e => setCurrentPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowCurrentPassword(!showCurrentPassword)}
>
{showCurrentPassword ? '👀' : '😝'}
</Button>
</div>
</div>
</>
)}
<div className='system-sm-semibold mt-8 text-text-secondary'>
{userProfile.is_password_set ? t('common.account.newPassword') : t('common.account.password')}
</div>
<div className='relative mt-2'>
<Input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={e => setPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='system-sm-semibold mt-8 text-text-secondary'>{t('common.account.confirmPassword')}</div>
<div className='relative mt-2'>
<Input
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={e => setConfirmPassword(e.target.value)}
/>
<div className="absolute inset-y-0 right-0 flex items-center">
<Button
type="button"
variant='ghost'
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? '👀' : '😝'}
</Button>
</div>
</div>
<div className='mt-10 flex justify-end'>
<Button className='mr-2' onClick={() => {
setEditPasswordModalVisible(false)
resetPasswordForm()
}}>{t('common.operation.cancel')}</Button>
<Button
disabled={editing}
variant='primary'
onClick={handleSavePassword}
>
{userProfile.is_password_set ? t('common.operation.reset') : t('common.operation.save')}
</Button>
</div>
</Modal>
)
}
{
showDeleteAccountModal && (
<DeleteAccount
onCancel={() => setShowDeleteAccountModal(false)}
onConfirm={() => setShowDeleteAccountModal(false)}
/>
)
}
{showUpdateEmail && (
<EmailChangeModal
show={showUpdateEmail}
onClose={() => setShowUpdateEmail(false)}
email={userProfile.email}
/>
)}
</>
)
}

View File

@@ -0,0 +1,106 @@
'use client'
import { useTranslation } from 'react-i18next'
import { Fragment } from 'react'
import { useRouter } from 'next/navigation'
import {
RiGraduationCapFill,
} from '@remixicon/react'
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
import Avatar from '@/app/components/base/avatar'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { LogOut01 } from '@/app/components/base/icons/src/vender/line/general'
import PremiumBadge from '@/app/components/base/premium-badge'
import { useLogout } from '@/service/use-common'
export type IAppSelector = {
isMobile: boolean
}
export default function AppSelector() {
const router = useRouter()
const { t } = useTranslation()
const { userProfile } = useAppContext()
const { isEducationAccount } = useProviderContext()
const { mutateAsync: logout } = useLogout()
const handleLogout = async () => {
await logout()
localStorage.removeItem('setup_status')
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
}
return (
<Menu as="div" className="relative inline-block text-left">
{
({ open }) => (
<>
<div>
<MenuButton
className={`
p-1x inline-flex
items-center rounded-[20px] text-sm
text-text-primary
mobile:px-1
${open && 'bg-components-panel-bg-blur'}
`}
>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</MenuButton>
</div>
<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="
absolute -right-2 -top-1 w-60 max-w-80
origin-top-right divide-y divide-divider-subtle rounded-lg bg-components-panel-bg-blur
shadow-lg
"
>
<MenuItem>
<div className='p-1'>
<div className='flex flex-nowrap items-center px-3 py-2'>
<div className='grow'>
<div className='system-md-medium break-all text-text-primary'>
{userProfile.name}
{isEducationAccount && (
<PremiumBadge size='s' color='blue' className='ml-1 !px-2'>
<RiGraduationCapFill className='mr-1 h-3 w-3' />
<span className='system-2xs-medium'>EDU</span>
</PremiumBadge>
)}
</div>
<div className='system-xs-regular break-all text-text-tertiary'>{userProfile.email}</div>
</div>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={32} />
</div>
</div>
</MenuItem>
<MenuItem>
<div className='p-1' onClick={() => handleLogout()}>
<div
className='group flex h-9 cursor-pointer items-center justify-start rounded-lg px-3 hover:bg-state-base-hover'
>
<LogOut01 className='mr-1 flex h-4 w-4 text-text-tertiary' />
<div className='text-[14px] font-normal text-text-secondary'>{t('common.userProfile.logout')}</div>
</div>
</div>
</MenuItem>
</MenuItems>
</Transition>
</>
)
}
</Menu>
)
}

View File

@@ -0,0 +1,48 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import Link from 'next/link'
import { useSendDeleteAccountEmail } from '../state'
import { useAppContext } from '@/context/app-context'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function CheckEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const [userInputEmail, setUserInputEmail] = useState('')
const { isPending: isSendingEmail, mutateAsync: getDeleteEmailVerifyCode } = useSendDeleteAccountEmail()
const handleConfirm = useCallback(async () => {
try {
const ret = await getDeleteEmailVerifyCode()
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [getDeleteEmailVerifyCode, props])
return <>
<div className='body-md-medium py-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.deleteLabel')}</label>
<Input placeholder={t('common.account.deletePlaceholder') as string} onChange={(e) => {
setUserInputEmail(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={userInputEmail !== userProfile.email || isSendingEmail} loading={isSendingEmail} variant='primary' onClick={handleConfirm}>{t('common.account.sendVerificationButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
</div>
</>
}

View File

@@ -0,0 +1,65 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useDeleteAccountFeedback } from '../state'
import { useAppContext } from '@/context/app-context'
import Button from '@/app/components/base/button'
import CustomDialog from '@/app/components/base/dialog'
import Textarea from '@/app/components/base/textarea'
import Toast from '@/app/components/base/toast'
import { useLogout } from '@/service/use-common'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function FeedBack(props: DeleteAccountProps) {
const { t } = useTranslation()
const { userProfile } = useAppContext()
const router = useRouter()
const [userFeedback, setUserFeedback] = useState('')
const { isPending, mutateAsync: sendFeedback } = useDeleteAccountFeedback()
const { mutateAsync: logout } = useLogout()
const handleSuccess = useCallback(async () => {
try {
await logout()
// Tokens are now stored in cookies and cleared by backend
router.push('/signin')
Toast.notify({ type: 'info', message: t('common.account.deleteSuccessTip') })
}
catch (error) { console.error(error) }
}, [router, t])
const handleSubmit = useCallback(async () => {
try {
await sendFeedback({ feedback: userFeedback, email: userProfile.email })
props.onConfirm()
await handleSuccess()
}
catch (error) { console.error(error) }
}, [handleSuccess, userFeedback, sendFeedback, userProfile, props])
const handleSkip = useCallback(() => {
props.onCancel()
handleSuccess()
}, [handleSuccess, props])
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.feedbackTitle')}
className="max-w-[480px]"
footer={false}
>
<label className='system-sm-semibold mb-1 mt-3 flex items-center text-text-secondary'>{t('common.account.feedbackLabel')}</label>
<Textarea rows={6} value={userFeedback} placeholder={t('common.account.feedbackPlaceholder') as string} onChange={(e) => {
setUserFeedback(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' loading={isPending} variant='primary' onClick={handleSubmit}>{t('common.operation.submit')}</Button>
<Button className='w-full' onClick={handleSkip}>{t('common.operation.skip')}</Button>
</div>
</CustomDialog>
}

View File

@@ -0,0 +1,55 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { useAccountDeleteStore, useConfirmDeleteAccount, useSendDeleteAccountEmail } from '../state'
import Input from '@/app/components/base/input'
import Button from '@/app/components/base/button'
import Countdown from '@/app/components/signin/countdown'
const CODE_EXP = /[A-Za-z\d]{6}/gi
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function VerifyEmail(props: DeleteAccountProps) {
const { t } = useTranslation()
const emailToken = useAccountDeleteStore(state => state.sendEmailToken)
const [verificationCode, setVerificationCode] = useState<string>()
const [shouldButtonDisabled, setShouldButtonDisabled] = useState(true)
const { mutate: sendEmail } = useSendDeleteAccountEmail()
const { isPending: isDeleting, mutateAsync: confirmDeleteAccount } = useConfirmDeleteAccount()
useEffect(() => {
setShouldButtonDisabled(!(verificationCode && CODE_EXP.test(verificationCode)) || isDeleting)
}, [verificationCode, isDeleting])
const handleConfirm = useCallback(async () => {
try {
const ret = await confirmDeleteAccount({ code: verificationCode!, token: emailToken })
if (ret.result === 'success')
props.onConfirm()
}
catch (error) { console.error(error) }
}, [emailToken, verificationCode, confirmDeleteAccount, props])
return <>
<div className='body-md-medium pt-1 text-text-destructive'>
{t('common.account.deleteTip')}
</div>
<div className='body-md-regular pb-2 pt-1 text-text-secondary'>
{t('common.account.deletePrivacyLinkTip')}
<Link href='https://dify.ai/privacy' className='text-text-accent'>{t('common.account.deletePrivacyLink')}</Link>
</div>
<label className='system-sm-semibold mb-1 mt-3 flex h-6 items-center text-text-secondary'>{t('common.account.verificationLabel')}</label>
<Input minLength={6} maxLength={6} placeholder={t('common.account.verificationPlaceholder') as string} onChange={(e) => {
setVerificationCode(e.target.value)
}} />
<div className='mt-3 flex w-full flex-col gap-2'>
<Button className='w-full' disabled={shouldButtonDisabled} loading={isDeleting} variant='warning' onClick={handleConfirm}>{t('common.account.permanentlyDeleteButton')}</Button>
<Button className='w-full' onClick={props.onCancel}>{t('common.operation.cancel')}</Button>
<Countdown onResend={sendEmail} />
</div>
</>
}

View File

@@ -0,0 +1,44 @@
'use client'
import { useTranslation } from 'react-i18next'
import { useCallback, useState } from 'react'
import CheckEmail from './components/check-email'
import VerifyEmail from './components/verify-email'
import FeedBack from './components/feed-back'
import CustomDialog from '@/app/components/base/dialog'
import { COUNT_DOWN_KEY, COUNT_DOWN_TIME_MS } from '@/app/components/signin/countdown'
type DeleteAccountProps = {
onCancel: () => void
onConfirm: () => void
}
export default function DeleteAccount(props: DeleteAccountProps) {
const { t } = useTranslation()
const [showVerifyEmail, setShowVerifyEmail] = useState(false)
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false)
const handleEmailCheckSuccess = useCallback(async () => {
try {
setShowVerifyEmail(true)
localStorage.setItem(COUNT_DOWN_KEY, `${COUNT_DOWN_TIME_MS}`)
}
catch (error) { console.error(error) }
}, [])
if (showFeedbackDialog)
return <FeedBack onCancel={props.onCancel} onConfirm={props.onConfirm} />
return <CustomDialog
show={true}
onClose={props.onCancel}
title={t('common.account.delete')}
className="max-w-[480px]"
footer={false}
>
{!showVerifyEmail && <CheckEmail onCancel={props.onCancel} onConfirm={handleEmailCheckSuccess} />}
{showVerifyEmail && <VerifyEmail onCancel={props.onCancel} onConfirm={() => {
setShowFeedbackDialog(true)
}} />}
</CustomDialog>
}

View File

@@ -0,0 +1,39 @@
import { useMutation } from '@tanstack/react-query'
import { create } from 'zustand'
import { sendDeleteAccountCode, submitDeleteAccountFeedback, verifyDeleteAccountCode } from '@/service/common'
type State = {
sendEmailToken: string
setSendEmailToken: (token: string) => void
}
export const useAccountDeleteStore = create<State>(set => ({
sendEmailToken: '',
setSendEmailToken: (token: string) => set({ sendEmailToken: token }),
}))
export function useSendDeleteAccountEmail() {
const updateEmailToken = useAccountDeleteStore(state => state.setSendEmailToken)
return useMutation({
mutationKey: ['delete-account'],
mutationFn: sendDeleteAccountCode,
onSuccess: (ret) => {
if (ret.result === 'success')
updateEmailToken(ret.data)
},
})
}
export function useConfirmDeleteAccount() {
return useMutation({
mutationKey: ['confirm-delete-account'],
mutationFn: verifyDeleteAccountCode,
})
}
export function useDeleteAccountFeedback() {
return useMutation({
mutationKey: ['delete-account-feedback'],
mutationFn: submitDeleteAccountFeedback,
})
}

View File

@@ -0,0 +1,47 @@
'use client'
import { useTranslation } from 'react-i18next'
import { RiArrowRightUpLine, RiRobot2Line } from '@remixicon/react'
import { useRouter } from 'next/navigation'
import Button from '@/app/components/base/button'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { useCallback } from 'react'
import { useGlobalPublicStore } from '@/context/global-public-context'
import Avatar from './avatar'
const Header = () => {
const { t } = useTranslation()
const router = useRouter()
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
const goToStudio = useCallback(() => {
router.push('/apps')
}, [router])
return (
<div className='flex flex-1 items-center justify-between px-4'>
<div className='flex items-center gap-3'>
<div className='flex cursor-pointer items-center' onClick={goToStudio}>
{systemFeatures.branding.enabled && systemFeatures.branding.login_page_logo
? <img
src={systemFeatures.branding.login_page_logo}
className='block h-[22px] w-auto object-contain'
alt='Dify logo'
/>
: <DifyLogo />}
</div>
<div className='h-4 w-[1px] origin-center rotate-[11.31deg] bg-divider-regular' />
<p className='title-3xl-semi-bold relative mt-[-2px] text-text-primary'>{t('common.account.account')}</p>
</div>
<div className='flex shrink-0 items-center gap-3'>
<Button className='system-sm-medium gap-2 px-3 py-2' onClick={goToStudio}>
<RiRobot2Line className='h-4 w-4' />
<p>{t('common.account.studio')}</p>
<RiArrowRightUpLine className='h-4 w-4' />
</Button>
<div className='h-4 w-[1px] bg-divider-regular' />
<Avatar />
</div>
</div>
)
}
export default Header

View File

@@ -0,0 +1,35 @@
import React from 'react'
import type { ReactNode } from 'react'
import Header from './header'
import SwrInitor from '@/app/components/swr-initializer'
import { AppContextProvider } from '@/context/app-context'
import GA, { GaType } from '@/app/components/base/ga'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import { EventEmitterContextProvider } from '@/context/event-emitter'
import { ProviderContextProvider } from '@/context/provider-context'
import { ModalContextProvider } from '@/context/modal-context'
const Layout = ({ children }: { children: ReactNode }) => {
return (
<>
<GA gaType={GaType.admin} />
<SwrInitor>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg'>
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</SwrInitor>
</>
)
}
export default Layout

View File

@@ -0,0 +1,12 @@
'use client'
import { useTranslation } from 'react-i18next'
import AccountPage from './account-page'
import useDocumentTitle from '@/hooks/use-document-title'
export default function Account() {
const { t } = useTranslation()
useDocumentTitle(t('common.menus.account'))
return <div className='mx-auto w-full max-w-[640px] px-6 pt-12'>
<AccountPage />
</div>
}

View File

@@ -0,0 +1,3 @@
export const OAUTH_AUTHORIZE_PENDING_KEY = 'oauth_authorize_pending'
export const REDIRECT_URL_KEY = 'oauth_redirect_url'
export const OAUTH_AUTHORIZE_PENDING_TTL = 60 * 3

View File

@@ -0,0 +1,42 @@
'use client'
import Header from '@/app/signin/_header'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
import useDocumentTitle from '@/hooks/use-document-title'
import { AppContextProvider } from '@/context/app-context'
import { useIsLogin } from '@/service/use-common'
import Loading from '@/app/components/base/loading'
export default function SignInLayout({ children }: any) {
const { systemFeatures } = useGlobalPublicStore()
useDocumentTitle('')
const { isLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
if(isLoading) {
return (
<div className='flex min-h-screen w-full justify-center bg-background-default-burn'>
<Loading />
</div>
)
}
return <>
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col items-center rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<div className={cn('flex w-full grow flex-col items-center justify-center px-6 md:px-[108px]')}>
<div className='flex flex-col md:w-[400px]'>
{isLoggedIn ? <AppContextProvider>
{children}
</AppContextProvider>
: children}
</div>
</div>
{systemFeatures.branding.enabled === false && <div className='system-xs-regular px-8 py-6 text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
</>
}

View File

@@ -0,0 +1,202 @@
'use client'
import React, { useEffect, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { useRouter, useSearchParams } from 'next/navigation'
import Button from '@/app/components/base/button'
import Avatar from '@/app/components/base/avatar'
import Loading from '@/app/components/base/loading'
import Toast from '@/app/components/base/toast'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { useAppContext } from '@/context/app-context'
import { useAuthorizeOAuthApp, useOAuthAppInfo } from '@/service/use-oauth'
import {
RiAccountCircleLine,
RiGlobalLine,
RiInfoCardLine,
RiMailLine,
RiTranslate2,
} from '@remixicon/react'
import dayjs from 'dayjs'
import { useIsLogin } from '@/service/use-common'
import {
OAUTH_AUTHORIZE_PENDING_KEY,
OAUTH_AUTHORIZE_PENDING_TTL,
REDIRECT_URL_KEY,
} from './constants'
function setItemWithExpiry(key: string, value: string, ttl: number) {
const item = {
value,
expiry: dayjs().add(ttl, 'seconds').unix(),
}
localStorage.setItem(key, JSON.stringify(item))
}
function buildReturnUrl(pathname: string, search: string) {
try {
const base = `${globalThis.location.origin}${pathname}${search}`
return base
}
catch {
return pathname + search
}
}
export default function OAuthAuthorize() {
const { t } = useTranslation()
const SCOPE_INFO_MAP: Record<string, { icon: React.ComponentType<{ className?: string }>, label: string }> = {
'read:name': {
icon: RiInfoCardLine,
label: t('oauth.scopes.name'),
},
'read:email': {
icon: RiMailLine,
label: t('oauth.scopes.email'),
},
'read:avatar': {
icon: RiAccountCircleLine,
label: t('oauth.scopes.avatar'),
},
'read:interface_language': {
icon: RiTranslate2,
label: t('oauth.scopes.languagePreference'),
},
'read:timezone': {
icon: RiGlobalLine,
label: t('oauth.scopes.timezone'),
},
}
const router = useRouter()
const language = useLanguage()
const searchParams = useSearchParams()
const client_id = decodeURIComponent(searchParams.get('client_id') || '')
const redirect_uri = decodeURIComponent(searchParams.get('redirect_uri') || '')
const { userProfile } = useAppContext()
const { data: authAppInfo, isLoading: isOAuthLoading, isError } = useOAuthAppInfo(client_id, redirect_uri)
const { mutateAsync: authorize, isPending: authorizing } = useAuthorizeOAuthApp()
const hasNotifiedRef = useRef(false)
const { isLoading: isIsLoginLoading, data: loginData } = useIsLogin()
const isLoggedIn = loginData?.logged_in
const isLoading = isOAuthLoading || isIsLoginLoading
const onLoginSwitchClick = () => {
try {
const returnUrl = buildReturnUrl('/account/oauth/authorize', `?client_id=${encodeURIComponent(client_id)}&redirect_uri=${encodeURIComponent(redirect_uri)}`)
setItemWithExpiry(OAUTH_AUTHORIZE_PENDING_KEY, returnUrl, OAUTH_AUTHORIZE_PENDING_TTL)
router.push(`/signin?${REDIRECT_URL_KEY}=${encodeURIComponent(returnUrl)}`)
}
catch {
router.push('/signin')
}
}
const onAuthorize = async () => {
if (!client_id || !redirect_uri)
return
try {
const { code } = await authorize({ client_id })
const url = new URL(redirect_uri)
url.searchParams.set('code', code)
globalThis.location.href = url.toString()
}
catch (err: any) {
Toast.notify({
type: 'error',
message: `${t('oauth.error.authorizeFailed')}: ${err.message}`,
})
}
}
useEffect(() => {
const invalidParams = !client_id || !redirect_uri
if ((invalidParams || isError) && !hasNotifiedRef.current) {
hasNotifiedRef.current = true
Toast.notify({
type: 'error',
message: invalidParams ? t('oauth.error.invalidParams') : t('oauth.error.authAppInfoFetchFailed'),
duration: 0,
})
}
}, [client_id, redirect_uri, isError])
if (isLoading) {
return (
<div className='bg-background-default-subtle'>
<Loading type='app' />
</div>
)
}
return (
<div className='bg-background-default-subtle'>
{authAppInfo?.app_icon && (
<div className='w-max rounded-2xl border-[0.5px] border-components-panel-border bg-text-primary-on-surface p-3 shadow-lg'>
<img src={authAppInfo.app_icon} alt='app icon' className='h-10 w-10 rounded' />
</div>
)}
<div className={`mb-4 mt-5 flex flex-col gap-2 ${isLoggedIn ? 'pb-2' : ''}`}>
<div className='title-4xl-semi-bold'>
{isLoggedIn && <div className='text-text-primary'>{t('oauth.connect')}</div>}
<div className='text-[var(--color-saas-dify-blue-inverted)]'>{authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t('oauth.unknownApp')}</div>
{!isLoggedIn && <div className='text-text-primary'>{t('oauth.tips.notLoggedIn')}</div>}
</div>
<div className='body-md-regular text-text-secondary'>{isLoggedIn ? `${authAppInfo?.app_label[language] || authAppInfo?.app_label?.en_US || t('oauth.unknownApp')} ${t('oauth.tips.loggedIn')}` : t('oauth.tips.needLogin')}</div>
</div>
{isLoggedIn && userProfile && (
<div className='flex items-center justify-between rounded-xl bg-background-section-burn-inverted p-3'>
<div className='flex items-center gap-2.5'>
<Avatar avatar={userProfile.avatar_url} name={userProfile.name} size={36} />
<div>
<div className='system-md-semi-bold text-text-secondary'>{userProfile.name}</div>
<div className='system-xs-regular text-text-tertiary'>{userProfile.email}</div>
</div>
</div>
<Button variant='tertiary' size='small' onClick={onLoginSwitchClick}>{t('oauth.switchAccount')}</Button>
</div>
)}
{isLoggedIn && Boolean(authAppInfo?.scope) && (
<div className='mt-2 flex flex-col gap-2.5 rounded-xl bg-background-section-burn-inverted px-[22px] py-5 text-text-secondary'>
{authAppInfo!.scope.split(/\s+/).filter(Boolean).map((scope: string) => {
const Icon = SCOPE_INFO_MAP[scope]
return (
<div key={scope} className='body-sm-medium flex items-center gap-2 text-text-secondary'>
{Icon ? <Icon.icon className='h-4 w-4' /> : <RiAccountCircleLine className='h-4 w-4' />}
{Icon.label}
</div>
)
})}
</div>
)}
<div className='flex flex-col items-center gap-2 pt-4'>
{!isLoggedIn ? (
<Button variant='primary' size='large' className='w-full' onClick={onLoginSwitchClick}>{t('oauth.login')}</Button>
) : (
<>
<Button variant='primary' size='large' className='w-full' onClick={onAuthorize} disabled={!client_id || !redirect_uri || isError || authorizing} loading={authorizing}>{t('oauth.continue')}</Button>
<Button size='large' className='w-full' onClick={() => router.push('/apps')}>{t('common.operation.cancel')}</Button>
</>
)}
</div>
<div className='mt-4 py-2'>
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="1" viewBox="0 0 400 1" fill="none">
<path d="M0 0.5H400" stroke="url(#paint0_linear_2_5904)" />
<defs>
<linearGradient id="paint0_linear_2_5904" x1="400" y1="9.49584" x2="0.000228929" y2="9.17666" gradientUnits="userSpaceOnUse">
<stop stop-color="white" stop-opacity="0.01" />
<stop offset="0.505" stop-color="#101828" stop-opacity="0.08" />
<stop offset="1" stop-color="white" stop-opacity="0.01" />
</linearGradient>
</defs>
</svg>
</div>
<div className='system-xs-regular mt-3 text-text-tertiary'>{t('oauth.tips.common')}</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
'use client'
import { useTranslation } from 'react-i18next'
import useSWR from 'swr'
import { useRouter, useSearchParams } from 'next/navigation'
import cn from '@/utils/classnames'
import Button from '@/app/components/base/button'
import { invitationCheck } from '@/service/common'
import Loading from '@/app/components/base/loading'
import useDocumentTitle from '@/hooks/use-document-title'
const ActivateForm = () => {
useDocumentTitle('')
const router = useRouter()
const { t } = useTranslation()
const searchParams = useSearchParams()
const workspaceID = searchParams.get('workspace_id')
const email = searchParams.get('email')
const token = searchParams.get('token')
const checkParams = {
url: '/activate/check',
params: {
...workspaceID && { workspace_id: workspaceID },
...email && { email },
token,
},
}
const { data: checkRes } = useSWR(checkParams, invitationCheck, {
revalidateOnFocus: false,
onSuccess(data) {
if (data.is_valid) {
const params = new URLSearchParams(searchParams)
const { email, workspace_id } = data.data
params.set('email', encodeURIComponent(email))
params.set('workspace_id', encodeURIComponent(workspace_id))
params.set('invite_token', encodeURIComponent(token as string))
router.replace(`/signin?${params.toString()}`)
}
},
})
return (
<div className={
cn(
'flex w-full grow flex-col items-center justify-center',
'px-6',
'md:px-[108px]',
)
}>
{!checkRes && <Loading />}
{checkRes && !checkRes.is_valid && (
<div className="flex flex-col md:w-[400px]">
<div className="mx-auto w-full">
<div className="mb-3 flex h-20 w-20 items-center justify-center rounded-[20px] border border-divider-regular bg-components-option-card-option-bg p-5 text-[40px] font-bold shadow-lg">🤷</div>
<h2 className="text-[32px] font-bold text-text-primary">{t('login.invalid')}</h2>
</div>
<div className="mx-auto mt-6 w-full">
<Button variant='primary' className='w-full !text-sm'>
<a href="https://dify.ai">{t('login.explore')}</a>
</Button>
</div>
</div>
)}
</div>
)
}
export default ActivateForm

View File

@@ -0,0 +1,23 @@
'use client'
import React from 'react'
import Header from '../signin/_header'
import ActivateForm from './activateForm'
import cn from '@/utils/classnames'
import { useGlobalPublicStore } from '@/context/global-public-context'
const Activate = () => {
const { systemFeatures } = useGlobalPublicStore()
return (
<div className={cn('flex min-h-screen w-full justify-center bg-background-default-burn p-6')}>
<div className={cn('flex w-full shrink-0 flex-col rounded-2xl border border-effects-highlight bg-background-default-subtle')}>
<Header />
<ActivateForm />
{!systemFeatures.branding.enabled && <div className='px-8 py-6 text-sm font-normal text-text-tertiary'>
© {new Date().getFullYear()} LangGenius, Inc. All rights reserved.
</div>}
</div>
</div>
)
}
export default Activate

View File

@@ -0,0 +1,464 @@
import { useTranslation } from 'react-i18next'
import { useRouter } from 'next/navigation'
import { useContext } from 'use-context-selector'
import React, { useCallback, useState } from 'react'
import {
RiDeleteBinLine,
RiEditLine,
RiEqualizer2Line,
RiExchange2Line,
RiFileCopy2Line,
RiFileDownloadLine,
RiFileUploadLine,
} from '@remixicon/react'
import AppIcon from '../base/app-icon'
import { useStore as useAppStore } from '@/app/components/app/store'
import { ToastContext } from '@/app/components/base/toast'
import { useAppContext } from '@/context/app-context'
import { useProviderContext } from '@/context/provider-context'
import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps'
import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal'
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
import { getRedirection } from '@/utils/app-redirection'
import type { EnvironmentVariable } from '@/app/components/workflow/types'
import { fetchWorkflowDraft } from '@/service/workflow'
import ContentDialog from '@/app/components/base/content-dialog'
import Button from '@/app/components/base/button'
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/card-view'
import type { Operation } from './app-operations'
import AppOperations from './app-operations'
import dynamic from 'next/dynamic'
import cn from '@/utils/classnames'
import { AppModeEnum } from '@/types/app'
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
ssr: false,
})
const CreateAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), {
ssr: false,
})
const DuplicateAppModal = dynamic(() => import('@/app/components/app/duplicate-modal'), {
ssr: false,
})
const Confirm = dynamic(() => import('@/app/components/base/confirm'), {
ssr: false,
})
const UpdateDSLModal = dynamic(() => import('@/app/components/workflow/update-dsl-modal'), {
ssr: false,
})
const DSLExportConfirmModal = dynamic(() => import('@/app/components/workflow/dsl-export-confirm-modal'), {
ssr: false,
})
export type IAppInfoProps = {
expand: boolean
onlyShowDetail?: boolean
openState?: boolean
onDetailExpand?: (expand: boolean) => void
}
const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailExpand }: IAppInfoProps) => {
const { t } = useTranslation()
const { notify } = useContext(ToastContext)
const { replace } = useRouter()
const { onPlanInfoChanged } = useProviderContext()
const appDetail = useAppStore(state => state.appDetail)
const setAppDetail = useAppStore(state => state.setAppDetail)
const [open, setOpen] = useState(openState)
const [showEditModal, setShowEditModal] = useState(false)
const [showDuplicateModal, setShowDuplicateModal] = useState(false)
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const [showSwitchModal, setShowSwitchModal] = useState<boolean>(false)
const [showImportDSLModal, setShowImportDSLModal] = useState<boolean>(false)
const [secretEnvList, setSecretEnvList] = useState<EnvironmentVariable[]>([])
const [showExportWarning, setShowExportWarning] = useState(false)
const onEdit: CreateAppModalProps['onConfirm'] = useCallback(async ({
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
}) => {
if (!appDetail)
return
try {
const app = await updateAppInfo({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
description,
use_icon_as_answer_icon,
max_active_requests,
})
setShowEditModal(false)
notify({
type: 'success',
message: t('app.editDone'),
})
setAppDetail(app)
}
catch {
notify({ type: 'error', message: t('app.editFailed') })
}
}, [appDetail, notify, setAppDetail, t])
const onCopy: DuplicateAppModalProps['onConfirm'] = async ({ name, icon_type, icon, icon_background }) => {
if (!appDetail)
return
try {
const newApp = await copyApp({
appID: appDetail.id,
name,
icon_type,
icon,
icon_background,
mode: appDetail.mode,
})
setShowDuplicateModal(false)
notify({
type: 'success',
message: t('app.newApp.appCreated'),
})
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
onPlanInfoChanged()
getRedirection(true, newApp, replace)
}
catch {
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
}
}
const onExport = async (include = false) => {
if (!appDetail)
return
try {
const { data } = await exportAppConfig({
appID: appDetail.id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${appDetail.name}.yml`
a.click()
URL.revokeObjectURL(url)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
}
}
const exportCheck = async () => {
if (!appDetail)
return
if (appDetail.mode !== AppModeEnum.WORKFLOW && appDetail.mode !== AppModeEnum.ADVANCED_CHAT) {
onExport()
return
}
setShowExportWarning(true)
}
const handleConfirmExport = async () => {
if (!appDetail)
return
setShowExportWarning(false)
try {
const workflowDraft = await fetchWorkflowDraft(`/apps/${appDetail.id}/workflows/draft`)
const list = (workflowDraft.environment_variables || []).filter(env => env.value_type === 'secret')
if (list.length === 0) {
onExport()
return
}
setSecretEnvList(list)
}
catch {
notify({ type: 'error', message: t('app.exportFailed') })
}
}
const onConfirmDelete = useCallback(async () => {
if (!appDetail)
return
try {
await deleteApp(appDetail.id)
notify({ type: 'success', message: t('app.appDeleted') })
onPlanInfoChanged()
setAppDetail()
replace('/apps')
}
catch (e: any) {
notify({
type: 'error',
message: `${t('app.appDeleteFailed')}${'message' in e ? `: ${e.message}` : ''}`,
})
}
setShowConfirmDelete(false)
}, [appDetail, notify, onPlanInfoChanged, replace, setAppDetail, t])
const { isCurrentWorkspaceEditor } = useAppContext()
if (!appDetail)
return null
const primaryOperations = [
{
id: 'edit',
title: t('app.editApp'),
icon: <RiEditLine />,
onClick: () => {
setOpen(false)
onDetailExpand?.(false)
setShowEditModal(true)
},
},
{
id: 'duplicate',
title: t('app.duplicate'),
icon: <RiFileCopy2Line />,
onClick: () => {
setOpen(false)
onDetailExpand?.(false)
setShowDuplicateModal(true)
},
},
{
id: 'export',
title: t('app.export'),
icon: <RiFileDownloadLine />,
onClick: exportCheck,
},
]
const secondaryOperations: Operation[] = [
// Import DSL (conditional)
...(appDetail.mode === AppModeEnum.ADVANCED_CHAT || appDetail.mode === AppModeEnum.WORKFLOW) ? [{
id: 'import',
title: t('workflow.common.importDSL'),
icon: <RiFileUploadLine />,
onClick: () => {
setOpen(false)
onDetailExpand?.(false)
setShowImportDSLModal(true)
},
}] : [],
// Divider
{
id: 'divider-1',
title: '',
icon: <></>,
onClick: () => { /* divider has no action */ },
type: 'divider' as const,
},
// Delete operation
{
id: 'delete',
title: t('common.operation.delete'),
icon: <RiDeleteBinLine />,
onClick: () => {
setOpen(false)
onDetailExpand?.(false)
setShowConfirmDelete(true)
},
},
]
// Keep the switch operation separate as it's not part of the main operations
const switchOperation = (appDetail.mode === AppModeEnum.COMPLETION || appDetail.mode === AppModeEnum.CHAT) ? {
id: 'switch',
title: t('app.switch'),
icon: <RiExchange2Line />,
onClick: () => {
setOpen(false)
onDetailExpand?.(false)
setShowSwitchModal(true)
},
} : null
return (
<div>
{!onlyShowDetail && (
<button type="button"
onClick={() => {
if (isCurrentWorkspaceEditor)
setOpen(v => !v)
}}
className='block w-full'
>
<div className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'>
<div className='flex items-center gap-1'>
<div className={cn(!expand && 'ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
</div>
{expand && (
<div className='ml-auto flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
)}
</div>
{!expand && (
<div className='flex items-center justify-center'>
<div className='flex h-5 w-5 items-center justify-center rounded-md p-0.5'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
)}
{expand && (
<div className='flex flex-col items-start gap-1'>
<div className='flex w-full'>
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
</div>
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>
{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced')
: appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent')
: appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot')
: appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion')
: t('app.types.workflow')}</div>
</div>
)}
</div>
</button>
)}
<ContentDialog
show={onlyShowDetail ? openState : open}
onClose={() => {
setOpen(false)
onDetailExpand?.(false)
}}
className='absolute bottom-2 left-2 top-2 flex w-[420px] flex-col rounded-2xl !p-0'
>
<div className='flex shrink-0 flex-col items-start justify-center gap-3 self-stretch p-4'>
<div className='flex items-center gap-3 self-stretch'>
<AppIcon
size='large'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex flex-1 flex-col items-start justify-center overflow-hidden'>
<div className='system-md-semibold w-full truncate text-text-secondary'>{appDetail.name}</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
</div>
{/* description */}
{appDetail.description && (
<div className='system-xs-regular overflow-wrap-anywhere max-h-[105px] w-full max-w-full overflow-y-auto whitespace-normal break-words text-text-tertiary'>{appDetail.description}</div>
)}
{/* operations */}
<AppOperations
gap={4}
primaryOperations={primaryOperations}
secondaryOperations={secondaryOperations}
/>
</div>
<CardView
appId={appDetail.id}
isInPanel={true}
className='flex flex-1 flex-col gap-2 overflow-auto px-2 py-1'
/>
{/* Switch operation (if available) */}
{switchOperation && (
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch pb-2'>
<Button
size={'medium'}
variant={'ghost'}
className='gap-0.5'
onClick={switchOperation.onClick}
>
{switchOperation.icon}
<span className='system-sm-medium text-text-tertiary'>{switchOperation.title}</span>
</Button>
</div>
)}
</ContentDialog>
{showSwitchModal && (
<SwitchAppModal
inAppDetail
show={showSwitchModal}
appDetail={appDetail}
onClose={() => setShowSwitchModal(false)}
onSuccess={() => setShowSwitchModal(false)}
/>
)}
{showEditModal && (
<CreateAppModal
isEditModal
appName={appDetail.name}
appIconType={appDetail.icon_type}
appIcon={appDetail.icon}
appIconBackground={appDetail.icon_background}
appIconUrl={appDetail.icon_url}
appDescription={appDetail.description}
appMode={appDetail.mode}
appUseIconAsAnswerIcon={appDetail.use_icon_as_answer_icon}
max_active_requests={appDetail.max_active_requests ?? null}
show={showEditModal}
onConfirm={onEdit}
onHide={() => setShowEditModal(false)}
/>
)}
{showDuplicateModal && (
<DuplicateAppModal
appName={appDetail.name}
icon_type={appDetail.icon_type}
icon={appDetail.icon}
icon_background={appDetail.icon_background}
icon_url={appDetail.icon_url}
show={showDuplicateModal}
onConfirm={onCopy}
onHide={() => setShowDuplicateModal(false)}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('app.deleteAppConfirmTitle')}
content={t('app.deleteAppConfirmContent')}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
{showImportDSLModal && (
<UpdateDSLModal
onCancel={() => setShowImportDSLModal(false)}
onBackup={exportCheck}
/>
)}
{secretEnvList.length > 0 && (
<DSLExportConfirmModal
envList={secretEnvList}
onConfirm={onExport}
onClose={() => setSecretEnvList([])}
/>
)}
{showExportWarning && (
<Confirm
type="info"
isShow={showExportWarning}
title={t('workflow.sidebar.exportWarning')}
content={t('workflow.sidebar.exportWarningDesc')}
onConfirm={handleConfirmExport}
onCancel={() => setShowExportWarning(false)}
/>
)}
</div>
)
}
export default React.memo(AppInfo)

View File

@@ -0,0 +1,214 @@
import type { JSX } from 'react'
import { cloneElement, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Button from '@/app/components/base/button'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
import { RiMoreLine } from '@remixicon/react'
export type Operation = {
id: string
title: string
icon: JSX.Element
onClick: () => void
type?: 'divider'
}
type AppOperationsProps = {
gap: number
operations?: Operation[]
primaryOperations?: Operation[]
secondaryOperations?: Operation[]
}
const EMPTY_OPERATIONS: Operation[] = []
const AppOperations = ({
operations,
primaryOperations,
secondaryOperations,
gap,
}: AppOperationsProps) => {
const { t } = useTranslation()
const [visibleOpreations, setVisibleOperations] = useState<Operation[]>([])
const [moreOperations, setMoreOperations] = useState<Operation[]>([])
const [showMore, setShowMore] = useState(false)
const navRef = useRef<HTMLDivElement>(null)
const handleTriggerMore = useCallback(() => {
setShowMore(true)
}, [setShowMore])
const primaryOps = useMemo(() => {
if (operations)
return operations
if (primaryOperations)
return primaryOperations
return EMPTY_OPERATIONS
}, [operations, primaryOperations])
const secondaryOps = useMemo(() => {
if (operations)
return EMPTY_OPERATIONS
if (secondaryOperations)
return secondaryOperations
return EMPTY_OPERATIONS
}, [operations, secondaryOperations])
const inlineOperations = primaryOps.filter(operation => operation.type !== 'divider')
useEffect(() => {
const applyState = (visible: Operation[], overflow: Operation[]) => {
const combinedMore = [...overflow, ...secondaryOps]
if (!overflow.length && combinedMore[0]?.type === 'divider')
combinedMore.shift()
setVisibleOperations(visible)
setMoreOperations(combinedMore)
}
const inline = primaryOps.filter(operation => operation.type !== 'divider')
if (!inline.length) {
applyState([], [])
return
}
const navElement = navRef.current
const moreElement = document.getElementById('more-measure')
if (!navElement || !moreElement)
return
let width = 0
const containerWidth = navElement.clientWidth
const moreWidth = moreElement.clientWidth
if (containerWidth === 0 || moreWidth === 0)
return
const updatedEntries: Record<string, boolean> = inline.reduce((pre, cur) => {
pre[cur.id] = false
return pre
}, {} as Record<string, boolean>)
const childrens = Array.from(navElement.children).slice(0, -1)
for (let i = 0; i < childrens.length; i++) {
const child = childrens[i] as HTMLElement
const id = child.dataset.targetid
if (!id) break
const childWidth = child.clientWidth
if (width + gap + childWidth + moreWidth <= containerWidth) {
updatedEntries[id] = true
width += gap + childWidth
}
else {
if (i === childrens.length - 1 && width + childWidth <= containerWidth)
updatedEntries[id] = true
else
updatedEntries[id] = false
break
}
}
const visible = inline.filter(item => updatedEntries[item.id])
const overflow = inline.filter(item => !updatedEntries[item.id])
applyState(visible, overflow)
}, [gap, primaryOps, secondaryOps])
const shouldShowMoreButton = moreOperations.length > 0
return (
<>
<div
aria-hidden="true"
ref={navRef}
className="pointer-events-none flex h-0 items-center self-stretch overflow-hidden"
style={{ gap }}
>
{inlineOperations.map(operation => (
<Button
key={operation.id}
data-targetid={operation.id}
size={'small'}
variant={'secondary'}
className="gap-[1px]"
tabIndex={-1}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
<Button
id="more-measure"
size={'small'}
variant={'secondary'}
className="gap-[1px]"
tabIndex={-1}
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('common.operation.more')}
</span>
</Button>
</div>
<div className="flex items-center self-stretch overflow-hidden" style={{ gap }}>
{visibleOpreations.map(operation => (
<Button
key={operation.id}
data-targetid={operation.id}
size={'small'}
variant={'secondary'}
className="gap-[1px]"
onClick={operation.onClick}
>
{cloneElement(operation.icon, { className: 'h-3.5 w-3.5 text-components-button-secondary-text' })}
<span className="system-xs-medium text-components-button-secondary-text">
{operation.title}
</span>
</Button>
))}
{shouldShowMoreButton && (
<PortalToFollowElem
open={showMore}
onOpenChange={setShowMore}
placement="bottom-end"
offset={{ mainAxis: 4 }}
>
<PortalToFollowElemTrigger onClick={handleTriggerMore}>
<Button
size={'small'}
variant={'secondary'}
className="gap-[1px]"
>
<RiMoreLine className="h-3.5 w-3.5 text-components-button-secondary-text" />
<span className="system-xs-medium text-components-button-secondary-text">
{t('common.operation.more')}
</span>
</Button>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className="z-[30]">
<div className="flex min-w-[264px] flex-col rounded-[12px] border-[0.5px] border-components-panel-border bg-components-panel-bg-blur p-1 shadow-lg backdrop-blur-[5px]">
{moreOperations.map(item => item.type === 'divider'
? (
<div key={item.id} className="my-1 h-px bg-divider-subtle" />
)
: (
<div
key={item.id}
className="flex h-8 cursor-pointer items-center gap-x-1 rounded-lg p-1.5 hover:bg-state-base-hover"
onClick={item.onClick}
>
{cloneElement(item.icon, { className: 'h-4 w-4 text-text-tertiary' })}
<span className="system-md-regular text-text-secondary">{item.title}</span>
</div>
))}
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
)}
</div>
</>
)
}
export default AppOperations

View File

@@ -0,0 +1,126 @@
import React, { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useAppContext } from '@/context/app-context'
import {
RiEqualizer2Line,
RiMenuLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import AppInfo from './app-info'
import NavLink from './navLink'
import { useStore as useAppStore } from '@/app/components/app/store'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { AppModeEnum } from '@/types/app'
type Props = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
}>
}
const AppSidebarDropdown = ({ navigation }: Props) => {
const { t } = useTranslation()
const { isCurrentWorkspaceEditor } = useAppContext()
const appDetail = useAppStore(state => state.appDetail)
const [detailExpand, setDetailExpand] = useState(false)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
if (!appDetail)
return null
return (
<>
<div className='fixed left-2 top-2 z-20'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div className={cn('flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover', open && 'bg-background-default-hover')}>
<AppIcon
size='small'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<RiMenuLine className='h-4 w-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[1000]'>
<div className={cn('w-[305px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg')}>
<div className='p-2'>
<div
className={cn('flex flex-col gap-2 rounded-lg p-2 pb-2.5', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}
onClick={() => {
setDetailExpand(true)
setOpen(false)
}}
>
<div className='flex items-center justify-between self-stretch'>
<AppIcon
size='large'
iconType={appDetail.icon_type}
icon={appDetail.icon}
background={appDetail.icon_background}
imageUrl={appDetail.icon_url}
/>
<div className='flex items-center justify-center rounded-md p-0.5'>
<div className='flex h-5 w-5 items-center justify-center'>
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
</div>
</div>
</div>
<div className='flex flex-col items-start gap-1'>
<div className='flex w-full'>
<div className='system-md-semibold truncate text-text-secondary'>{appDetail.name}</div>
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>{appDetail.mode === AppModeEnum.ADVANCED_CHAT ? t('app.types.advanced') : appDetail.mode === AppModeEnum.AGENT_CHAT ? t('app.types.agent') : appDetail.mode === AppModeEnum.CHAT ? t('app.types.chatbot') : appDetail.mode === AppModeEnum.COMPLETION ? t('app.types.completion') : t('app.types.workflow')}</div>
</div>
</div>
</div>
<div className='px-4'>
<Divider bgStyle='gradient' />
</div>
<nav className='space-y-0.5 px-3 pb-6 pt-4'>
{navigation.map((item, index) => {
return (
<NavLink key={index} mode='expand' iconMap={{ selected: item.selectedIcon, normal: item.icon }} name={item.name} href={item.href} />
)
})}
</nav>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
<div className='z-20'>
<AppInfo expand onlyShowDetail openState={detailExpand} onDetailExpand={setDetailExpand} />
</div>
</>
)
}
export default AppSidebarDropdown

View File

@@ -0,0 +1,96 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../base/app-icon'
import Tooltip from '@/app/components/base/tooltip'
import {
ApiAggregate,
WindowCursor,
} from '@/app/components/base/icons/src/vender/workflow'
export type IAppBasicProps = {
iconType?: 'app' | 'api' | 'dataset' | 'webapp' | 'notion'
icon?: string
icon_background?: string | null
isExternal?: boolean
name: string
type: string | React.ReactNode
hoverTip?: string
textStyle?: { main?: string; extra?: string }
isExtraInLine?: boolean
mode?: string
hideType?: boolean
}
const DatasetSvg = <svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fillRule="evenodd" clipRule="evenodd" d="M0.833497 5.13481C0.833483 4.69553 0.83347 4.31654 0.858973 4.0044C0.88589 3.67495 0.94532 3.34727 1.10598 3.03195C1.34567 2.56155 1.72812 2.17909 2.19852 1.93941C2.51384 1.77875 2.84152 1.71932 3.17097 1.6924C3.48312 1.6669 3.86209 1.66691 4.30137 1.66693L7.62238 1.66684C8.11701 1.66618 8.55199 1.66561 8.95195 1.80356C9.30227 1.92439 9.62134 2.12159 9.88607 2.38088C10.1883 2.67692 10.3823 3.06624 10.603 3.50894L11.3484 5.00008H14.3679C15.0387 5.00007 15.5924 5.00006 16.0434 5.03691C16.5118 5.07518 16.9424 5.15732 17.3468 5.36339C17.974 5.68297 18.4839 6.19291 18.8035 6.82011C19.0096 7.22456 19.0917 7.65515 19.13 8.12356C19.1668 8.57455 19.1668 9.12818 19.1668 9.79898V13.5345C19.1668 14.2053 19.1668 14.7589 19.13 15.2099C19.0917 15.6784 19.0096 16.1089 18.8035 16.5134C18.4839 17.1406 17.974 17.6505 17.3468 17.9701C16.9424 18.1762 16.5118 18.2583 16.0434 18.2966C15.5924 18.3334 15.0387 18.3334 14.3679 18.3334H5.63243C4.96163 18.3334 4.40797 18.3334 3.95698 18.2966C3.48856 18.2583 3.05798 18.1762 2.65353 17.9701C2.02632 17.6505 1.51639 17.1406 1.19681 16.5134C0.990734 16.1089 0.908597 15.6784 0.870326 15.2099C0.833478 14.7589 0.833487 14.2053 0.833497 13.5345V5.13481ZM7.51874 3.33359C8.17742 3.33359 8.30798 3.34447 8.4085 3.37914C8.52527 3.41942 8.63163 3.48515 8.71987 3.57158C8.79584 3.64598 8.86396 3.7579 9.15852 4.34704L9.48505 5.00008L2.50023 5.00008C2.50059 4.61259 2.50314 4.34771 2.5201 4.14012C2.5386 3.91374 2.57 3.82981 2.59099 3.7886C2.67089 3.6318 2.79837 3.50432 2.95517 3.42442C2.99638 3.40343 3.08031 3.37203 3.30669 3.35353C3.54281 3.33424 3.85304 3.33359 4.3335 3.33359H7.51874Z" fill="#444CE7" />
</svg>
const NotionSvg = <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_6294_13848)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M4.287 21.9133L1.70748 18.6999C1.08685 17.9267 0.75 16.976 0.75 15.9974V4.36124C0.75 2.89548 1.92269 1.67923 3.43553 1.57594L15.3991 0.759137C16.2682 0.699797 17.1321 0.930818 17.8461 1.41353L22.0494 4.25543C22.8018 4.76414 23.25 5.59574 23.25 6.48319V19.7124C23.25 21.1468 22.0969 22.3345 20.6157 22.4256L7.3375 23.243C6.1555 23.3158 5.01299 22.8178 4.287 21.9133Z" fill="white" />
<path d="M8.43607 10.1842V10.0318C8.43607 9.64564 8.74535 9.32537 9.14397 9.29876L12.0475 9.10491L16.0628 15.0178V9.82823L15.0293 9.69046V9.6181C15.0293 9.22739 15.3456 8.90501 15.7493 8.88433L18.3912 8.74899V9.12918C18.3912 9.30765 18.2585 9.46031 18.0766 9.49108L17.4408 9.59861V18.0029L16.6429 18.2773C15.9764 18.5065 15.2343 18.2611 14.8527 17.6853L10.9545 11.803V17.4173L12.1544 17.647L12.1377 17.7583C12.0853 18.1069 11.7843 18.3705 11.4202 18.3867L8.43607 18.5195C8.39662 18.1447 8.67758 17.8093 9.06518 17.7686L9.45771 17.7273V10.2416L8.43607 10.1842Z" fill="black" />
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.5062 2.22521L3.5426 3.04201C2.82599 3.09094 2.27051 3.66706 2.27051 4.36136V15.9975C2.27051 16.6499 2.49507 17.2837 2.90883 17.7992L5.48835 21.0126C5.90541 21.5322 6.56174 21.8183 7.24076 21.7765L20.519 20.9591C21.1995 20.9172 21.7293 20.3716 21.7293 19.7125V6.48332C21.7293 6.07557 21.5234 5.69348 21.1777 5.45975L16.9743 2.61784C16.546 2.32822 16.0277 2.1896 15.5062 2.22521ZM4.13585 4.54287C3.96946 4.41968 4.04865 4.16303 4.25768 4.14804L15.5866 3.33545C15.9476 3.30956 16.3063 3.40896 16.5982 3.61578L18.8713 5.22622C18.9576 5.28736 18.9171 5.41935 18.8102 5.42516L6.8129 6.07764C6.44983 6.09739 6.09144 5.99073 5.80276 5.77699L4.13585 4.54287ZM6.25018 8.12315C6.25018 7.7334 6.56506 7.41145 6.9677 7.38952L19.6523 6.69871C20.0447 6.67734 20.375 6.97912 20.375 7.35898V18.8141C20.375 19.2031 20.0613 19.5247 19.6594 19.5476L7.05516 20.2648C6.61845 20.2896 6.25018 19.954 6.25018 19.5312V8.12315Z" fill="black" />
</g>
<defs>
<clipPath id="clip0_6294_13848">
<rect width="24" height="24" fill="white" />
</clipPath>
</defs>
</svg>
const ICON_MAP = {
app: <AppIcon className='border !border-[rgba(0,0,0,0.05)]' />,
api: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
<ApiAggregate className='h-4 w-4 text-text-primary-on-surface' />
</div>,
dataset: <AppIcon innerIcon={DatasetSvg} className='!border-[0.5px] !border-indigo-100 !bg-indigo-25' />,
webapp: <div className='rounded-lg border-[0.5px] border-divider-subtle bg-util-colors-blue-brand-blue-brand-500 p-1 shadow-md'>
<WindowCursor className='h-4 w-4 text-text-primary-on-surface' />
</div>,
notion: <AppIcon innerIcon={NotionSvg} className='!border-[0.5px] !border-indigo-100 !bg-white' />,
}
export default function AppBasic({ icon, icon_background, name, isExternal, type, hoverTip, textStyle, isExtraInLine, mode = 'expand', iconType = 'app', hideType }: IAppBasicProps) {
const { t } = useTranslation()
return (
<div className="flex grow items-center">
{icon && icon_background && iconType === 'app' && (
<div className='mr-2 shrink-0'>
<AppIcon icon={icon} background={icon_background} />
</div>
)}
{iconType !== 'app'
&& <div className='mr-2 shrink-0'>
{ICON_MAP[iconType]}
</div>
}
{mode === 'expand' && <div className="group w-full">
<div className={`system-md-semibold flex flex-row items-center text-text-secondary group-hover:text-text-primary ${textStyle?.main ?? ''}`}>
<div className="min-w-0 overflow-hidden text-ellipsis break-normal">
{name}
</div>
{hoverTip
&& <Tooltip
popupContent={
<div className='w-[240px]'>
{hoverTip}
</div>
}
popupClassName='ml-1'
triggerClassName='w-4 h-4 ml-1'
position='top'
/>
}
</div>
{!hideType && isExtraInLine && (
<div className="system-2xs-medium-uppercase flex text-text-tertiary">{type}</div>
)}
{!hideType && !isExtraInLine && (
<div className='system-2xs-medium-uppercase text-text-tertiary'>{isExternal ? t('dataset.externalTag') : type}</div>
)}
</div>}
</div>
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -0,0 +1,152 @@
import React, { useCallback, useState } from 'react'
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
import ActionButton from '../../base/action-button'
import { RiMoreFill } from '@remixicon/react'
import cn from '@/utils/classnames'
import Menu from './menu'
import { useSelector as useAppContextWithSelector } from '@/context/app-context'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { datasetDetailQueryKeyPrefix, useInvalidDatasetList } from '@/service/knowledge/use-dataset'
import { useInvalid } from '@/service/use-base'
import { useExportPipelineDSL } from '@/service/use-pipeline'
import Toast from '../../base/toast'
import { useTranslation } from 'react-i18next'
import RenameDatasetModal from '../../datasets/rename-modal'
import { checkIsUsedInApp, deleteDataset } from '@/service/datasets'
import Confirm from '../../base/confirm'
import { useRouter } from 'next/navigation'
type DropDownProps = {
expand: boolean
}
const DropDown = ({
expand,
}: DropDownProps) => {
const { t } = useTranslation()
const { replace } = useRouter()
const [open, setOpen] = useState(false)
const [showRenameModal, setShowRenameModal] = useState(false)
const [confirmMessage, setConfirmMessage] = useState<string>('')
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
const isCurrentWorkspaceDatasetOperator = useAppContextWithSelector(state => state.isCurrentWorkspaceDatasetOperator)
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const handleTrigger = useCallback(() => {
setOpen(prev => !prev)
}, [])
const invalidDatasetList = useInvalidDatasetList()
const invalidDatasetDetail = useInvalid([...datasetDetailQueryKeyPrefix, dataset.id])
const refreshDataset = useCallback(() => {
invalidDatasetList()
invalidDatasetDetail()
}, [invalidDatasetDetail, invalidDatasetList])
const openRenameModal = useCallback(() => {
setShowRenameModal(true)
handleTrigger()
}, [handleTrigger])
const { mutateAsync: exportPipelineConfig } = useExportPipelineDSL()
const handleExportPipeline = useCallback(async (include = false) => {
const { pipeline_id, name } = dataset
if (!pipeline_id)
return
handleTrigger()
try {
const { data } = await exportPipelineConfig({
pipelineId: pipeline_id,
include,
})
const a = document.createElement('a')
const file = new Blob([data], { type: 'application/yaml' })
const url = URL.createObjectURL(file)
a.href = url
a.download = `${name}.pipeline`
a.click()
URL.revokeObjectURL(url)
}
catch {
Toast.notify({ type: 'error', message: t('app.exportFailed') })
}
}, [dataset, exportPipelineConfig, handleTrigger, t])
const detectIsUsedByApp = useCallback(async () => {
try {
const { is_using: isUsedByApp } = await checkIsUsedInApp(dataset.id)
setConfirmMessage(isUsedByApp ? t('dataset.datasetUsedByApp')! : t('dataset.deleteDatasetConfirmContent')!)
setShowConfirmDelete(true)
}
catch (e: any) {
const res = await e.json()
Toast.notify({ type: 'error', message: res?.message || 'Unknown error' })
}
finally {
handleTrigger()
}
}, [dataset.id, handleTrigger, t])
const onConfirmDelete = useCallback(async () => {
try {
await deleteDataset(dataset.id)
Toast.notify({ type: 'success', message: t('dataset.datasetDeleted') })
invalidDatasetList()
replace('/datasets')
}
finally {
setShowConfirmDelete(false)
}
}, [dataset.id, replace, invalidDatasetList, t])
return (
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement={expand ? 'bottom-end' : 'right'}
offset={expand ? {
mainAxis: 4,
crossAxis: 10,
} : {
mainAxis: 4,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<ActionButton className={cn(expand ? 'size-8 rounded-lg' : 'size-6 rounded-md')}>
<RiMoreFill className='size-4' />
</ActionButton>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-[60]'>
<Menu
showDelete={!isCurrentWorkspaceDatasetOperator}
openRenameModal={openRenameModal}
handleExportPipeline={handleExportPipeline}
detectIsUsedByApp={detectIsUsedByApp}
/>
</PortalToFollowElemContent>
{showRenameModal && (
<RenameDatasetModal
show={showRenameModal}
dataset={dataset!}
onClose={() => setShowRenameModal(false)}
onSuccess={refreshDataset}
/>
)}
{showConfirmDelete && (
<Confirm
title={t('dataset.deleteDatasetConfirmTitle')}
content={confirmMessage}
isShow={showConfirmDelete}
onConfirm={onConfirmDelete}
onCancel={() => setShowConfirmDelete(false)}
/>
)}
</PortalToFollowElem>
)
}
export default React.memo(DropDown)

View File

@@ -0,0 +1,91 @@
'use client'
import type { FC } from 'react'
import React, { useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import AppIcon from '../../base/app-icon'
import Effect from '../../base/effect'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import cn from '@/utils/classnames'
import Dropdown from './dropdown'
type DatasetInfoProps = {
expand: boolean
}
const DatasetInfo: FC<DatasetInfoProps> = ({
expand,
}) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const isPipelinePublished = useMemo(() => {
return dataset.runtime_mode === 'rag_pipeline' && dataset.is_published
}, [dataset.runtime_mode, dataset.is_published])
const { formatIndexingTechniqueAndMethod } = useKnowledge()
return (
<div className={cn('relative flex flex-col', expand ? '' : 'p-1')}>
{expand && (
<Effect className='-left-5 top-[-22px] opacity-15' />
)}
<div className='flex flex-col gap-2 p-2'>
<div className='flex items-center gap-1'>
<div className={cn(!expand && '-ml-1')}>
<AppIcon
size={expand ? 'large' : 'small'}
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
</div>
{expand && (
<div className='ml-auto'>
<Dropdown expand />
</div>
)}
</div>
{!expand && (
<div className='-mb-2 -mt-1 flex items-center justify-center'>
<Dropdown expand={false} />
</div>
)}
{expand && (
<div className='flex flex-col gap-y-1 pb-0.5'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && isPipelinePublished && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-2'>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
)}
</div>
</div>
)
}
export default React.memo(DatasetInfo)

View File

@@ -0,0 +1,30 @@
import React from 'react'
import type { RemixiconComponentType } from '@remixicon/react'
type MenuItemProps = {
name: string
Icon: RemixiconComponentType
handleClick?: () => void
}
const MenuItem = ({
Icon,
name,
handleClick,
}: MenuItemProps) => {
return (
<div
className='flex items-center gap-x-1 rounded-lg px-2 py-1.5 hover:bg-state-base-hover'
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
handleClick?.()
}}
>
<Icon className='size-4 text-text-tertiary' />
<span className='system-md-regular px-1 text-text-secondary'>{name}</span>
</div>
)
}
export default React.memo(MenuItem)

View File

@@ -0,0 +1,56 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import MenuItem from './menu-item'
import { RiDeleteBinLine, RiEditLine, RiFileDownloadLine } from '@remixicon/react'
import Divider from '../../base/divider'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
type MenuProps = {
showDelete: boolean
openRenameModal: () => void
handleExportPipeline: () => void
detectIsUsedByApp: () => void
}
const Menu = ({
showDelete,
openRenameModal,
handleExportPipeline,
detectIsUsedByApp,
}: MenuProps) => {
const { t } = useTranslation()
const runtimeMode = useDatasetDetailContextWithSelector(state => state.dataset?.runtime_mode)
return (
<div className='flex w-[200px] flex-col rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg shadow-shadow-shadow-5 backdrop-blur-[5px]'>
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiEditLine}
name={t('common.operation.edit')}
handleClick={openRenameModal}
/>
{runtimeMode === 'rag_pipeline' && (
<MenuItem
Icon={RiFileDownloadLine}
name={t('datasetPipeline.operations.exportPipeline')}
handleClick={handleExportPipeline}
/>
)}
</div>
{showDelete && (
<>
<Divider type='horizontal' className='my-0 bg-divider-subtle' />
<div className='flex flex-col p-1'>
<MenuItem
Icon={RiDeleteBinLine}
name={t('common.operation.delete')}
handleClick={detectIsUsedByApp}
/>
</div>
</>
)}
</div>
)
}
export default React.memo(Menu)

View File

@@ -0,0 +1,164 @@
import React, { useCallback, useRef, useState } from 'react'
import {
RiMenuLine,
} from '@remixicon/react'
import {
PortalToFollowElem,
PortalToFollowElemContent,
PortalToFollowElemTrigger,
} from '@/app/components/base/portal-to-follow-elem'
import AppIcon from '../base/app-icon'
import Divider from '../base/divider'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import cn from '@/utils/classnames'
import { useDatasetDetailContextWithSelector } from '@/context/dataset-detail'
import Effect from '../base/effect'
import Dropdown from './dataset-info/dropdown'
import type { DataSet } from '@/models/datasets'
import { DOC_FORM_TEXT } from '@/models/datasets'
import { useKnowledge } from '@/hooks/use-knowledge'
import { useTranslation } from 'react-i18next'
import { useDatasetRelatedApps } from '@/service/knowledge/use-dataset'
import ExtraInfo from '../datasets/extra-info'
type DatasetSidebarDropdownProps = {
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
}
const DatasetSidebarDropdown = ({
navigation,
}: DatasetSidebarDropdownProps) => {
const { t } = useTranslation()
const dataset = useDatasetDetailContextWithSelector(state => state.dataset) as DataSet
const { data: relatedApps } = useDatasetRelatedApps(dataset.id)
const [open, doSetOpen] = useState(false)
const openRef = useRef(open)
const setOpen = useCallback((v: boolean) => {
doSetOpen(v)
openRef.current = v
}, [doSetOpen])
const handleTrigger = useCallback(() => {
setOpen(!openRef.current)
}, [setOpen])
const iconInfo = dataset.icon_info || {
icon: '📙',
icon_type: 'emoji',
icon_background: '#FFF4ED',
icon_url: '',
}
const isExternalProvider = dataset.provider === 'external'
const { formatIndexingTechniqueAndMethod } = useKnowledge()
if (!dataset)
return null
return (
<>
<div className='fixed left-2 top-2 z-20'>
<PortalToFollowElem
open={open}
onOpenChange={setOpen}
placement='bottom-start'
offset={{
mainAxis: -41,
}}
>
<PortalToFollowElemTrigger onClick={handleTrigger}>
<div
className={cn(
'flex cursor-pointer items-center rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-1 shadow-lg backdrop-blur-sm hover:bg-background-default-hover',
open && 'bg-background-default-hover',
)}
>
<AppIcon
size='small'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<RiMenuLine className='size-4 text-text-tertiary' />
</div>
</PortalToFollowElemTrigger>
<PortalToFollowElemContent className='z-50'>
<div className='relative w-[216px] rounded-xl border-[0.5px] border-components-panel-border bg-background-default-subtle shadow-lg'>
<Effect className='-left-5 top-[-22px] opacity-15' />
<div className='flex flex-col gap-y-2 p-4'>
<div className='flex items-center justify-between'>
<AppIcon
size='medium'
iconType={iconInfo.icon_type}
icon={iconInfo.icon}
background={iconInfo.icon_background}
imageUrl={iconInfo.icon_url}
/>
<Dropdown expand />
</div>
<div className='flex flex-col gap-y-1 pb-0.5'>
<div
className='system-md-semibold truncate text-text-secondary'
title={dataset.name}
>
{dataset.name}
</div>
<div className='system-2xs-medium-uppercase text-text-tertiary'>
{isExternalProvider && t('dataset.externalTag')}
{!isExternalProvider && dataset.doc_form && dataset.indexing_technique && (
<div className='flex items-center gap-x-2'>
<span>{t(`dataset.chunkingMode.${DOC_FORM_TEXT[dataset.doc_form]}`)}</span>
<span>{formatIndexingTechniqueAndMethod(dataset.indexing_technique, dataset.retrieval_model_dict?.search_method)}</span>
</div>
)}
</div>
</div>
{!!dataset.description && (
<p className='system-xs-regular line-clamp-3 text-text-tertiary first-letter:capitalize'>
{dataset.description}
</p>
)}
</div>
<div className='px-4 py-2'>
<Divider
type='horizontal'
bgStyle='gradient'
className='my-0 h-px bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
/>
</div>
<nav className='flex min-h-[200px] grow flex-col gap-y-0.5 px-3 py-2'>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode='expand'
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
<ExtraInfo
relatedApps={relatedApps}
expand
documentCount={dataset.document_count}
/>
</div>
</PortalToFollowElemContent>
</PortalToFollowElem>
</div>
</>
)
}
export default DatasetSidebarDropdown

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

View File

@@ -0,0 +1,156 @@
import React, { useCallback, useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { useShallow } from 'zustand/react/shallow'
import NavLink from './navLink'
import type { NavIcon } from './navLink'
import AppInfo from './app-info'
import DatasetInfo from './dataset-info'
import AppSidebarDropdown from './app-sidebar-dropdown'
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
import { useStore as useAppStore } from '@/app/components/app/store'
import { useEventEmitterContextContext } from '@/context/event-emitter'
import cn from '@/utils/classnames'
import Divider from '../base/divider'
import { useHover, useKeyPress } from 'ahooks'
import ToggleButton from './toggle-button'
import { getKeyboardKeyCodeBySystem } from '../workflow/utils'
import DatasetSidebarDropdown from './dataset-sidebar-dropdown'
export type IAppDetailNavProps = {
iconType?: 'app' | 'dataset'
navigation: Array<{
name: string
href: string
icon: NavIcon
selectedIcon: NavIcon
disabled?: boolean
}>
extraInfo?: (modeState: string) => React.ReactNode
}
const AppDetailNav = ({
navigation,
extraInfo,
iconType = 'app',
}: IAppDetailNavProps) => {
const { appSidebarExpand, setAppSidebarExpand } = useAppStore(useShallow(state => ({
appSidebarExpand: state.appSidebarExpand,
setAppSidebarExpand: state.setAppSidebarExpand,
})))
const sidebarRef = React.useRef<HTMLDivElement>(null)
const media = useBreakpoints()
const isMobile = media === MediaType.mobile
const expand = appSidebarExpand === 'expand'
const handleToggle = useCallback(() => {
setAppSidebarExpand(appSidebarExpand === 'expand' ? 'collapse' : 'expand')
}, [appSidebarExpand, setAppSidebarExpand])
const isHoveringSidebar = useHover(sidebarRef)
// Check if the current path is a workflow canvas & fullscreen
const pathname = usePathname()
const inWorkflowCanvas = pathname.endsWith('/workflow')
const isPipelineCanvas = pathname.endsWith('/pipeline')
const workflowCanvasMaximize = localStorage.getItem('workflow-canvas-maximize') === 'true'
const [hideHeader, setHideHeader] = useState(workflowCanvasMaximize)
const { eventEmitter } = useEventEmitterContextContext()
eventEmitter?.useSubscription((v: any) => {
if (v?.type === 'workflow-canvas-maximize')
setHideHeader(v.payload)
})
useEffect(() => {
if (appSidebarExpand) {
localStorage.setItem('app-detail-collapse-or-expand', appSidebarExpand)
setAppSidebarExpand(appSidebarExpand)
}
}, [appSidebarExpand, setAppSidebarExpand])
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.b`, (e) => {
e.preventDefault()
handleToggle()
}, { exactMatch: true, useCapture: true })
if (inWorkflowCanvas && hideHeader) {
return (
<div className='flex w-0 shrink-0'>
<AppSidebarDropdown navigation={navigation} />
</div>
)
}
if (isPipelineCanvas && hideHeader) {
return (
<div className='flex w-0 shrink-0'>
<DatasetSidebarDropdown navigation={navigation} />
</div>
)
}
return (
<div
ref={sidebarRef}
className={cn(
'flex shrink-0 flex-col border-r border-divider-burn bg-background-default-subtle transition-all',
expand ? 'w-[216px]' : 'w-14',
)}
>
<div
className={cn(
'shrink-0',
expand ? 'p-2' : 'p-1',
)}
>
{iconType === 'app' && (
<AppInfo expand={expand} />
)}
{iconType !== 'app' && (
<DatasetInfo expand={expand} />
)}
</div>
<div className='relative px-4 py-2'>
<Divider
type='horizontal'
bgStyle={expand ? 'gradient' : 'solid'}
className={cn(
'my-0 h-px',
expand
? 'bg-gradient-to-r from-divider-subtle to-background-gradient-mask-transparent'
: 'bg-divider-subtle',
)}
/>
{!isMobile && isHoveringSidebar && (
<ToggleButton
className='absolute -right-3 top-[-3.5px] z-20'
expand={expand}
handleToggle={handleToggle}
/>
)}
</div>
<nav
className={cn(
'flex grow flex-col gap-y-0.5',
expand ? 'px-3 py-2' : 'p-3',
)}
>
{navigation.map((item, index) => {
return (
<NavLink
key={index}
mode={appSidebarExpand}
iconMap={{ selected: item.selectedIcon, normal: item.icon }}
name={item.name}
href={item.href}
disabled={!!item.disabled}
/>
)
})}
</nav>
{iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
</div>
)
}
export default React.memo(AppDetailNav)

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