mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
main
This commit is contained in:
@ -34,6 +34,7 @@ import { fetchWorkflowDraft } from '@/service/workflow'
|
||||
import ContentDialog from '@/app/components/base/content-dialog'
|
||||
import Button from '@/app/components/base/button'
|
||||
import CardView from '@/app/(commonLayout)/app/(appDetailLayout)/[appId]/overview/cardView'
|
||||
import Divider from '../base/divider'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../base/portal-to-follow-elem'
|
||||
|
||||
export type IAppInfoProps = {
|
||||
@ -247,7 +248,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||
</div>
|
||||
{/* description */}
|
||||
{appDetail.description && (
|
||||
<div className='system-xs-regular text-text-tertiary'>{appDetail.description}</div>
|
||||
<div className='system-xs-regular overflow-wrap-anywhere w-full max-w-full whitespace-normal break-words text-text-tertiary'>{appDetail.description}</div>
|
||||
)}
|
||||
{/* operations */}
|
||||
<div className='flex flex-wrap items-center gap-1 self-stretch'>
|
||||
@ -270,8 +271,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||
onClick={() => {
|
||||
setOpen(false)
|
||||
setShowDuplicateModal(true)
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<RiFileCopy2Line className='h-3.5 w-3.5 text-components-button-secondary-text' />
|
||||
<span className='system-xs-medium text-components-button-secondary-text'>{t('app.duplicate')}</span>
|
||||
</Button>
|
||||
@ -337,6 +337,7 @@ const AppInfo = ({ expand }: IAppInfoProps) => {
|
||||
className='flex grow flex-col gap-2 overflow-auto px-2 py-1'
|
||||
/>
|
||||
</div>
|
||||
<Divider />
|
||||
<div className='flex min-h-fit shrink-0 flex-col items-start justify-center gap-3 self-stretch border-t-[0.5px] border-divider-subtle p-2'>
|
||||
<Button
|
||||
size={'medium'}
|
||||
|
||||
@ -16,7 +16,7 @@ export type IAppDetailNavProps = {
|
||||
desc: string
|
||||
isExternal?: boolean
|
||||
icon: string
|
||||
icon_background: string
|
||||
icon_background: string | null
|
||||
navigation: Array<{
|
||||
name: string
|
||||
href: string
|
||||
|
||||
@ -31,126 +31,98 @@ type Props = {
|
||||
appDetail: App
|
||||
}
|
||||
|
||||
const Annotation: FC<Props> = ({
|
||||
appDetail,
|
||||
}) => {
|
||||
const Annotation: FC<Props> = (props) => {
|
||||
const { appDetail } = props
|
||||
const { t } = useTranslation()
|
||||
const [isShowEdit, setIsShowEdit] = React.useState(false)
|
||||
const [isShowEdit, setIsShowEdit] = useState(false)
|
||||
const [annotationConfig, setAnnotationConfig] = useState<AnnotationReplyConfig | null>(null)
|
||||
const [isChatApp, setIsChatApp] = useState(false)
|
||||
const [isChatApp] = useState(appDetail.mode !== 'completion')
|
||||
const [controlRefreshSwitch, setControlRefreshSwitch] = useState(Date.now())
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({})
|
||||
const [currPage, setCurrPage] = useState(0)
|
||||
const [limit, setLimit] = useState(APP_PAGE_LIMIT)
|
||||
const [list, setList] = useState<AnnotationItem[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [controlUpdateList, setControlUpdateList] = useState(Date.now())
|
||||
const [currItem, setCurrItem] = useState<AnnotationItem | null>(null)
|
||||
const [isShowViewModal, setIsShowViewModal] = useState(false)
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
|
||||
const fetchAnnotationConfig = async () => {
|
||||
const res = await doFetchAnnotationConfig(appDetail.id)
|
||||
setAnnotationConfig(res as AnnotationReplyConfig)
|
||||
return (res as AnnotationReplyConfig).id
|
||||
}
|
||||
useEffect(() => {
|
||||
const isChatApp = appDetail.mode !== 'completion'
|
||||
setIsChatApp(isChatApp)
|
||||
if (isChatApp)
|
||||
fetchAnnotationConfig()
|
||||
}, [])
|
||||
const [controlRefreshSwitch, setControlRefreshSwitch] = useState(Date.now())
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAnnotationFull = (enableBilling && plan.usage.annotatedResponse >= plan.total.annotatedResponse)
|
||||
const [isShowAnnotationFullModal, setIsShowAnnotationFullModal] = useState(false)
|
||||
const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
|
||||
let isCompleted = false
|
||||
while (!isCompleted) {
|
||||
const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId)
|
||||
isCompleted = res.job_status === JobStatus.completed
|
||||
if (isCompleted)
|
||||
break
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatApp) fetchAnnotationConfig()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
const ensureJobCompleted = async (jobId: string, status: AnnotationEnableStatus) => {
|
||||
while (true) {
|
||||
const res: any = await queryAnnotationJobStatus(appDetail.id, status, jobId)
|
||||
if (res.job_status === JobStatus.completed) break
|
||||
await sleep(2000)
|
||||
}
|
||||
}
|
||||
|
||||
const [queryParams, setQueryParams] = useState<QueryParam>({})
|
||||
const [currPage, setCurrPage] = React.useState<number>(0)
|
||||
const debouncedQueryParams = useDebounce(queryParams, { wait: 500 })
|
||||
const [limit, setLimit] = React.useState<number>(APP_PAGE_LIMIT)
|
||||
const query = {
|
||||
page: currPage + 1,
|
||||
limit,
|
||||
keyword: debouncedQueryParams.keyword || '',
|
||||
}
|
||||
|
||||
const [controlUpdateList, setControlUpdateList] = useState(Date.now())
|
||||
const [list, setList] = useState<AnnotationItem[]>([])
|
||||
const [total, setTotal] = useState(10)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const fetchList = async (page = 1) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const { data, total }: any = await fetchAnnotationList(appDetail.id, {
|
||||
...query,
|
||||
page,
|
||||
limit,
|
||||
keyword: debouncedQueryParams.keyword || '',
|
||||
})
|
||||
setList(data as AnnotationItem[])
|
||||
setTotal(total)
|
||||
}
|
||||
catch {
|
||||
|
||||
finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchList(currPage + 1)
|
||||
}, [currPage])
|
||||
|
||||
useEffect(() => {
|
||||
fetchList(1)
|
||||
setControlUpdateList(Date.now())
|
||||
}, [queryParams])
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currPage, limit, debouncedQueryParams])
|
||||
|
||||
const handleAdd = async (payload: AnnotationItemBasic) => {
|
||||
await addAnnotation(appDetail.id, {
|
||||
...payload,
|
||||
})
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
await addAnnotation(appDetail.id, payload)
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
const handleRemove = async (id: string) => {
|
||||
await delAnnotation(appDetail.id, id)
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
const [currItem, setCurrItem] = useState<AnnotationItem | null>(list[0])
|
||||
const [isShowViewModal, setIsShowViewModal] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!isShowEdit)
|
||||
setControlRefreshSwitch(Date.now())
|
||||
}, [isShowEdit])
|
||||
const handleView = (item: AnnotationItem) => {
|
||||
setCurrItem(item)
|
||||
setIsShowViewModal(true)
|
||||
}
|
||||
|
||||
const handleSave = async (question: string, answer: string) => {
|
||||
await editAnnotation(appDetail.id, (currItem as AnnotationItem).id, {
|
||||
question,
|
||||
answer,
|
||||
})
|
||||
Toast.notify({
|
||||
message: t('common.api.actionSuccess'),
|
||||
type: 'success',
|
||||
})
|
||||
if (!currItem) return
|
||||
await editAnnotation(appDetail.id, currItem.id, { question, answer })
|
||||
Toast.notify({ message: t('common.api.actionSuccess'), type: 'success' })
|
||||
fetchList()
|
||||
setControlUpdateList(Date.now())
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShowEdit) setControlRefreshSwitch(Date.now())
|
||||
}, [isShowEdit])
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
<p className='system-sm-regular text-text-tertiary'>{t('appLog.description')}</p>
|
||||
@ -211,6 +183,7 @@ const Annotation: FC<Props> = ({
|
||||
</Filter>
|
||||
{isLoading
|
||||
? <Loading type='app' />
|
||||
// eslint-disable-next-line sonarjs/no-nested-conditional
|
||||
: total > 0
|
||||
? <List
|
||||
list={list}
|
||||
|
||||
@ -0,0 +1,61 @@
|
||||
import { Fragment, useCallback } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { Dialog, Transition } from '@headlessui/react'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
type DialogProps = {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
show: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
const AccessControlDialog = ({
|
||||
className,
|
||||
children,
|
||||
show,
|
||||
onClose,
|
||||
}: DialogProps) => {
|
||||
const close = useCallback(() => {
|
||||
onClose?.()
|
||||
}, [onClose])
|
||||
return (
|
||||
<Transition appear show={show} as={Fragment}>
|
||||
<Dialog as="div" open={true} className="relative z-20" onClose={() => null}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className={cn('relative h-auto min-h-[323px] w-[600px] overflow-y-auto rounded-2xl bg-components-panel-bg p-0 shadow-xl transition-all', className)}>
|
||||
<div onClick={() => close()} className="absolute right-5 top-5 z-10 flex h-8 w-8 cursor-pointer items-center justify-center">
|
||||
<RiCloseLine className='h-5 w-5' />
|
||||
</div>
|
||||
{children}
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition >
|
||||
)
|
||||
}
|
||||
|
||||
export default AccessControlDialog
|
||||
@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
import type { FC, PropsWithChildren } from 'react'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import type { AccessMode } from '@/models/access-control'
|
||||
|
||||
type AccessControlItemProps = PropsWithChildren<{
|
||||
type: AccessMode
|
||||
}>
|
||||
|
||||
const AccessControlItem: FC<AccessControlItemProps> = ({ type, children }) => {
|
||||
const { currentMenu, setCurrentMenu } = useAccessControlStore(s => ({ currentMenu: s.currentMenu, setCurrentMenu: s.setCurrentMenu }))
|
||||
if (currentMenu !== type) {
|
||||
return <div
|
||||
className="cursor-pointer rounded-[10px] border-[1px]
|
||||
border-components-option-card-option-border bg-components-option-card-option-bg
|
||||
hover:border-components-option-card-option-border-hover hover:bg-components-option-card-option-bg-hover"
|
||||
onClick={() => setCurrentMenu(type)} >
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div className="rounded-[10px] border-[1.5px]
|
||||
border-components-option-card-option-selected-border bg-components-option-card-option-selected-bg shadow-sm">
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
AccessControlItem.displayName = 'AccessControlItem'
|
||||
|
||||
export default AccessControlItem
|
||||
@ -0,0 +1,204 @@
|
||||
'use client'
|
||||
import { RiAddCircleFill, RiArrowRightSLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import { FloatingOverlay } from '@floating-ui/react'
|
||||
import Avatar from '../../base/avatar'
|
||||
import Button from '../../base/button'
|
||||
import Checkbox from '../../base/checkbox'
|
||||
import Input from '../../base/input'
|
||||
import { PortalToFollowElem, PortalToFollowElemContent, PortalToFollowElemTrigger } from '../../base/portal-to-follow-elem'
|
||||
import Loading from '../../base/loading'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import classNames from '@/utils/classnames'
|
||||
import { useSearchForWhiteListCandidates } from '@/service/access-control'
|
||||
import type { AccessControlAccount, AccessControlGroup, Subject, SubjectAccount, SubjectGroup } from '@/models/access-control'
|
||||
import { SubjectType } from '@/models/access-control'
|
||||
import { useSelector } from '@/context/app-context'
|
||||
|
||||
export default function AddMemberOrGroupDialog() {
|
||||
const { t } = useTranslation()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const debouncedKeyword = useDebounce(keyword, { wait: 500 })
|
||||
|
||||
const lastAvailableGroup = selectedGroupsForBreadcrumb[selectedGroupsForBreadcrumb.length - 1]
|
||||
const { isLoading, isFetchingNextPage, fetchNextPage, data } = useSearchForWhiteListCandidates({ keyword: debouncedKeyword, groupId: lastAvailableGroup?.id, resultsPerPage: 10 }, open)
|
||||
const handleKeywordChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setKeyword(e.target.value)
|
||||
}
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null)
|
||||
useEffect(() => {
|
||||
const hasMore = data?.pages?.[0].hasMore ?? false
|
||||
let observer: IntersectionObserver | undefined
|
||||
if (anchorRef.current) {
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting && !isLoading && hasMore)
|
||||
fetchNextPage()
|
||||
}, { rootMargin: '20px' })
|
||||
observer.observe(anchorRef.current)
|
||||
}
|
||||
return () => observer?.disconnect()
|
||||
}, [isLoading, fetchNextPage, anchorRef, data])
|
||||
|
||||
return <PortalToFollowElem open={open} onOpenChange={setOpen} offset={{ crossAxis: 300 }} placement='bottom-end'>
|
||||
<PortalToFollowElemTrigger asChild>
|
||||
<Button variant='ghost-accent' size='small' className='flex shrink-0 items-center gap-x-0.5' onClick={() => setOpen(!open)}>
|
||||
<RiAddCircleFill className='h-4 w-4' />
|
||||
<span>{t('common.operation.add')}</span>
|
||||
</Button>
|
||||
</PortalToFollowElemTrigger>
|
||||
{open && <FloatingOverlay />}
|
||||
<PortalToFollowElemContent className='z-[25]'>
|
||||
<div className='relative flex max-h-[400px] w-[400px] flex-col overflow-y-auto rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-[5px]'>
|
||||
<div className='sticky top-0 z-10 bg-components-panel-bg-blur p-2 pb-0.5 backdrop-blur-[5px]'>
|
||||
<Input value={keyword} onChange={handleKeywordChange} showLeftIcon placeholder={t('app.accessControlDialog.operateGroupAndMember.searchPlaceholder') as string} />
|
||||
</div>
|
||||
{
|
||||
isLoading
|
||||
? <div className='p-1'><Loading /></div>
|
||||
: (data?.pages?.length ?? 0) > 0
|
||||
? <>
|
||||
<div className='flex h-7 items-center px-2 py-0.5'>
|
||||
<SelectedGroupsBreadCrumb />
|
||||
</div>
|
||||
<div className='p-1'>
|
||||
{renderGroupOrMember(data?.pages ?? [])}
|
||||
{isFetchingNextPage && <Loading />}
|
||||
</div>
|
||||
<div ref={anchorRef} className='h-0'> </div>
|
||||
</>
|
||||
: <div className='flex h-7 items-center justify-center px-2 py-0.5'>
|
||||
<span className='system-xs-regular text-text-tertiary'>{t('app.accessControlDialog.operateGroupAndMember.noResult')}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
}
|
||||
|
||||
type GroupOrMemberData = { subjects: Subject[]; currPage: number }[]
|
||||
function renderGroupOrMember(data: GroupOrMemberData) {
|
||||
return data?.map((page) => {
|
||||
return <div key={`search_group_member_page_${page.currPage}`}>
|
||||
{page.subjects?.map((item, index) => {
|
||||
if (item.subjectType === SubjectType.GROUP)
|
||||
return <GroupItem key={index} group={(item as SubjectGroup).groupData} />
|
||||
return <MemberItem key={index} member={(item as SubjectAccount).accountData} />
|
||||
})}
|
||||
</div>
|
||||
}) ?? null
|
||||
}
|
||||
|
||||
function SelectedGroupsBreadCrumb() {
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleBreadCrumbClick = useCallback((index: number) => {
|
||||
const newGroups = selectedGroupsForBreadcrumb.slice(0, index + 1)
|
||||
setSelectedGroupsForBreadcrumb(newGroups)
|
||||
}, [setSelectedGroupsForBreadcrumb, selectedGroupsForBreadcrumb])
|
||||
const handleReset = useCallback(() => {
|
||||
setSelectedGroupsForBreadcrumb([])
|
||||
}, [setSelectedGroupsForBreadcrumb])
|
||||
return <div className='flex h-7 items-center gap-x-0.5 px-2 py-0.5'>
|
||||
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'text-text-accent cursor-pointer')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||
<span>/</span>
|
||||
<span className={index === selectedGroupsForBreadcrumb.length - 1 ? '' : 'cursor-pointer text-text-accent'} onClick={() => handleBreadCrumbClick(index)}>{group.name}</span>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const selectedGroupsForBreadcrumb = useAccessControlStore(s => s.selectedGroupsForBreadcrumb)
|
||||
const setSelectedGroupsForBreadcrumb = useAccessControlStore(s => s.setSelectedGroupsForBreadcrumb)
|
||||
const isChecked = specificGroups.some(g => g.id === group.id)
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (!isChecked) {
|
||||
const newGroups = [...specificGroups, group]
|
||||
setSpecificGroups(newGroups)
|
||||
}
|
||||
else {
|
||||
const newGroups = specificGroups.filter(g => g.id !== group.id)
|
||||
setSpecificGroups(newGroups)
|
||||
}
|
||||
}, [specificGroups, setSpecificGroups, group, isChecked])
|
||||
|
||||
const handleExpandClick = useCallback(() => {
|
||||
setSelectedGroupsForBreadcrumb([...selectedGroupsForBreadcrumb, group])
|
||||
}, [selectedGroupsForBreadcrumb, setSelectedGroupsForBreadcrumb, group])
|
||||
return <BaseItem>
|
||||
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||
<div className='item-center flex grow'>
|
||||
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />
|
||||
</div>
|
||||
</div>
|
||||
<p className='system-sm-medium mr-1 text-text-secondary'>{group.name}</p>
|
||||
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||
</div>
|
||||
<Button size="small" disabled={isChecked} variant='ghost-accent'
|
||||
className='flex shrink-0 items-center justify-between px-1.5 py-1' onClick={handleExpandClick}>
|
||||
<span className='px-[3px]'>{t('app.accessControlDialog.operateGroupAndMember.expand')}</span>
|
||||
<RiArrowRightSLine className='h-4 w-4' />
|
||||
</Button>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const currentUser = useSelector(s => s.userProfile)
|
||||
const { t } = useTranslation()
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const isChecked = specificMembers.some(m => m.id === member.id)
|
||||
const handleCheckChange = useCallback(() => {
|
||||
if (!isChecked) {
|
||||
const newMembers = [...specificMembers, member]
|
||||
setSpecificMembers(newMembers)
|
||||
}
|
||||
else {
|
||||
const newMembers = specificMembers.filter(m => m.id !== member.id)
|
||||
setSpecificMembers(newMembers)
|
||||
}
|
||||
}, [specificMembers, setSpecificMembers, member, isChecked])
|
||||
return <BaseItem className='pr-3'>
|
||||
<Checkbox checked={isChecked} className='h-4 w-4 shrink-0' onCheck={handleCheckChange} />
|
||||
<div className='flex grow items-center'>
|
||||
<div className='mr-2 h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />
|
||||
</div>
|
||||
</div>
|
||||
<p className='system-sm-medium mr-1 text-text-secondary'>{member.name}</p>
|
||||
{currentUser.email === member.email && <p className='system-xs-regular text-text-tertiary'>({t('common.you')})</p>}
|
||||
</div>
|
||||
<p className='system-xs-regular text-text-quaternary'>{member.email}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
className?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
function BaseItem({ children, className }: BaseItemProps) {
|
||||
return <div className={classNames('p-1 pl-2 flex items-center space-x-2 hover:rounded-lg hover:bg-state-base-hover cursor-pointer', className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
110
web/app/components/app/app-access-control/index.tsx
Normal file
110
web/app/components/app/app-access-control/index.tsx
Normal file
@ -0,0 +1,110 @@
|
||||
'use client'
|
||||
import { Description as DialogDescription, DialogTitle } from '@headlessui/react'
|
||||
import { RiBuildingLine, RiGlobalLine, RiVerifiedBadgeLine } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Button from '../../base/button'
|
||||
import Toast from '../../base/toast'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AccessControlDialog from './access-control-dialog'
|
||||
import AccessControlItem from './access-control-item'
|
||||
import SpecificGroupsOrMembers, { WebAppSSONotEnabledTip } from './specific-groups-or-members'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import type { App } from '@/types/app'
|
||||
import type { Subject } from '@/models/access-control'
|
||||
import { AccessMode, SubjectType } from '@/models/access-control'
|
||||
import { useUpdateAccessMode } from '@/service/access-control'
|
||||
|
||||
type AccessControlProps = {
|
||||
app: App
|
||||
onClose: () => void
|
||||
onConfirm?: () => void
|
||||
}
|
||||
|
||||
export default function AccessControl(props: AccessControlProps) {
|
||||
const { app, onClose, onConfirm } = props
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const setAppId = useAccessControlStore(s => s.setAppId)
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const setCurrentMenu = useAccessControlStore(s => s.setCurrentMenu)
|
||||
const hideTip = systemFeatures.webapp_auth.enabled
|
||||
&& (systemFeatures.webapp_auth.allow_sso
|
||||
|| systemFeatures.webapp_auth.allow_email_password_login
|
||||
|| systemFeatures.webapp_auth.allow_email_code_login)
|
||||
|
||||
useEffect(() => {
|
||||
setAppId(app.id)
|
||||
setCurrentMenu(app.access_mode ?? AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
}, [app, setAppId, setCurrentMenu])
|
||||
|
||||
const { isPending, mutateAsync: updateAccessMode } = useUpdateAccessMode()
|
||||
const handleConfirm = useCallback(async () => {
|
||||
const submitData: {
|
||||
appId: string
|
||||
accessMode: AccessMode
|
||||
subjects?: Pick<Subject, 'subjectId' | 'subjectType'>[]
|
||||
} = { appId: app.id, accessMode: currentMenu }
|
||||
if (currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
const subjects: Pick<Subject, 'subjectId' | 'subjectType'>[] = []
|
||||
specificGroups.forEach((group) => {
|
||||
subjects.push({ subjectId: group.id, subjectType: SubjectType.GROUP })
|
||||
})
|
||||
specificMembers.forEach((member) => {
|
||||
subjects.push({
|
||||
subjectId: member.id,
|
||||
subjectType: SubjectType.ACCOUNT,
|
||||
})
|
||||
})
|
||||
submitData.subjects = subjects
|
||||
}
|
||||
await updateAccessMode(submitData)
|
||||
Toast.notify({ type: 'success', message: t('app.accessControlDialog.updateSuccess') })
|
||||
onConfirm?.()
|
||||
}, [updateAccessMode, app, specificGroups, specificMembers, t, onConfirm, currentMenu])
|
||||
return <AccessControlDialog show onClose={onClose}>
|
||||
<div className='flex flex-col gap-y-3'>
|
||||
<div className='pb-3 pl-6 pr-14 pt-6'>
|
||||
<DialogTitle className='title-2xl-semi-bold text-text-primary'>{t('app.accessControlDialog.title')}</DialogTitle>
|
||||
<DialogDescription className='system-xs-regular mt-1 text-text-tertiary'>{t('app.accessControlDialog.description')}</DialogDescription>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-1 px-6 pb-3'>
|
||||
<div className='leading-6'>
|
||||
<p className='system-sm-medium'>{t('app.accessControlDialog.accessLabel')}</p>
|
||||
</div>
|
||||
<AccessControlItem type={AccessMode.ORGANIZATION}>
|
||||
<div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiBuildingLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.SPECIFIC_GROUPS_MEMBERS}>
|
||||
<SpecificGroupsOrMembers />
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.EXTERNAL_MEMBERS}>
|
||||
<div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.external')}</p>
|
||||
</div>
|
||||
{!hideTip && <WebAppSSONotEnabledTip />}
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
<AccessControlItem type={AccessMode.PUBLIC}>
|
||||
<div className='flex items-center gap-x-2 p-3'>
|
||||
<RiGlobalLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||
</div>
|
||||
</AccessControlItem>
|
||||
</div>
|
||||
<div className='flex items-center justify-end gap-x-2 p-6 pt-5'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button disabled={isPending} loading={isPending} variant='primary' onClick={handleConfirm}>{t('common.operation.confirm')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</AccessControlDialog>
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
'use client'
|
||||
import { RiAlertFill, RiCloseCircleFill, RiLockLine, RiOrganizationChart } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useCallback, useEffect } from 'react'
|
||||
import Avatar from '../../base/avatar'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import Loading from '../../base/loading'
|
||||
import useAccessControlStore from '../../../../context/access-control-store'
|
||||
import AddMemberOrGroupDialog from './add-member-or-group-pop'
|
||||
import type { AccessControlAccount, AccessControlGroup } from '@/models/access-control'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
|
||||
export default function SpecificGroupsOrMembers() {
|
||||
const currentMenu = useAccessControlStore(s => s.currentMenu)
|
||||
const appId = useAccessControlStore(s => s.appId)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const { t } = useTranslation()
|
||||
|
||||
const { isPending, data } = useAppWhiteListSubjects(appId, Boolean(appId) && currentMenu === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
useEffect(() => {
|
||||
setSpecificGroups(data?.groups ?? [])
|
||||
setSpecificMembers(data?.members ?? [])
|
||||
}, [data, setSpecificGroups, setSpecificMembers])
|
||||
|
||||
if (currentMenu !== AccessMode.SPECIFIC_GROUPS_MEMBERS) {
|
||||
return <div className='flex items-center p-3'>
|
||||
<div className='flex grow items-center gap-x-2'>
|
||||
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
return <div>
|
||||
<div className='flex items-center gap-x-1 p-3'>
|
||||
<div className='flex grow items-center gap-x-1'>
|
||||
<RiLockLine className='h-4 w-4 text-text-primary' />
|
||||
<p className='system-sm-medium text-text-primary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||
</div>
|
||||
<div className='flex items-center gap-x-1'>
|
||||
<AddMemberOrGroupDialog />
|
||||
</div>
|
||||
</div>
|
||||
<div className='px-1 pb-1'>
|
||||
<div className='flex max-h-[400px] flex-col gap-y-2 overflow-y-auto rounded-lg bg-background-section p-2'>
|
||||
{isPending ? <Loading /> : <RenderGroupsAndMembers />}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
}
|
||||
|
||||
function RenderGroupsAndMembers() {
|
||||
const { t } = useTranslation()
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
if (specificGroups.length <= 0 && specificMembers.length <= 0)
|
||||
return <div className='px-2 pb-1.5 pt-5'><p className='system-xs-regular text-center text-text-tertiary'>{t('app.accessControlDialog.noGroupsOrMembers')}</p></div>
|
||||
return <>
|
||||
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.groups', { count: specificGroups.length ?? 0 })}</p>
|
||||
<div className='flex flex-row flex-wrap gap-1'>
|
||||
{specificGroups.map((group, index) => <GroupItem key={index} group={group} />)}
|
||||
</div>
|
||||
<p className='system-2xs-medium-uppercase sticky top-0 text-text-tertiary'>{t('app.accessControlDialog.members', { count: specificMembers.length ?? 0 })}</p>
|
||||
<div className='flex flex-row flex-wrap gap-1'>
|
||||
{specificMembers.map((member, index) => <MemberItem key={index} member={member} />)}
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
|
||||
type GroupItemProps = {
|
||||
group: AccessControlGroup
|
||||
}
|
||||
function GroupItem({ group }: GroupItemProps) {
|
||||
const specificGroups = useAccessControlStore(s => s.specificGroups)
|
||||
const setSpecificGroups = useAccessControlStore(s => s.setSpecificGroups)
|
||||
const handleRemoveGroup = useCallback(() => {
|
||||
setSpecificGroups(specificGroups.filter(g => g.id !== group.id))
|
||||
}, [group, setSpecificGroups, specificGroups])
|
||||
return <BaseItem icon={<RiOrganizationChart className='h-[14px] w-[14px] text-components-avatar-shape-fill-stop-0' />}
|
||||
onRemove={handleRemoveGroup}>
|
||||
<p className='system-xs-regular text-text-primary'>{group.name}</p>
|
||||
<p className='system-xs-regular text-text-tertiary'>{group.groupSize}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type MemberItemProps = {
|
||||
member: AccessControlAccount
|
||||
}
|
||||
function MemberItem({ member }: MemberItemProps) {
|
||||
const specificMembers = useAccessControlStore(s => s.specificMembers)
|
||||
const setSpecificMembers = useAccessControlStore(s => s.setSpecificMembers)
|
||||
const handleRemoveMember = useCallback(() => {
|
||||
setSpecificMembers(specificMembers.filter(m => m.id !== member.id))
|
||||
}, [member, setSpecificMembers, specificMembers])
|
||||
return <BaseItem icon={<Avatar className='h-[14px] w-[14px]' textClassName='text-[12px]' avatar={null} name={member.name} />}
|
||||
onRemove={handleRemoveMember}>
|
||||
<p className='system-xs-regular text-text-primary'>{member.name}</p>
|
||||
</BaseItem>
|
||||
}
|
||||
|
||||
type BaseItemProps = {
|
||||
icon: React.ReactNode
|
||||
children: React.ReactNode
|
||||
onRemove?: () => void
|
||||
}
|
||||
function BaseItem({ icon, onRemove, children }: BaseItemProps) {
|
||||
return <div className='group flex flex-row items-center gap-x-1 rounded-full border-[0.5px] bg-components-badge-white-to-dark p-1 pr-1.5 shadow-xs'>
|
||||
<div className='h-5 w-5 overflow-hidden rounded-full bg-components-icon-bg-blue-solid'>
|
||||
<div className='bg-access-app-icon-mask-bg flex h-full w-full items-center justify-center'>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
{children}
|
||||
<div className='flex h-4 w-4 cursor-pointer items-center justify-center' onClick={onRemove}>
|
||||
<RiCloseCircleFill className='h-[14px] w-[14px] text-text-quaternary' />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function WebAppSSONotEnabledTip() {
|
||||
const { t } = useTranslation()
|
||||
return <Tooltip asChild={false} popupContent={t('app.accessControlDialog.webAppSSONotEnabledTip')}>
|
||||
<RiAlertFill className='h-4 w-4 shrink-0 text-text-warning-secondary' />
|
||||
</Tooltip>
|
||||
}
|
||||
@ -1,21 +1,31 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
RiArrowRightSLine,
|
||||
RiBuildingLine,
|
||||
RiGlobalLine,
|
||||
RiLockLine,
|
||||
RiPlanetLine,
|
||||
RiPlayCircleLine,
|
||||
RiPlayList2Line,
|
||||
RiTerminalBoxLine,
|
||||
RiVerifiedBadgeLine,
|
||||
} from '@remixicon/react'
|
||||
import { useKeyPress } from 'ahooks'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import Toast from '../../base/toast'
|
||||
import type { ModelAndParameter } from '../configuration/debug/types'
|
||||
import { getKeyboardKeyCodeBySystem } from '../../workflow/utils'
|
||||
import Divider from '../../base/divider'
|
||||
import AccessControl from '../app-access-control'
|
||||
import Loading from '../../base/loading'
|
||||
import Tooltip from '../../base/tooltip'
|
||||
import SuggestedAction from './suggested-action'
|
||||
import PublishWithMultipleModel from './publish-with-multiple-model'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -34,6 +44,10 @@ import WorkflowToolConfigureButton from '@/app/components/tools/workflow-tool/co
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import { appDefaultIconBackground } from '@/config'
|
||||
import type { PublishWorkflowParams } from '@/types/workflow'
|
||||
import { useAppWhiteListSubjects, useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export type AppPublisherProps = {
|
||||
disabled?: boolean
|
||||
@ -74,11 +88,33 @@ const AppPublisher = ({
|
||||
const [published, setPublished] = useState(false)
|
||||
const [open, setOpen] = useState(false)
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(s => s.setAppDetail)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { app_base_url: appBaseURL = '', access_token: accessToken = '' } = appDetail?.site ?? {}
|
||||
const appMode = (appDetail?.mode !== 'completion' && appDetail?.mode !== 'workflow') ? 'chat' : appDetail.mode
|
||||
const appURL = `${appBaseURL}${basePath}/${appMode}/${accessToken}`
|
||||
const isChatApp = ['chat', 'agent-chat', 'completion'].includes(appDetail?.mode || '')
|
||||
const { data: userCanAccessApp, isLoading: isGettingUserCanAccessApp, refetch } = useGetUserCanAccessApp({ appId: appDetail?.id, enabled: false })
|
||||
const { data: appAccessSubjects, isLoading: isGettingAppWhiteListSubjects } = useAppWhiteListSubjects(appDetail?.id, open && systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
|
||||
useEffect(() => {
|
||||
if (systemFeatures.webapp_auth.enabled && open && appDetail)
|
||||
refetch()
|
||||
}, [open, appDetail, refetch, systemFeatures])
|
||||
|
||||
const [showAppAccessControl, setShowAppAccessControl] = useState(false)
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
const language = useGetLanguage()
|
||||
const formatTimeFromNow = useCallback((time: number) => {
|
||||
return dayjs(time).locale(language === 'zh_Hans' ? 'zh-cn' : language.replace('_', '-')).fromNow()
|
||||
@ -99,7 +135,7 @@ const AppPublisher = ({
|
||||
await onRestore?.()
|
||||
setOpen(false)
|
||||
}
|
||||
catch {}
|
||||
catch { }
|
||||
}, [onRestore])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
@ -130,6 +166,13 @@ const AppPublisher = ({
|
||||
}
|
||||
}, [appDetail?.id])
|
||||
|
||||
const handleAccessControlUpdate = useCallback(() => {
|
||||
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
|
||||
setAppDetail(res)
|
||||
setShowAppAccessControl(false)
|
||||
})
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
const [embeddingModalOpen, setEmbeddingModalOpen] = useState(false)
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.shift.p`, (e) => {
|
||||
@ -138,7 +181,7 @@ const AppPublisher = ({
|
||||
return
|
||||
handlePublish()
|
||||
},
|
||||
{ exactMatch: true, useCapture: true })
|
||||
{ exactMatch: true, useCapture: true })
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -223,70 +266,127 @@ const AppPublisher = ({
|
||||
)
|
||||
}
|
||||
</div>
|
||||
<div className='border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||
<SuggestedAction
|
||||
disabled={!publishedAt}
|
||||
link={appURL}
|
||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.runApp')}
|
||||
</SuggestedAction>
|
||||
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
|
||||
? (
|
||||
<SuggestedAction
|
||||
disabled={!publishedAt}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.batchRunApp')}
|
||||
</SuggestedAction>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
{(systemFeatures.webapp_auth.enabled && (isGettingUserCanAccessApp || isGettingAppWhiteListSubjects))
|
||||
? <div className='py-2'><Loading /></div>
|
||||
: <>
|
||||
<Divider className='my-0' />
|
||||
{systemFeatures.webapp_auth.enabled && <div className='p-4 pt-3'>
|
||||
<div className='flex h-6 items-center'>
|
||||
<p className='system-xs-medium text-text-tertiary'>{t('app.publishApp.title')}</p>
|
||||
</div>
|
||||
<div className='flex h-8 cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2 hover:bg-primary-50 hover:text-text-accent'
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
setShowAppAccessControl(true)
|
||||
}}>
|
||||
<div className='flex grow items-center gap-x-1.5 overflow-hidden pr-1'>
|
||||
{appDetail?.access_mode === AccessMode.ORGANIZATION
|
||||
&& <>
|
||||
<RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
&& <>
|
||||
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<div className='grow truncate'>
|
||||
<span className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.PUBLIC
|
||||
&& <>
|
||||
<RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
|
||||
&& <>
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
|
||||
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
|
||||
</div>
|
||||
</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular mt-1 text-text-warning'>{t('app.publishApp.notSetDesc')}</p>}
|
||||
</div>}
|
||||
<div className='flex flex-col gap-y-1 border-t-[0.5px] border-t-divider-regular p-4 pt-3'>
|
||||
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && appDetail?.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result)}
|
||||
link={appURL}
|
||||
icon={<RiPlayCircleLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.runApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
{appDetail?.mode === 'workflow' || appDetail?.mode === 'completion'
|
||||
? (
|
||||
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || appDetail.access_mode === AccessMode.EXTERNAL_MEMBERS || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && appDetail.access_mode !== AccessMode.EXTERNAL_MEMBERS && !userCanAccessApp?.result)}
|
||||
link={`${appURL}${appURL.includes('?') ? '&' : '?'}mode=batch`}
|
||||
icon={<RiPlayList2Line className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.batchRunApp')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
)
|
||||
: (
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
setEmbeddingModalOpen(true)
|
||||
handleTrigger()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.embedIntoSite')}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<Tooltip triggerClassName='flex' disabled={!systemFeatures.webapp_auth.enabled || userCanAccessApp?.result} popupContent={t('app.noAccessPermission')} asChild={false}>
|
||||
<SuggestedAction
|
||||
className='flex-1'
|
||||
onClick={() => {
|
||||
publishedAt && handleOpenInExplore()
|
||||
}}
|
||||
disabled={!publishedAt || (systemFeatures.webapp_auth.enabled && !userCanAccessApp?.result)}
|
||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.openInExplore')}
|
||||
</SuggestedAction>
|
||||
</Tooltip>
|
||||
<SuggestedAction
|
||||
disabled={!publishedAt}
|
||||
icon={<CodeBrowser className='h-4 w-4' />}
|
||||
link='./develop'
|
||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.embedIntoSite')}
|
||||
{t('workflow.common.accessAPIReference')}
|
||||
</SuggestedAction>
|
||||
)}
|
||||
<SuggestedAction
|
||||
onClick={() => {
|
||||
publishedAt && handleOpenInExplore()
|
||||
}}
|
||||
disabled={!publishedAt}
|
||||
icon={<RiPlanetLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.openInExplore')}
|
||||
</SuggestedAction>
|
||||
<SuggestedAction
|
||||
disabled={!publishedAt}
|
||||
link='./develop'
|
||||
icon={<RiTerminalBoxLine className='h-4 w-4' />}
|
||||
>
|
||||
{t('workflow.common.accessAPIReference')}
|
||||
</SuggestedAction>
|
||||
{appDetail?.mode === 'workflow' && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={!publishedAt}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{appDetail?.mode === 'workflow' && (
|
||||
<WorkflowToolConfigureButton
|
||||
disabled={!publishedAt}
|
||||
published={!!toolPublished}
|
||||
detailNeedUpdate={!!toolPublished && published}
|
||||
workflowAppId={appDetail?.id}
|
||||
icon={{
|
||||
content: (appDetail.icon_type === 'image' ? '🤖' : appDetail?.icon) || '🤖',
|
||||
background: (appDetail.icon_type === 'image' ? appDefaultIconBackground : appDetail?.icon_background) || appDefaultIconBackground,
|
||||
}}
|
||||
name={appDetail?.name}
|
||||
description={appDetail?.description}
|
||||
inputs={inputs}
|
||||
handlePublish={handlePublish}
|
||||
onRefreshData={onRefreshData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
<EmbeddedModal
|
||||
@ -296,9 +396,9 @@ const AppPublisher = ({
|
||||
appBaseUrl={appBaseURL}
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
{showAppAccessControl && <AccessControl app={appDetail!} onConfirm={handleAccessControlUpdate} onClose={() => { setShowAppAccessControl(false) }} />}
|
||||
</PortalToFollowElem >
|
||||
</>
|
||||
)
|
||||
</>)
|
||||
}
|
||||
|
||||
export default memo(AppPublisher)
|
||||
|
||||
@ -8,22 +8,30 @@ export type SuggestedActionProps = PropsWithChildren<HTMLProps<HTMLAnchorElement
|
||||
disabled?: boolean
|
||||
}>
|
||||
|
||||
const SuggestedAction = ({ icon, link, disabled, children, className, ...props }: SuggestedActionProps) => (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={classNames(
|
||||
'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg transition-colors [&:not(:first-child)]:mt-1',
|
||||
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative h-4 w-4'>{icon}</div>
|
||||
<div className='system-sm-medium shrink grow basis-0'>{children}</div>
|
||||
<RiArrowRightUpLine className='h-3.5 w-3.5' />
|
||||
</a>
|
||||
)
|
||||
const SuggestedAction = ({ icon, link, disabled, children, className, onClick, ...props }: SuggestedActionProps) => {
|
||||
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
||||
if (disabled)
|
||||
return
|
||||
onClick?.(e)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
href={disabled ? undefined : link}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
className={classNames(
|
||||
'flex justify-start items-center gap-2 py-2 px-2.5 bg-background-section-burn rounded-lg text-text-secondary transition-colors [&:not(:first-child)]:mt-1',
|
||||
disabled ? 'shadow-xs opacity-30 cursor-not-allowed' : 'text-text-secondary hover:bg-state-accent-hover hover:text-text-accent cursor-pointer',
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
<div className='relative h-4 w-4'>{icon}</div>
|
||||
<div className='system-sm-medium shrink grow basis-0'>{children}</div>
|
||||
<RiArrowRightUpLine className='h-3.5 w-3.5' />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default SuggestedAction
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OperationBtn from '@/app/components/app/configuration/base/operation-btn'
|
||||
import Panel from '@/app/components/app/configuration/base/feature-panel'
|
||||
import { MessageClockCircle } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n/language'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type Props = {
|
||||
showWarning: boolean
|
||||
@ -19,7 +17,7 @@ const HistoryPanel: FC<Props> = ({
|
||||
onShowEditModal,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const docLink = useDocLink()
|
||||
|
||||
return (
|
||||
<Panel
|
||||
@ -45,9 +43,8 @@ const HistoryPanel: FC<Props> = ({
|
||||
{showWarning && (
|
||||
<div className='flex justify-between rounded-b-xl bg-background-section-burn px-3 py-2 text-xs text-text-secondary'>
|
||||
<div>{t('appDebug.feature.conversationHistory.tip')}
|
||||
<a href={`${locale === LanguagesSupported[1]
|
||||
? 'https://docs.dify.ai/zh-hans/learn-more/extended-reading/prompt-engineering/README'
|
||||
: 'https://docs.dify.ai/en/features/prompt-engineering'}`}
|
||||
<a href={docLink('/learn-more/extended-reading/what-is-llmops',
|
||||
{ 'zh-Hans': '/learn-more/extended-reading/prompt-engineering/README' })}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='text-[#155EEF]'>{t('appDebug.feature.conversationHistory.learnMore')}
|
||||
</a>
|
||||
|
||||
@ -10,7 +10,6 @@ import PromptEditorHeightResizeWrap from './prompt-editor-height-resize-wrap'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import type { CompletionParams } from '@/types/app'
|
||||
import { AppType } from '@/types/app'
|
||||
import { getNewVar, getVars } from '@/utils/var'
|
||||
import AutomaticBtn from '@/app/components/app/configuration/config/automatic/automatic-btn'
|
||||
@ -63,7 +62,6 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
const { eventEmitter } = useEventEmitterContextContext()
|
||||
const {
|
||||
modelConfig,
|
||||
completionParams,
|
||||
dataSets,
|
||||
setModelConfig,
|
||||
setPrevPromptConfig,
|
||||
@ -264,14 +262,6 @@ const Prompt: FC<ISimplePromptInput> = ({
|
||||
{showAutomatic && (
|
||||
<GetAutomaticResModal
|
||||
mode={mode as AppType}
|
||||
model={
|
||||
{
|
||||
provider: modelConfig.provider,
|
||||
name: modelConfig.model_id,
|
||||
mode: modelConfig.mode,
|
||||
completion_params: completionParams as CompletionParams,
|
||||
}
|
||||
}
|
||||
isShow={showAutomatic}
|
||||
onClose={showAutomaticFalse}
|
||||
onFinished={handleAutomaticRes}
|
||||
|
||||
@ -234,9 +234,14 @@ const ConfigModal: FC<IConfigModalProps> = ({
|
||||
)}
|
||||
|
||||
<div className='!mt-5 flex h-6 items-center space-x-2'>
|
||||
<Checkbox checked={tempPayload.required} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
|
||||
<Checkbox checked={tempPayload.required} disabled={tempPayload.hide} onCheck={() => handlePayloadChange('required')(!tempPayload.required)} />
|
||||
<span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.required')}</span>
|
||||
</div>
|
||||
|
||||
<div className='!mt-5 flex h-6 items-center space-x-2'>
|
||||
<Checkbox checked={tempPayload.hide} disabled={tempPayload.required} onCheck={() => handlePayloadChange('hide')(!tempPayload.hide)} />
|
||||
<span className='system-sm-semibold text-text-secondary'>{t('appDebug.variableConfig.hide')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ModalFoot
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import copy from 'copy-to-clipboard'
|
||||
@ -34,6 +34,7 @@ import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/bloc
|
||||
import { canFindTool } from '@/utils'
|
||||
import { useAllBuiltInTools, useAllCustomTools, useAllMCPTools, useAllWorkflowTools } from '@/service/use-tools'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { useMittContextSelector } from '@/context/mitt-context'
|
||||
|
||||
type AgentToolWithMoreInfo = AgentTool & { icon: any; collection?: Collection } | null
|
||||
const AgentTools: FC = () => {
|
||||
@ -55,7 +56,6 @@ const AgentTools: FC = () => {
|
||||
}, [buildInTools, customTools, workflowTools, mcpTools])
|
||||
|
||||
const formattingChangedDispatcher = useFormattingChangedDispatcher()
|
||||
|
||||
const [currentTool, setCurrentTool] = useState<AgentToolWithMoreInfo>(null)
|
||||
const currentCollection = useMemo(() => {
|
||||
if (!currentTool) return null
|
||||
@ -77,6 +77,17 @@ const AgentTools: FC = () => {
|
||||
collection,
|
||||
}
|
||||
})
|
||||
const useSubscribe = useMittContextSelector(s => s.useSubscribe)
|
||||
const handleUpdateToolsWhenInstallToolSuccess = useCallback((installedPluginNames: string[]) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
draft.agentConfig.tools.forEach((item: any) => {
|
||||
if (item.isDeleted && installedPluginNames.includes(item.provider_id))
|
||||
item.isDeleted = false
|
||||
})
|
||||
})
|
||||
setModelConfig(newModelConfig)
|
||||
}, [modelConfig, setModelConfig])
|
||||
useSubscribe('plugin:install:success', handleUpdateToolsWhenInstallToolSuccess as any)
|
||||
|
||||
const handleToolSettingChange = (value: Record<string, any>) => {
|
||||
const newModelConfig = produce(modelConfig, (draft) => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import {
|
||||
@ -22,7 +22,7 @@ import Textarea from '@/app/components/base/textarea'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { generateRule } from '@/service/debug'
|
||||
import ConfigPrompt from '@/app/components/app/configuration/config-prompt'
|
||||
import type { Model } from '@/types/app'
|
||||
import type { CompletionParams, Model } from '@/types/app'
|
||||
import { AppType } from '@/types/app'
|
||||
import ConfigVar from '@/app/components/app/configuration/config-var'
|
||||
import GroupName from '@/app/components/app/configuration/base/group-name'
|
||||
@ -33,14 +33,15 @@ import { LoveMessage } from '@/app/components/base/icons/src/vender/features'
|
||||
// type
|
||||
import type { AutomaticRes } from '@/service/debug'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import type { ModelModeType } from '@/types/app'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export type IGetAutomaticResProps = {
|
||||
mode: AppType
|
||||
model: Model
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onFinished: (res: AutomaticRes) => void
|
||||
@ -65,16 +66,23 @@ const TryLabel: FC<{
|
||||
|
||||
const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
mode,
|
||||
model,
|
||||
isShow,
|
||||
onClose,
|
||||
isInLLMNode,
|
||||
onFinished,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
||||
: null
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: {} as CompletionParams,
|
||||
})
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const tryList = [
|
||||
{
|
||||
@ -115,7 +123,7 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
},
|
||||
]
|
||||
|
||||
const [instruction, setInstruction] = React.useState<string>('')
|
||||
const [instruction, setInstruction] = useState<string>('')
|
||||
const handleChooseTemplate = useCallback((key: string) => {
|
||||
return () => {
|
||||
const template = t(`appDebug.generate.template.${key}.instruction`)
|
||||
@ -135,7 +143,25 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
return true
|
||||
}
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const [res, setRes] = React.useState<AutomaticRes | null>(null)
|
||||
const [res, setRes] = useState<AutomaticRes | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
||||
: null
|
||||
if (localModel) {
|
||||
setModel(localModel)
|
||||
}
|
||||
else {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const renderLoading = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3'>
|
||||
@ -154,6 +180,26 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
</div>
|
||||
)
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const onGenerate = async () => {
|
||||
if (!isValid())
|
||||
return
|
||||
@ -198,17 +244,18 @@ const GetAutomaticRes: FC<IGetAutomaticResProps> = ({
|
||||
<div className={`text-lg font-bold leading-[28px] ${s.textGradient}`}>{t('appDebug.generate.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.generate.description')}</div>
|
||||
</div>
|
||||
<div className='mb-8 flex items-center'>
|
||||
<ModelIcon
|
||||
className='mr-1.5 shrink-0 '
|
||||
provider={currentProvider}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
<ModelName
|
||||
className='grow'
|
||||
modelItem={currentModel!}
|
||||
showMode
|
||||
showFeatures
|
||||
<div className='mb-8'>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[520px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
mode={model.mode}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div >
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import React, { useCallback, useEffect } from 'react'
|
||||
import cn from 'classnames'
|
||||
import useBoolean from 'ahooks/lib/useBoolean'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -7,8 +7,10 @@ import ConfigPrompt from '../../config-prompt'
|
||||
import { languageMap } from '../../../../workflow/nodes/_base/components/editor/code-editor/index'
|
||||
import { generateRuleCode } from '@/service/debug'
|
||||
import type { CodeGenRes } from '@/service/debug'
|
||||
import { type AppType, type Model, ModelModeType } from '@/types/app'
|
||||
import type { ModelModeType } from '@/types/app'
|
||||
import type { AppType, CompletionParams, Model } from '@/types/app'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import Button from '@/app/components/base/button'
|
||||
import { Generator } from '@/app/components/base/icons/src/vender/other'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
@ -17,8 +19,9 @@ import Confirm from '@/app/components/base/confirm'
|
||||
import type { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import ModelIcon from '@/app/components/header/account-setting/model-provider-page/model-icon'
|
||||
import ModelName from '@/app/components/header/account-setting/model-provider-page/model-name'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
|
||||
export type IGetCodeGeneratorResProps = {
|
||||
mode: AppType
|
||||
isShow: boolean
|
||||
@ -36,11 +39,28 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
onFinished,
|
||||
},
|
||||
) => {
|
||||
const {
|
||||
currentProvider,
|
||||
currentModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const { t } = useTranslation()
|
||||
const defaultCompletionParams = {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
}
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') as string) as Model
|
||||
: null
|
||||
const [model, setModel] = React.useState<Model>(localModel || {
|
||||
name: '',
|
||||
provider: '',
|
||||
mode: mode as unknown as ModelModeType.chat,
|
||||
completion_params: defaultCompletionParams,
|
||||
})
|
||||
const {
|
||||
defaultModel,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.textGeneration)
|
||||
const [instruction, setInstruction] = React.useState<string>('')
|
||||
const [isLoading, { setTrue: setLoadingTrue, setFalse: setLoadingFalse }] = useBoolean(false)
|
||||
const [res, setRes] = React.useState<CodeGenRes | null>(null)
|
||||
@ -56,21 +76,27 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
}
|
||||
return true
|
||||
}
|
||||
const model: Model = {
|
||||
provider: currentProvider?.provider || '',
|
||||
name: currentModel?.model || '',
|
||||
mode: ModelModeType.chat,
|
||||
// This is a fixed parameter
|
||||
completion_params: {
|
||||
temperature: 0.7,
|
||||
max_tokens: 0,
|
||||
top_p: 0,
|
||||
echo: false,
|
||||
stop: [],
|
||||
presence_penalty: 0,
|
||||
frequency_penalty: 0,
|
||||
},
|
||||
}
|
||||
|
||||
const handleModelChange = useCallback((newValue: { modelId: string; provider: string; mode?: string; features?: string[] }) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
provider: newValue.provider,
|
||||
name: newValue.modelId,
|
||||
mode: newValue.mode as ModelModeType,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const handleCompletionParamsChange = useCallback((newParams: FormValue) => {
|
||||
const newModel = {
|
||||
...model,
|
||||
completion_params: newParams as CompletionParams,
|
||||
}
|
||||
setModel(newModel)
|
||||
localStorage.setItem('auto-gen-model', JSON.stringify(newModel))
|
||||
}, [model, setModel])
|
||||
|
||||
const isInLLMNode = true
|
||||
const onGenerate = async () => {
|
||||
if (!isValid())
|
||||
@ -99,16 +125,40 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
}
|
||||
const [showConfirmOverwrite, setShowConfirmOverwrite] = React.useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultModel) {
|
||||
const localModel = localStorage.getItem('auto-gen-model')
|
||||
? JSON.parse(localStorage.getItem('auto-gen-model') || '')
|
||||
: null
|
||||
if (localModel) {
|
||||
setModel({
|
||||
...localModel,
|
||||
completion_params: {
|
||||
...defaultCompletionParams,
|
||||
...localModel.completion_params,
|
||||
},
|
||||
})
|
||||
}
|
||||
else {
|
||||
setModel(prev => ({
|
||||
...prev,
|
||||
name: defaultModel.model,
|
||||
provider: defaultModel.provider.provider,
|
||||
}))
|
||||
}
|
||||
}
|
||||
}, [defaultModel])
|
||||
|
||||
const renderLoading = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3'>
|
||||
<Loading />
|
||||
<div className='text-[13px] text-gray-400'>{t('appDebug.codegen.loading')}</div>
|
||||
<div className='text-[13px] text-text-tertiary'>{t('appDebug.codegen.loading')}</div>
|
||||
</div>
|
||||
)
|
||||
const renderNoData = (
|
||||
<div className='flex h-full w-0 grow flex-col items-center justify-center space-y-3 px-8'>
|
||||
<Generator className='h-14 w-14 text-gray-300' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-gray-400'>
|
||||
<Generator className='h-14 w-14 text-text-tertiary' />
|
||||
<div className='text-center text-[13px] font-normal leading-5 text-text-tertiary'>
|
||||
<div>{t('appDebug.codegen.noDataLine1')}</div>
|
||||
<div>{t('appDebug.codegen.noDataLine2')}</div>
|
||||
</div>
|
||||
@ -123,29 +173,30 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
closable
|
||||
>
|
||||
<div className='relative flex h-[680px] flex-wrap'>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-gray-100 p-8'>
|
||||
<div className='h-full w-[570px] shrink-0 overflow-y-auto border-r border-divider-regular p-8'>
|
||||
<div className='mb-8'>
|
||||
<div className={'text-lg font-bold leading-[28px]'}>{t('appDebug.codegen.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-gray-500'>{t('appDebug.codegen.description')}</div>
|
||||
<div className={'text-lg font-bold leading-[28px] text-text-primary'}>{t('appDebug.codegen.title')}</div>
|
||||
<div className='mt-1 text-[13px] font-normal text-text-tertiary'>{t('appDebug.codegen.description')}</div>
|
||||
</div>
|
||||
<div className='flex items-center'>
|
||||
<ModelIcon
|
||||
className='mr-1.5 shrink-0'
|
||||
provider={currentProvider}
|
||||
modelName={currentModel?.model}
|
||||
/>
|
||||
<ModelName
|
||||
className='grow'
|
||||
modelItem={currentModel!}
|
||||
showMode
|
||||
showFeatures
|
||||
<div className='mb-8'>
|
||||
<ModelParameterModal
|
||||
popupClassName='!w-[520px]'
|
||||
portalToFollowElemContentClassName='z-[1000]'
|
||||
isAdvancedMode={true}
|
||||
provider={model.provider}
|
||||
mode={model.mode}
|
||||
completionParams={model.completion_params}
|
||||
modelId={model.name}
|
||||
setModel={handleModelChange}
|
||||
onCompletionParamsChange={handleCompletionParamsChange}
|
||||
hideDebugWithMultipleModel
|
||||
/>
|
||||
</div>
|
||||
<div className='mt-6'>
|
||||
<div>
|
||||
<div className='text-[0px]'>
|
||||
<div className='mb-2 text-sm font-medium leading-5 text-gray-900'>{t('appDebug.codegen.instruction')}</div>
|
||||
<textarea
|
||||
className="h-[200px] w-full overflow-y-auto rounded-lg bg-gray-50 px-3 py-2 text-sm"
|
||||
<div className='mb-2 text-sm font-medium leading-5 text-text-primary'>{t('appDebug.codegen.instruction')}</div>
|
||||
<Textarea
|
||||
className="h-[200px] resize-none"
|
||||
placeholder={t('appDebug.codegen.instructionPlaceholder') || ''}
|
||||
value={instruction}
|
||||
onChange={e => setInstruction(e.target.value)}
|
||||
@ -169,7 +220,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
{!isLoading && !res && renderNoData}
|
||||
{(!isLoading && res) && (
|
||||
<div className='h-full w-0 grow p-6 pb-0'>
|
||||
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-gray-800'>{t('appDebug.codegen.resTitle')}</div>
|
||||
<div className='mb-3 shrink-0 text-base font-semibold leading-[160%] text-text-secondary'>{t('appDebug.codegen.resTitle')}</div>
|
||||
<div className={cn('max-h-[555px] overflow-y-auto', !isInLLMNode && 'pb-2')}>
|
||||
<ConfigPrompt
|
||||
mode={mode}
|
||||
@ -185,7 +236,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
<>
|
||||
{res?.code && (
|
||||
<div className='mt-4'>
|
||||
<h3 className='mb-2 text-sm font-medium text-gray-900'>{t('appDebug.codegen.generatedCode')}</h3>
|
||||
<h3 className='mb-2 text-sm font-medium text-text-primary'>{t('appDebug.codegen.generatedCode')}</h3>
|
||||
<pre className='overflow-x-auto rounded-lg bg-gray-50 p-4'>
|
||||
<code className={`language-${res.language}`}>
|
||||
{res.code}
|
||||
@ -202,7 +253,7 @@ export const GetCodeGeneratorResModal: FC<IGetCodeGeneratorResProps> = (
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className='flex justify-end bg-white py-4'>
|
||||
<div className='flex justify-end bg-background-default py-4'>
|
||||
<Button onClick={onClose}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' className='ml-2' onClick={() => {
|
||||
setShowConfirmOverwrite(true)
|
||||
|
||||
@ -12,6 +12,7 @@ import { DataSourceType } from '@/models/datasets'
|
||||
import FileIcon from '@/app/components/base/file-icon'
|
||||
import { Folder } from '@/app/components/base/icons/src/vender/solid/files'
|
||||
import { Globe06 } from '@/app/components/base/icons/src/vender/solid/mapsAndTravel'
|
||||
import ActionButton, { ActionButtonState } from '@/app/components/base/action-button'
|
||||
import Drawer from '@/app/components/base/drawer'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
@ -47,56 +48,66 @@ const Item: FC<ItemProps> = ({
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
|
||||
return (
|
||||
<div className={cn('group relative mb-1 flex w-full items-center rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg py-2 pl-2.5 pr-3 shadow-xs last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover hover:shadow-sm', isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover')}>
|
||||
{
|
||||
config.data_source_type === DataSourceType.FILE && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF]'>
|
||||
<Folder className='h-4 w-4 text-[#444CE7]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
config.data_source_type === DataSourceType.NOTION && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5]'>
|
||||
<FileIcon type='notion' className='h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
config.data_source_type === DataSourceType.WEB && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-blue-100 bg-[#F5FAFF]'>
|
||||
<Globe06 className='h-4 w-4 text-blue-600' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='grow'>
|
||||
<div className='flex h-[18px] items-center'>
|
||||
<div className='grow truncate text-[13px] font-medium text-text-secondary' title={config.name}>{config.name}</div>
|
||||
{config.provider === 'external'
|
||||
? <Badge text={t('dataset.externalTag') as string} />
|
||||
: <Badge
|
||||
text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)}
|
||||
/>}
|
||||
</div>
|
||||
</div >
|
||||
<div className='absolute bottom-0 right-0 top-0 hidden w-[124px] items-center justify-end rounded-lg bg-gradient-to-r from-white/50 to-white to-50% pr-2 group-hover:flex'>
|
||||
<div className={cn(
|
||||
'group relative mb-1 flex h-10 w-full cursor-pointer items-center justify-between rounded-lg border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg px-2 last-of-type:mb-0 hover:bg-components-panel-on-panel-item-bg-hover',
|
||||
isDeleting && 'border-state-destructive-border hover:bg-state-destructive-hover',
|
||||
)}>
|
||||
<div className='flex w-0 grow items-center space-x-1.5'>
|
||||
{
|
||||
editable && <div
|
||||
className='mr-1 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md hover:bg-black/5'
|
||||
onClick={() => setShowSettingsModal(true)}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
config.data_source_type === DataSourceType.FILE && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF]'>
|
||||
<Folder className='h-4 w-4 text-[#444CE7]' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div
|
||||
className='flex h-6 w-6 cursor-pointer items-center justify-center text-text-tertiary hover:text-text-destructive'
|
||||
{
|
||||
config.data_source_type === DataSourceType.NOTION && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#EAECF5]'>
|
||||
<FileIcon type='notion' className='h-4 w-4' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
{
|
||||
config.data_source_type === DataSourceType.WEB && (
|
||||
<div className='mr-2 flex h-6 w-6 shrink-0 items-center justify-center rounded-md border-[0.5px] border-blue-100 bg-[#F5FAFF]'>
|
||||
<Globe06 className='h-4 w-4 text-blue-600' />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
<div className='system-sm-medium w-0 grow truncate text-text-secondary' title={config.name}>{config.name}</div>
|
||||
</div>
|
||||
<div className='ml-2 hidden shrink-0 items-center space-x-1 group-hover:flex'>
|
||||
{
|
||||
editable && <ActionButton
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowSettingsModal(true)
|
||||
}}
|
||||
>
|
||||
<RiEditLine className='h-4 w-4 shrink-0 text-text-tertiary' />
|
||||
</ActionButton>
|
||||
}
|
||||
<ActionButton
|
||||
onClick={() => onRemove(config.id)}
|
||||
onMouseOver={() => setIsDeleting(true)}
|
||||
state={isDeleting ? ActionButtonState.Destructive : ActionButtonState.Default}
|
||||
onMouseEnter={() => setIsDeleting(true)}
|
||||
onMouseLeave={() => setIsDeleting(false)}
|
||||
>
|
||||
<RiDeleteBinLine className='h-4 w-4' />
|
||||
</div>
|
||||
<RiDeleteBinLine className={cn('h-4 w-4 shrink-0 text-text-tertiary', isDeleting && 'text-text-destructive')} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
{
|
||||
config.indexing_technique && <Badge
|
||||
className='shrink-0 group-hover:hidden'
|
||||
text={formatIndexingTechniqueAndMethod(config.indexing_technique, config.retrieval_model_dict?.search_method)}
|
||||
/>
|
||||
}
|
||||
{
|
||||
config.provider === 'external' && <Badge
|
||||
className='shrink-0 group-hover:hidden'
|
||||
text={t('dataset.externalTag') as string}
|
||||
/>
|
||||
}
|
||||
<Drawer isOpen={showSettingsModal} onClose={() => setShowSettingsModal(false)} footer={null} mask={isMobile} panelClassName='mt-16 mx-2 sm:mr-2 mb-3 !p-0 !max-w-[640px] rounded-xl'>
|
||||
<SettingsModal
|
||||
currentDataset={config}
|
||||
|
||||
@ -31,6 +31,7 @@ import {
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { fetchMembers } from '@/service/common'
|
||||
import type { Member } from '@/models/common'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type SettingsModalProps = {
|
||||
currentDataset: DataSet
|
||||
@ -58,6 +59,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
currentModel: isRerankDefaultModelValid,
|
||||
} = useModelListAndDefaultModelAndCurrentProviderAndModel(ModelTypeEnum.rerank)
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { notify } = useToastContext()
|
||||
const ref = useRef(null)
|
||||
const isExternal = currentDataset.provider === 'external'
|
||||
@ -328,7 +330,7 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
<div>
|
||||
<div className='system-sm-semibold text-text-secondary'>{t('datasetSettings.form.retrievalSetting.title')}</div>
|
||||
<div className='text-xs font-normal leading-[18px] text-text-tertiary'>
|
||||
<a target='_blank' rel='noopener noreferrer' href='https://docs.dify.ai/guides/knowledge-base/create-knowledge-and-upload-documents#id-4-retrieval-settings' className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
|
||||
<a target='_blank' rel='noopener noreferrer' href={docLink('/guides/knowledge-base/create-knowledge-and-upload-documents/setting-indexing-methods#setting-the-retrieval-setting')} className='text-text-accent'>{t('datasetSettings.form.retrievalSetting.learnMore')}</a>
|
||||
{t('datasetSettings.form.retrievalSetting.description')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -99,7 +99,15 @@ const DebugWithMultipleModel = () => {
|
||||
}, [twoLine, threeLine, fourLine])
|
||||
|
||||
const setShowAppConfigureFeaturesModal = useAppStore(s => s.setShowAppConfigureFeaturesModal)
|
||||
const inputsForm = modelConfig.configs.prompt_variables.filter(item => item.type !== 'api').map(item => ({ ...item, label: item.name, variable: item.key })) as InputForm[]
|
||||
const inputsForm = modelConfig.configs.prompt_variables
|
||||
.filter(item => item.type !== 'api')
|
||||
.map(item => ({
|
||||
...item,
|
||||
label: item.name,
|
||||
variable: item.key,
|
||||
hide: item.hide ?? false,
|
||||
required: item.required ?? false,
|
||||
})) as InputForm[]
|
||||
|
||||
return (
|
||||
<div className='flex h-full flex-col'>
|
||||
@ -133,6 +141,7 @@ const DebugWithMultipleModel = () => {
|
||||
{isChatMode && (
|
||||
<div className='shrink-0 px-6 pb-0'>
|
||||
<ChatInputArea
|
||||
botName='Bot'
|
||||
showFeatureBar
|
||||
showFileUpload={false}
|
||||
onFeatureBarClick={setShowAppConfigureFeaturesModal}
|
||||
|
||||
@ -156,12 +156,11 @@ const Debug: FC<IDebug> = ({
|
||||
}
|
||||
let hasEmptyInput = ''
|
||||
const requiredVars = modelConfig.configs.prompt_variables.filter(({ key, name, required, type }) => {
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select')
|
||||
if (type !== 'string' && type !== 'paragraph' && type !== 'select' && type !== 'number')
|
||||
return false
|
||||
const res = (!key || !key.trim()) || (!name || !name.trim()) || (required || required === undefined || required === null)
|
||||
return res
|
||||
}) // compatible with old version
|
||||
// debugger
|
||||
requiredVars.forEach(({ key, name }) => {
|
||||
if (hasEmptyInput)
|
||||
return
|
||||
|
||||
@ -79,6 +79,7 @@ import {
|
||||
} from '@/utils'
|
||||
import PluginDependency from '@/app/components/workflow/plugin-dependency'
|
||||
import { supportFunctionCall } from '@/utils/tool-call'
|
||||
import { MittProvider } from '@/context/mitt-context'
|
||||
|
||||
type PublishConfig = {
|
||||
modelConfig: ModelConfig
|
||||
@ -908,7 +909,7 @@ const Configuration: FC = () => {
|
||||
}}
|
||||
>
|
||||
<FeaturesProvider features={featuresData}>
|
||||
<>
|
||||
<MittProvider>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className='relative flex h-[200px] grow pt-14'>
|
||||
{/* Header */}
|
||||
@ -1060,7 +1061,7 @@ const Configuration: FC = () => {
|
||||
/>
|
||||
)}
|
||||
<PluginDependency />
|
||||
</>
|
||||
</MittProvider>
|
||||
</FeaturesProvider>
|
||||
</ConfigContext.Provider>
|
||||
)
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n/language'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
type Props = {
|
||||
onReturnToSimpleMode: () => void
|
||||
}
|
||||
@ -13,7 +11,7 @@ const AdvancedModeWarning: FC<Props> = ({
|
||||
onReturnToSimpleMode,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const docLink = useDocLink()
|
||||
const [show, setShow] = React.useState(true)
|
||||
if (!show)
|
||||
return null
|
||||
@ -25,7 +23,7 @@ const AdvancedModeWarning: FC<Props> = ({
|
||||
<span className='text-gray-700'>{t('appDebug.promptMode.advancedWarning.description')}</span>
|
||||
<a
|
||||
className='font-medium text-[#155EEF]'
|
||||
href={`https://docs.dify.ai/${locale === LanguagesSupported[1] ? '/guides/features/prompt-engineering' : 'features/prompt-engineering'}`}
|
||||
href={docLink('/guides/features/prompt-engineering')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>
|
||||
{t('appDebug.promptMode.advancedWarning.learnMore')}
|
||||
|
||||
@ -20,6 +20,7 @@ import type {
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
const systemTypes = ['api']
|
||||
type ExternalDataToolModalProps = {
|
||||
@ -40,6 +41,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
onValidateBeforeSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { notify } = useToastContext()
|
||||
const { locale } = useContext(I18n)
|
||||
const [localeData, setLocaleData] = useState(data.type ? data : { ...data, type: 'api' })
|
||||
@ -243,7 +245,7 @@ const ExternalDataToolModal: FC<ExternalDataToolModalProps> = ({
|
||||
<div className='flex h-9 items-center justify-between text-sm font-medium text-gray-900'>
|
||||
{t('common.apiBasedExtension.selector.title')}
|
||||
<a
|
||||
href={t('common.apiBasedExtension.linkUrl') || '/'}
|
||||
href={docLink('/guides/extension/api-based-extension/README')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='group flex items-center text-xs font-normal text-gray-500 hover:text-primary-600'
|
||||
>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext, useContextSelector } from 'use-context-selector'
|
||||
import { RiArrowRightLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiCommandLine, RiCornerDownLeftLine, RiExchange2Fill } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import Image from 'next/image'
|
||||
@ -19,7 +19,6 @@ import AppsContext, { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import type { AppMode } from '@/types/app'
|
||||
import { AppModes } from '@/types/app'
|
||||
import { createApp } from '@/service/apps'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
@ -30,6 +29,7 @@ import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { getRedirection } from '@/utils/app-redirection'
|
||||
import FullScreenModal from '@/app/components/base/fullscreen-modal'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type CreateAppProps = {
|
||||
onSuccess: () => void
|
||||
@ -43,11 +43,12 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
const { notify } = useContext(ToastContext)
|
||||
const mutateApps = useContextSelector(AppsContext, state => state.mutateApps)
|
||||
|
||||
const [appMode, setAppMode] = useState<AppMode>('chat')
|
||||
const [appMode, setAppMode] = useState<AppMode>('advanced-chat')
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>({ type: 'emoji', icon: '🤖', background: '#FFEAD5' })
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [name, setName] = useState('')
|
||||
const [description, setDescription] = useState('')
|
||||
const [isAppTypeExpanded, setIsAppTypeExpanded] = useState(false)
|
||||
|
||||
const { plan, enableBilling } = useProviderContext()
|
||||
const isAppsFull = (enableBilling && plan.usage.buildApps >= plan.total.buildApps)
|
||||
@ -55,14 +56,6 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const category = searchParams.get('category')
|
||||
if (category && AppModes.includes(category as AppMode))
|
||||
setAppMode(category as AppMode)
|
||||
}, [searchParams])
|
||||
|
||||
const onCreate = useCallback(async () => {
|
||||
if (!appMode) {
|
||||
notify({ type: 'error', message: t('app.newApp.appTypeRequired') })
|
||||
@ -116,57 +109,7 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
</div>
|
||||
<div className='flex w-[660px] flex-col gap-4'>
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>{t('app.newApp.forBeginners')}</span>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2'>
|
||||
<AppTypeCard
|
||||
active={appMode === 'chat'}
|
||||
title={t('app.types.chatbot')}
|
||||
description={t('app.newApp.chatbotShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid'>
|
||||
<ChatBot className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('chat')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'agent-chat'}
|
||||
title={t('app.types.agent')}
|
||||
description={t('app.newApp.agentShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-violet-solid'>
|
||||
<Logic className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('agent-chat')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'completion'}
|
||||
title={t('app.newApp.completeApp')}
|
||||
description={t('app.newApp.completionShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-teal-solid'>
|
||||
<ListSparkle className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('completion')
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-2'>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>{t('app.newApp.forAdvanced')}</span>
|
||||
</div>
|
||||
<div className='flex flex-row gap-2'>
|
||||
<AppTypeCard
|
||||
active={appMode === 'advanced-chat'}
|
||||
title={t('app.types.advanced')}
|
||||
description={t('app.newApp.advancedShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-light-solid'>
|
||||
<BubbleTextMod className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('advanced-chat')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'workflow'}
|
||||
title={t('app.types.workflow')}
|
||||
@ -177,8 +120,63 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
onClick={() => {
|
||||
setAppMode('workflow')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'advanced-chat'}
|
||||
title={t('app.types.advanced')}
|
||||
description={t('app.newApp.advancedShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-light-solid'>
|
||||
<BubbleTextMod className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('advanced-chat')
|
||||
}} />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className='mb-2 flex items-center'>
|
||||
<button
|
||||
className='flex cursor-pointer items-center border-0 bg-transparent p-0'
|
||||
onClick={() => setIsAppTypeExpanded(!isAppTypeExpanded)}
|
||||
>
|
||||
<span className='system-2xs-medium-uppercase text-text-tertiary'>{t('app.newApp.forBeginners')}</span>
|
||||
<RiArrowRightSLine className={`ml-1 h-4 w-4 text-text-tertiary transition-transform ${isAppTypeExpanded ? 'rotate-90' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
{isAppTypeExpanded && (
|
||||
<div className='flex flex-row gap-2'>
|
||||
<AppTypeCard
|
||||
active={appMode === 'chat'}
|
||||
title={t('app.types.chatbot')}
|
||||
description={t('app.newApp.chatbotShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-blue-solid'>
|
||||
<ChatBot className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('chat')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'agent-chat'}
|
||||
title={t('app.types.agent')}
|
||||
description={t('app.newApp.agentShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-violet-solid'>
|
||||
<Logic className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('agent-chat')
|
||||
}} />
|
||||
<AppTypeCard
|
||||
active={appMode === 'completion'}
|
||||
title={t('app.newApp.completeApp')}
|
||||
description={t('app.newApp.completionShortDescription')}
|
||||
icon={<div className='flex h-6 w-6 items-center justify-center rounded-md bg-components-icon-bg-teal-solid'>
|
||||
<ListSparkle className='h-4 w-4 text-components-avatar-shape-fill-stop-100' />
|
||||
</div>}
|
||||
onClick={() => {
|
||||
setAppMode('completion')
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: 0 }} />
|
||||
<div className='flex items-center space-x-3'>
|
||||
<div className='flex-1'>
|
||||
@ -306,31 +304,41 @@ function AppTypeCard({ icon, title, description, active, onClick }: AppTypeCardP
|
||||
|
||||
function AppPreview({ mode }: { mode: AppMode }) {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const modeToPreviewInfoMap = {
|
||||
'chat': {
|
||||
title: t('app.types.chatbot'),
|
||||
description: t('app.newApp.chatbotUserDescription'),
|
||||
link: 'https://docs.dify.ai/guides/application-orchestrate/readme',
|
||||
link: docLink('/guides/application-orchestrate/chatbot-application'),
|
||||
},
|
||||
'advanced-chat': {
|
||||
title: t('app.types.advanced'),
|
||||
description: t('app.newApp.advancedUserDescription'),
|
||||
link: 'https://docs.dify.ai/en/guides/workflow/README',
|
||||
link: docLink('/guides/workflow/README', {
|
||||
'zh-Hans': '/guides/workflow/readme',
|
||||
'ja-JP': '/guides/workflow/concepts',
|
||||
}),
|
||||
},
|
||||
'agent-chat': {
|
||||
title: t('app.types.agent'),
|
||||
description: t('app.newApp.agentUserDescription'),
|
||||
link: 'https://docs.dify.ai/en/guides/application-orchestrate/agent',
|
||||
link: docLink('/guides/application-orchestrate/agent'),
|
||||
},
|
||||
'completion': {
|
||||
title: t('app.newApp.completeApp'),
|
||||
description: t('app.newApp.completionUserDescription'),
|
||||
link: null,
|
||||
link: docLink('/guides/application-orchestrate/text-generator', {
|
||||
'zh-Hans': '/guides/application-orchestrate/readme',
|
||||
'ja-JP': '/guides/application-orchestrate/README',
|
||||
}),
|
||||
},
|
||||
'workflow': {
|
||||
title: t('app.types.workflow'),
|
||||
description: t('app.newApp.workflowUserDescription'),
|
||||
link: 'https://docs.dify.ai/en/guides/workflow/README',
|
||||
link: docLink('/guides/workflow/README', {
|
||||
'zh-Hans': '/guides/workflow/readme',
|
||||
'ja-JP': '/guides/workflow/concepts',
|
||||
}),
|
||||
},
|
||||
}
|
||||
const previewInfo = modeToPreviewInfoMap[mode]
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { MouseEventHandler } from 'react'
|
||||
import { useMemo, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -35,6 +35,7 @@ type CreateFromDSLModalProps = {
|
||||
onClose: () => void
|
||||
activeTab?: string
|
||||
dslUrl?: string
|
||||
droppedFile?: File
|
||||
}
|
||||
|
||||
export enum CreateFromDSLModalTab {
|
||||
@ -42,11 +43,11 @@ export enum CreateFromDSLModalTab {
|
||||
FROM_URL = 'from-url',
|
||||
}
|
||||
|
||||
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '' }: CreateFromDSLModalProps) => {
|
||||
const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDSLModalTab.FROM_FILE, dslUrl = '', droppedFile }: CreateFromDSLModalProps) => {
|
||||
const { push } = useRouter()
|
||||
const { t } = useTranslation()
|
||||
const { notify } = useContext(ToastContext)
|
||||
const [currentFile, setDSLFile] = useState<File>()
|
||||
const [currentFile, setDSLFile] = useState<File | undefined>(droppedFile)
|
||||
const [fileContent, setFileContent] = useState<string>()
|
||||
const [currentTab, setCurrentTab] = useState(activeTab)
|
||||
const [dslUrlValue, setDslUrlValue] = useState(dslUrl)
|
||||
@ -78,6 +79,11 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
|
||||
const isCreatingRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedFile)
|
||||
handleFile(droppedFile)
|
||||
}, [droppedFile])
|
||||
|
||||
const onCreate: MouseEventHandler = async () => {
|
||||
if (currentTab === CreateFromDSLModalTab.FROM_FILE && !currentFile)
|
||||
return
|
||||
@ -262,7 +268,7 @@ const CreateFromDSLModal = ({ show, onSuccess, onClose, activeTab = CreateFromDS
|
||||
{
|
||||
currentTab === CreateFromDSLModalTab.FROM_URL && (
|
||||
<div>
|
||||
<div className='system-md-semibold leading6 mb-1'>DSL URL</div>
|
||||
<div className='system-md-semibold mb-1 text-text-secondary'>DSL URL</div>
|
||||
<Input
|
||||
placeholder={t('app.importFromDSLUrlPlaceholder') || ''}
|
||||
value={dslUrlValue}
|
||||
|
||||
@ -3,6 +3,7 @@ import type { FC } from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
RiUploadCloud2Line,
|
||||
} from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
@ -10,8 +11,7 @@ import { formatFileSize } from '@/utils/format'
|
||||
import cn from '@/utils/classnames'
|
||||
import { Yaml as YamlIcon } from '@/app/components/base/icons/src/public/files'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { UploadCloud01 } from '@/app/components/base/icons/src/vender/line/general'
|
||||
import Button from '@/app/components/base/button'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
|
||||
export type Props = {
|
||||
file: File | undefined
|
||||
@ -102,19 +102,19 @@ const Uploader: FC<Props> = ({
|
||||
/>
|
||||
<div ref={dropRef}>
|
||||
{!file && (
|
||||
<div className={cn('flex h-12 items-center rounded-xl border border-dashed border-gray-200 bg-gray-50 text-sm font-normal', dragging && 'border border-[#B2CCFF] bg-[#F5F8FF]')}>
|
||||
<div className={cn('flex h-12 items-center rounded-[10px] border border-dashed border-components-dropzone-border bg-components-dropzone-bg text-sm font-normal', dragging && 'border-components-dropzone-border-accent bg-components-dropzone-bg-accent')}>
|
||||
<div className='flex w-full items-center justify-center space-x-2'>
|
||||
<UploadCloud01 className='mr-2 h-6 w-6' />
|
||||
<div className='text-gray-500'>
|
||||
<RiUploadCloud2Line className='h-6 w-6 text-text-tertiary' />
|
||||
<div className='text-text-tertiary'>
|
||||
{t('datasetCreation.stepOne.uploader.button')}
|
||||
<span className='cursor-pointer pl-1 text-[#155eef]' onClick={selectHandle}>{t('datasetDocuments.list.batchModal.browse')}</span>
|
||||
<span className='cursor-pointer pl-1 text-text-accent' onClick={selectHandle}>{t('datasetDocuments.list.batchModal.browse')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{dragging && <div ref={dragRef} className='absolute left-0 top-0 h-full w-full' />}
|
||||
</div>
|
||||
)}
|
||||
{file && (
|
||||
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', 'hover:border-[#B2CCFF] hover:bg-[#F5F8FF]')}>
|
||||
<div className={cn('group flex items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs', ' hover:bg-components-panel-on-panel-item-bg-hover')}>
|
||||
<div className='flex items-center justify-center p-3'>
|
||||
<YamlIcon className="h-6 w-6 shrink-0" />
|
||||
</div>
|
||||
@ -126,12 +126,10 @@ const Uploader: FC<Props> = ({
|
||||
<span>{formatFileSize(file.size)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className='hidden items-center group-hover:flex'>
|
||||
<Button onClick={selectHandle}>{t('datasetCreation.stepOne.uploader.change')}</Button>
|
||||
<div className='mx-2 h-4 w-px bg-gray-200' />
|
||||
<div className='cursor-pointer p-2' onClick={removeFile}>
|
||||
<div className='hidden items-center pr-3 group-hover:flex'>
|
||||
<ActionButton onClick={removeFile}>
|
||||
<RiDeleteBinLine className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -32,7 +32,6 @@ import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import TextGeneration from '@/app/components/app/text-generate/item'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import MessageLogModal from '@/app/components/base/message-log-modal'
|
||||
import PromptLogModal from '@/app/components/base/prompt-log-modal'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
@ -191,13 +190,11 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
const { userProfile: { timezone } } = useAppContext()
|
||||
const { formatTime } = useTimestamp()
|
||||
const { onClose, appDetail } = useContext(DrawerContext)
|
||||
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, showPromptLogModal, setShowPromptLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
|
||||
const { currentLogItem, setCurrentLogItem, showMessageLogModal, setShowMessageLogModal, currentLogModalActiveTab } = useAppStore(useShallow(state => ({
|
||||
currentLogItem: state.currentLogItem,
|
||||
setCurrentLogItem: state.setCurrentLogItem,
|
||||
showMessageLogModal: state.showMessageLogModal,
|
||||
setShowMessageLogModal: state.setShowMessageLogModal,
|
||||
showPromptLogModal: state.showPromptLogModal,
|
||||
setShowPromptLogModal: state.setShowPromptLogModal,
|
||||
currentLogModalActiveTab: state.currentLogModalActiveTab,
|
||||
})))
|
||||
const { t } = useTranslation()
|
||||
@ -357,7 +354,8 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
adjustModalWidth()
|
||||
const raf = requestAnimationFrame(adjustModalWidth)
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@ -518,16 +516,6 @@ function DetailPanel({ detail, onFeedback }: IDetailPanel) {
|
||||
defaultTab={currentLogModalActiveTab}
|
||||
/>
|
||||
)}
|
||||
{showPromptLogModal && (
|
||||
<PromptLogModal
|
||||
width={width}
|
||||
currentLogItem={currentLogItem}
|
||||
onCancel={() => {
|
||||
setCurrentLogItem()
|
||||
setShowPromptLogModal(false)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
'use client'
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import {
|
||||
RiArrowRightSLine,
|
||||
RiBookOpenLine,
|
||||
RiBuildingLine,
|
||||
RiEqualizer2Line,
|
||||
RiExternalLinkLine,
|
||||
RiGlobalLine,
|
||||
RiLockLine,
|
||||
RiPaintBrushLine,
|
||||
RiVerifiedBadgeLine,
|
||||
RiWindowLine,
|
||||
} from '@remixicon/react'
|
||||
import SettingsModal from './settings'
|
||||
@ -18,6 +23,7 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import AppBasic from '@/app/components/app-sidebar/basic'
|
||||
import { asyncRunSafe } from '@/utils'
|
||||
import { basePath } from '@/utils/var'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
@ -29,6 +35,11 @@ import type { AppDetailResponse } from '@/models/app'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import type { AppSSO } from '@/types/app'
|
||||
import Indicator from '@/app/components/header/indicator'
|
||||
import { fetchAppDetail } from '@/service/apps'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import AccessControl from '../app-access-control'
|
||||
import { useAppWhiteListSubjects } from '@/service/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export type IAppCardProps = {
|
||||
className?: string
|
||||
@ -54,13 +65,17 @@ function AppCard({
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { isCurrentWorkspaceManager, isCurrentWorkspaceEditor } = useAppContext()
|
||||
const appDetail = useAppStore(state => state.appDetail)
|
||||
const setAppDetail = useAppStore(state => state.setAppDetail)
|
||||
const [showSettingsModal, setShowSettingsModal] = useState(false)
|
||||
const [showEmbedded, setShowEmbedded] = useState(false)
|
||||
const [showCustomizeModal, setShowCustomizeModal] = useState(false)
|
||||
const [genLoading, setGenLoading] = useState(false)
|
||||
const [showConfirmDelete, setShowConfirmDelete] = useState(false)
|
||||
|
||||
const [showAccessControl, setShowAccessControl] = useState<boolean>(false)
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: appAccessSubjects } = useAppWhiteListSubjects(appDetail?.id, systemFeatures.webapp_auth.enabled && appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS)
|
||||
|
||||
const OPERATIONS_MAP = useMemo(() => {
|
||||
const operationsMap = {
|
||||
@ -128,6 +143,31 @@ function AppCard({
|
||||
}
|
||||
}
|
||||
|
||||
const [isAppAccessSet, setIsAppAccessSet] = useState(true)
|
||||
useEffect(() => {
|
||||
if (appDetail && appAccessSubjects) {
|
||||
if (appDetail.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS && appAccessSubjects.groups?.length === 0 && appAccessSubjects.members?.length === 0)
|
||||
setIsAppAccessSet(false)
|
||||
else
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
else {
|
||||
setIsAppAccessSet(true)
|
||||
}
|
||||
}, [appAccessSubjects, appDetail])
|
||||
|
||||
const handleClickAccessControl = useCallback(() => {
|
||||
if (!appDetail)
|
||||
return
|
||||
setShowAccessControl(true)
|
||||
}, [appDetail])
|
||||
const handleAccessControlUpdate = useCallback(() => {
|
||||
fetchAppDetail({ url: '/apps', id: appDetail!.id }).then((res) => {
|
||||
setAppDetail(res)
|
||||
setShowAccessControl(false)
|
||||
})
|
||||
}, [appDetail, setAppDetail])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
@ -207,6 +247,41 @@ function AppCard({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isApp && systemFeatures.webapp_auth.enabled && appDetail && <div className='flex flex-col items-start justify-center self-stretch'>
|
||||
<div className="system-xs-medium pb-1 text-text-tertiary">{t('app.publishApp.title')}</div>
|
||||
<div className='flex h-9 w-full cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal py-1 pl-2.5 pr-2'
|
||||
onClick={handleClickAccessControl}>
|
||||
<div className='flex grow items-center gap-x-1.5 pr-1'>
|
||||
{appDetail?.access_mode === AccessMode.ORGANIZATION
|
||||
&& <>
|
||||
<RiBuildingLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.organization')}</p>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.SPECIFIC_GROUPS_MEMBERS
|
||||
&& <>
|
||||
<RiLockLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.specific')}</p>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.PUBLIC
|
||||
&& <>
|
||||
<RiGlobalLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.anyone')}</p>
|
||||
</>
|
||||
}
|
||||
{appDetail?.access_mode === AccessMode.EXTERNAL_MEMBERS
|
||||
&& <>
|
||||
<RiVerifiedBadgeLine className='h-4 w-4 shrink-0 text-text-secondary' />
|
||||
<p className='system-sm-medium text-text-secondary'>{t('app.accessControlDialog.accessItems.external')}</p>
|
||||
</>
|
||||
}</div>
|
||||
{!isAppAccessSet && <p className='system-xs-regular shrink-0 text-text-tertiary'>{t('app.publishApp.notSet')}</p>}
|
||||
<div className='flex h-4 w-4 shrink-0 items-center justify-center'>
|
||||
<RiArrowRightSLine className='h-4 w-4 text-text-quaternary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
<div className={'flex items-center gap-1 self-stretch p-3'}>
|
||||
{!isApp && <SecretKeyButton appId={appInfo.id} />}
|
||||
@ -265,6 +340,11 @@ function AppCard({
|
||||
api_base_url={appInfo.api_base_url}
|
||||
mode={appInfo.mode}
|
||||
/>
|
||||
{
|
||||
showAccessControl && <AccessControl app={appDetail!}
|
||||
onConfirm={handleAccessControlUpdate}
|
||||
onClose={() => { setShowAccessControl(false) }} />
|
||||
}
|
||||
</>
|
||||
)
|
||||
: null}
|
||||
|
||||
@ -3,13 +3,11 @@ import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/react/24/outline'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
import type { AppMode } from '@/types/app'
|
||||
import I18n from '@/context/i18n'
|
||||
import Button from '@/app/components/base/button'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Tag from '@/app/components/base/tag'
|
||||
import { LanguagesSupported } from '@/i18n/language'
|
||||
|
||||
type IShareLinkProps = {
|
||||
isShow: boolean
|
||||
@ -43,7 +41,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
mode,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const docLink = useDocLink()
|
||||
const isChatApp = mode === 'chat' || mode === 'advanced-chat'
|
||||
|
||||
return <Modal
|
||||
@ -101,10 +99,7 @@ const CustomizeModal: FC<IShareLinkProps> = ({
|
||||
className='mt-2'
|
||||
onClick={() =>
|
||||
window.open(
|
||||
`https://docs.dify.ai/${locale !== LanguagesSupported[1]
|
||||
? 'user-guide/launching-dify-apps/developing-with-apis'
|
||||
: `${locale.toLowerCase()}/guides/application-publishing/developing-with-apis`
|
||||
}`,
|
||||
docLink('/guides/application-publishing/developing-with-apis'),
|
||||
'_blank',
|
||||
)
|
||||
}
|
||||
|
||||
@ -50,6 +50,10 @@ const OPTION_MAP = {
|
||||
// user_id: 'YOU CAN DEFINE USER ID HERE',
|
||||
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
|
||||
},
|
||||
userVariables: {
|
||||
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<script
|
||||
|
||||
@ -4,7 +4,6 @@ import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { RiArrowRightSLine, RiCloseLine } from '@remixicon/react'
|
||||
import Link from 'next/link'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useContext, useContextSelector } from 'use-context-selector'
|
||||
import { SparklesSoft } from '@/app/components/base/icons/src/public/common'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
@ -19,15 +18,14 @@ import { SimpleSelect } from '@/app/components/base/select'
|
||||
import type { AppDetailResponse } from '@/models/app'
|
||||
import type { AppIconType, AppSSO, Language } from '@/types/app'
|
||||
import { useToastContext } from '@/app/components/base/toast'
|
||||
import { LanguagesSupported, languages } from '@/i18n/language'
|
||||
import { languages } from '@/i18n/language'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import AppContext, { useAppContext } from '@/context/app-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import I18n from '@/context/i18n'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
export type ISettingsModalProps = {
|
||||
isChat: boolean
|
||||
@ -65,8 +63,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
onClose,
|
||||
onSave,
|
||||
}) => {
|
||||
const systemFeatures = useContextSelector(AppContext, state => state.systemFeatures)
|
||||
const { isCurrentWorkspaceEditor } = useAppContext()
|
||||
const { notify } = useToastContext()
|
||||
const [isShowMore, setIsShowMore] = useState(false)
|
||||
const {
|
||||
@ -101,7 +97,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
const [language, setLanguage] = useState(default_language)
|
||||
const [saveLoading, setSaveLoading] = useState(false)
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const docLink = useDocLink()
|
||||
|
||||
const [showAppIconPicker, setShowAppIconPicker] = useState(false)
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(
|
||||
@ -110,7 +106,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
: { type: 'emoji', icon, background: icon_background! },
|
||||
)
|
||||
|
||||
const { enableBilling, plan } = useProviderContext()
|
||||
const { enableBilling, plan, webappCopyrightEnabled } = useProviderContext()
|
||||
const { setShowPricingModal, setShowAccountSettingModal } = useModalContext()
|
||||
const isFreePlan = plan.type === 'sandbox'
|
||||
const handlePlanClick = useCallback(() => {
|
||||
@ -138,7 +134,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
setAppIcon(icon_type === 'image'
|
||||
? { type: 'image', url: icon_url!, fileId: icon }
|
||||
: { type: 'emoji', icon, background: icon_background! })
|
||||
}, [appInfo])
|
||||
}, [appInfo, chat_color_theme, chat_color_theme_inverted, copyright, custom_disclaimer, default_language, description, icon, icon_background, icon_type, icon_url, privacy_policy, show_workflow_steps, title, use_icon_as_answer_icon])
|
||||
|
||||
const onHide = () => {
|
||||
onClose()
|
||||
@ -188,7 +184,7 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
chat_color_theme: inputInfo.chatColorTheme,
|
||||
chat_color_theme_inverted: inputInfo.chatColorThemeInverted,
|
||||
prompt_public: false,
|
||||
copyright: isFreePlan
|
||||
copyright: !webappCopyrightEnabled
|
||||
? ''
|
||||
: inputInfo.copyrightSwitchValue
|
||||
? inputInfo.copyright
|
||||
@ -241,7 +237,10 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
</div>
|
||||
<div className='system-xs-regular mt-0.5 text-text-tertiary'>
|
||||
<span>{t(`${prefixSettings}.modalTip`)}</span>
|
||||
<Link href={`${locale === LanguagesSupported[1] ? 'https://docs.dify.ai/zh-hans/guides/application-publishing/launch-your-webapp-quickly#she-zhi-ni-de-ai-zhan-dian' : 'https://docs.dify.ai/en/guides/application-publishing/launch-your-webapp-quickly/README'}`} target='_blank' rel='noopener noreferrer' className='text-text-accent'>{t('common.operation.learnMore')}</Link>
|
||||
<Link href={docLink('/guides/application-publishing/launch-your-webapp-quickly/README', {
|
||||
'zh-Hans': '/guides/application-publishing/launch-your-webapp-quickly/readme',
|
||||
})}
|
||||
target='_blank' rel='noopener noreferrer' className='text-text-accent'>{t('common.operation.learnMore')}</Link>
|
||||
</div>
|
||||
</div>
|
||||
{/* form body */}
|
||||
@ -336,28 +335,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
</div>
|
||||
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.workflow.showDesc`)}</p>
|
||||
</div>
|
||||
{/* SSO */}
|
||||
{systemFeatures.enable_web_sso_switch_component && (
|
||||
<>
|
||||
<Divider className="my-0 h-px" />
|
||||
<div className='w-full'>
|
||||
<p className='system-xs-medium-uppercase mb-1 text-text-tertiary'>{t(`${prefixSettings}.sso.label`)}</p>
|
||||
<div className='flex items-center justify-between'>
|
||||
<div className={cn('system-sm-semibold py-1 text-text-secondary')}>{t(`${prefixSettings}.sso.title`)}</div>
|
||||
<Tooltip
|
||||
disabled={systemFeatures.sso_enforced_for_web}
|
||||
popupContent={
|
||||
<div className='w-[180px]'>{t(`${prefixSettings}.sso.tooltip`)}</div>
|
||||
}
|
||||
asChild={false}
|
||||
>
|
||||
<Switch disabled={!systemFeatures.sso_enforced_for_web || !isCurrentWorkspaceEditor} defaultValue={systemFeatures.sso_enforced_for_web && inputInfo.enable_sso} onChange={v => setInputInfo({ ...inputInfo, enable_sso: v })}></Switch>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className='body-xs-regular pb-0.5 text-text-tertiary'>{t(`${prefixSettings}.sso.description`)}</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* more settings switch */}
|
||||
<Divider className="my-0 h-px" />
|
||||
{!isShowMore && (
|
||||
@ -392,14 +369,14 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
)}
|
||||
</div>
|
||||
<Tooltip
|
||||
disabled={!isFreePlan}
|
||||
disabled={webappCopyrightEnabled}
|
||||
popupContent={
|
||||
<div className='w-[260px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
|
||||
<div className='w-[180px]'>{t(`${prefixSettings}.more.copyrightTooltip`)}</div>
|
||||
}
|
||||
asChild={false}
|
||||
>
|
||||
<Switch
|
||||
disabled={isFreePlan}
|
||||
disabled={!webappCopyrightEnabled}
|
||||
defaultValue={inputInfo.copyrightSwitchValue}
|
||||
onChange={v => setInputInfo({ ...inputInfo, copyrightSwitchValue: v })}
|
||||
/>
|
||||
@ -450,7 +427,6 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<Button className='mr-2' onClick={onHide}>{t('common.operation.cancel')}</Button>
|
||||
<Button variant='primary' onClick={onClickSave} loading={saveLoading}>{t('common.operation.save')}</Button>
|
||||
</div>
|
||||
|
||||
{showAppIconPicker && (
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
<AppIconPicker
|
||||
|
||||
@ -15,7 +15,7 @@ export type AppSelectorProps = {
|
||||
onChange: (value: AppSelectorProps['value']) => void
|
||||
}
|
||||
|
||||
const allTypes: AppMode[] = ['chat', 'agent-chat', 'completion', 'advanced-chat', 'workflow']
|
||||
const allTypes: AppMode[] = ['workflow', 'advanced-chat', 'chat', 'agent-chat', 'completion']
|
||||
|
||||
const AppTypeSelector = ({ value, onChange }: AppSelectorProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@ -94,7 +94,7 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
<div
|
||||
className={classNames(
|
||||
isDragActive && 'border-primary-600',
|
||||
'relative aspect-square bg-gray-50 border-[1.5px] border-gray-200 border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
|
||||
'relative aspect-square border-[1.5px] border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@ -115,7 +115,7 @@ const AppIconPicker: FC<AppIconPickerProps> = ({
|
||||
className={cn(s.container, '!w-[362px] !p-0')}
|
||||
>
|
||||
{!DISABLE_UPLOAD_IMAGE_AS_ICON && <div className="w-full p-2 pb-0">
|
||||
<div className='flex items-center justify-center gap-2 rounded-xl bg-background-body p-1'>
|
||||
<div className='flex items-center justify-center gap-2 rounded-xl bg-background-body p-1 text-text-primary'>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
|
||||
@ -4,9 +4,6 @@
|
||||
align-items: flex-start;
|
||||
width: 362px;
|
||||
max-height: 552px;
|
||||
|
||||
border: 0.5px solid #EAECF0;
|
||||
box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.08), 0px 4px 6px -2px rgba(16, 24, 40, 0.03);
|
||||
border-radius: 12px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
@ -1,24 +1,27 @@
|
||||
'use client'
|
||||
import classNames from '@/utils/classnames'
|
||||
import type { FC } from 'react'
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type IAppUnavailableProps = {
|
||||
code?: number
|
||||
code?: number | string
|
||||
isUnknownReason?: boolean
|
||||
unknownReason?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
const AppUnavailable: FC<IAppUnavailableProps> = ({
|
||||
code = 404,
|
||||
isUnknownReason,
|
||||
unknownReason,
|
||||
className,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className='flex h-screen w-screen items-center justify-center'>
|
||||
<h1 className='mr-5 h-[50px] pr-5 text-[24px] font-medium leading-[50px]'
|
||||
<div className={classNames('flex h-screen w-screen items-center justify-center', className)}>
|
||||
<h1 className='mr-5 h-[50px] shrink-0 pr-5 text-[24px] font-medium leading-[50px]'
|
||||
style={{
|
||||
borderRight: '1px solid rgba(0,0,0,.3)',
|
||||
}}>{code}</h1>
|
||||
|
||||
@ -47,6 +47,7 @@ const ChatWrapper = () => {
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
setIsResponding,
|
||||
allInputsHidden,
|
||||
} = useChatWithHistoryContext()
|
||||
const appConfig = useMemo(() => {
|
||||
const config = appParams || {}
|
||||
@ -81,6 +82,9 @@ const ChatWrapper = () => {
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
if (allInputsHidden)
|
||||
return false
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
@ -110,7 +114,7 @@ const ChatWrapper = () => {
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms])
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
@ -161,7 +165,7 @@ const ChatWrapper = () => {
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (!inputsForms.length)
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
if (isMobile) {
|
||||
if (!currentConversationId)
|
||||
@ -171,7 +175,7 @@ const ChatWrapper = () => {
|
||||
else {
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed])
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
@ -181,7 +185,7 @@ const ChatWrapper = () => {
|
||||
return null
|
||||
if (!welcomeMessage)
|
||||
return null
|
||||
if (!collapsed && inputsForms.length > 0)
|
||||
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
|
||||
return null
|
||||
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
|
||||
return (
|
||||
@ -218,7 +222,7 @@ const ChatWrapper = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState])
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
|
||||
const answerIcon = (appData?.site && appData.site.use_icon_as_answer_icon)
|
||||
? <AnswerIcon
|
||||
|
||||
@ -22,6 +22,7 @@ export type ChatWithHistoryContextValue = {
|
||||
appInfoLoading?: boolean
|
||||
appMeta?: AppMeta
|
||||
appData?: AppData
|
||||
userCanAccess?: boolean
|
||||
appParams?: ChatConfig
|
||||
appChatListDataLoading?: boolean
|
||||
currentConversationId: string
|
||||
@ -57,9 +58,11 @@ export type ChatWithHistoryContextValue = {
|
||||
setIsResponding: (state: boolean) => void,
|
||||
currentConversationInputs: Record<string, any> | null,
|
||||
setCurrentConversationInputs: (v: Record<string, any>) => void,
|
||||
allInputsHidden: boolean,
|
||||
}
|
||||
|
||||
export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>({
|
||||
userCanAccess: false,
|
||||
currentConversationId: '',
|
||||
appPrevChatTree: [],
|
||||
pinnedConversationList: [],
|
||||
@ -90,5 +93,6 @@ export const ChatWithHistoryContext = createContext<ChatWithHistoryContextValue>
|
||||
setIsResponding: noop,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
})
|
||||
export const useChatWithHistoryContext = () => useContext(ChatWithHistoryContext)
|
||||
|
||||
@ -16,7 +16,7 @@ import type {
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams } from '../utils'
|
||||
import { buildChatItemTree, getProcessedSystemVariablesFromUrlParams, getRawInputsFromUrlParams } from '../utils'
|
||||
import { addFileInfos, sortAgentSorts } from '../../../tools/utils'
|
||||
import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
|
||||
import {
|
||||
@ -43,6 +43,8 @@ import { useAppFavicon } from '@/hooks/use-app-favicon'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
@ -72,7 +74,13 @@ function getFormattedChatList(messages: any[]) {
|
||||
|
||||
export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const isInstalledApp = useMemo(() => !!installedAppInfo, [installedAppInfo])
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR(installedAppInfo ? null : 'appInfo', fetchAppInfo)
|
||||
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||
appId: installedAppInfo?.app.id || appInfo?.app_id,
|
||||
isInstalledApp,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
|
||||
useAppFavicon({
|
||||
enable: !installedAppInfo,
|
||||
@ -181,6 +189,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const { t } = useTranslation()
|
||||
const newConversationInputsRef = useRef<Record<string, any>>({})
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
|
||||
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
|
||||
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
|
||||
newConversationInputsRef.current = newInputs
|
||||
setNewConversationInputs(newInputs)
|
||||
@ -188,20 +197,29 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
const inputsForms = useMemo(() => {
|
||||
return (appParams?.user_input_form || []).filter((item: any) => !item.external_data_tool).map((item: any) => {
|
||||
if (item.paragraph) {
|
||||
let value = initInputs[item.paragraph.variable]
|
||||
if (value && item.paragraph.max_length && value.length > item.paragraph.max_length)
|
||||
value = value.slice(0, item.paragraph.max_length)
|
||||
|
||||
return {
|
||||
...item.paragraph,
|
||||
default: value || item.default,
|
||||
type: 'paragraph',
|
||||
}
|
||||
}
|
||||
if (item.number) {
|
||||
const convertedNumber = Number(initInputs[item.number.variable]) ?? undefined
|
||||
return {
|
||||
...item.number,
|
||||
default: convertedNumber || item.default,
|
||||
type: 'number',
|
||||
}
|
||||
}
|
||||
if (item.select) {
|
||||
const isInputInOptions = item.select.options.includes(initInputs[item.select.variable])
|
||||
return {
|
||||
...item.select,
|
||||
default: (isInputInOptions ? initInputs[item.select.variable] : undefined) || item.default,
|
||||
type: 'select',
|
||||
}
|
||||
}
|
||||
@ -220,12 +238,30 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
}
|
||||
}
|
||||
|
||||
let value = initInputs[item['text-input'].variable]
|
||||
if (value && item['text-input'].max_length && value.length > item['text-input'].max_length)
|
||||
value = value.slice(0, item['text-input'].max_length)
|
||||
|
||||
return {
|
||||
...item['text-input'],
|
||||
default: value || item.default,
|
||||
type: 'text-input',
|
||||
}
|
||||
})
|
||||
}, [appParams])
|
||||
}, [initInputs, appParams])
|
||||
|
||||
const allInputsHidden = useMemo(() => {
|
||||
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
|
||||
}, [inputsForms])
|
||||
|
||||
useEffect(() => {
|
||||
// init inputs from url params
|
||||
(async () => {
|
||||
const inputs = await getRawInputsFromUrlParams()
|
||||
setInitInputs(inputs)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const conversationInputs: Record<string, any> = {}
|
||||
|
||||
@ -290,6 +326,9 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (allInputsHidden)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
@ -325,7 +364,7 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
}
|
||||
|
||||
return true
|
||||
}, [inputsForms, notify, t])
|
||||
}, [inputsForms, notify, t, allInputsHidden])
|
||||
const handleStartChat = useCallback((callback: any) => {
|
||||
if (checkInputsRequired()) {
|
||||
setShowNewConversationItemInList(true)
|
||||
@ -340,11 +379,11 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
if (conversationId)
|
||||
setClearChatList(false)
|
||||
}, [handleConversationIdInfoChange, setClearChatList])
|
||||
const handleNewConversation = useCallback(() => {
|
||||
const handleNewConversation = useCallback(async () => {
|
||||
currentChatInstanceRef.current.handleStop()
|
||||
setShowNewConversationItemInList(true)
|
||||
handleChangeConversation('')
|
||||
handleNewConversationInputsChange({})
|
||||
handleNewConversationInputsChange(await getRawInputsFromUrlParams())
|
||||
setClearChatList(true)
|
||||
}, [handleChangeConversation, setShowNewConversationItemInList, handleNewConversationInputsChange, setClearChatList])
|
||||
const handleUpdateConversationList = useCallback(() => {
|
||||
@ -447,7 +486,8 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
|
||||
return {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
|
||||
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
|
||||
isInstalledApp,
|
||||
appId,
|
||||
currentConversationId,
|
||||
@ -491,5 +531,6 @@ export const useChatWithHistory = (installedAppInfo?: InstalledApp) => {
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
@ -17,9 +19,12 @@ import ChatWrapper from './chat-wrapper'
|
||||
import type { InstalledApp } from '@/models/explore'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
type ChatWithHistoryProps = {
|
||||
className?: string
|
||||
@ -28,6 +33,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
className,
|
||||
}) => {
|
||||
const {
|
||||
userCanAccess,
|
||||
appInfoError,
|
||||
appData,
|
||||
appInfoLoading,
|
||||
@ -36,6 +42,7 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
isMobile,
|
||||
themeBuilder,
|
||||
sidebarCollapseState,
|
||||
isInstalledApp,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
const customConfig = appData?.custom_config
|
||||
@ -45,19 +52,38 @@ const ChatWithHistory: FC<ChatWithHistoryProps> = ({
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
if (site) {
|
||||
if (customConfig)
|
||||
document.title = `${site.title}`
|
||||
else
|
||||
document.title = `${site.title} - Powered by Dify`
|
||||
}
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
const { t } = useTranslation()
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
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(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (appInfoLoading) {
|
||||
return (
|
||||
<Loading type='app' />
|
||||
)
|
||||
}
|
||||
if (!userCanAccess) {
|
||||
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.' />
|
||||
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
@ -124,6 +150,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
const {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
userCanAccess,
|
||||
appData,
|
||||
appParams,
|
||||
appMeta,
|
||||
@ -159,6 +186,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
} = useChatWithHistory(installedAppInfo)
|
||||
|
||||
return (
|
||||
@ -166,6 +194,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
userCanAccess,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
@ -202,6 +231,7 @@ const ChatWithHistoryWrap: FC<ChatWithHistoryWrapProps> = ({
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
}}>
|
||||
<ChatWithHistory className={className} />
|
||||
</ChatWithHistoryContext.Provider>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useChatWithHistoryContext } from '../context'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -36,9 +36,11 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
})
|
||||
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
|
||||
|
||||
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{inputsForms.map(form => (
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className='space-y-1'>
|
||||
<div className='flex h-6 items-center gap-1'>
|
||||
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
|
||||
@ -112,4 +114,4 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default InputsFormContent
|
||||
export default memo(InputsFormContent)
|
||||
|
||||
@ -21,9 +21,14 @@ const InputsFormNode = ({
|
||||
isMobile,
|
||||
currentConversationId,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
themeBuilder,
|
||||
inputsForms,
|
||||
} = useChatWithHistoryContext()
|
||||
|
||||
if (allInputsHidden || inputsForms.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col items-center px-4 pt-6', isMobile && 'pt-4')}>
|
||||
<div className={cn(
|
||||
|
||||
@ -16,9 +16,10 @@ import List from '@/app/components/base/chat/chat-with-history/sidebar/list'
|
||||
import MenuDropdown from '@/app/components/share/text-generation/menu-dropdown'
|
||||
import Confirm from '@/app/components/base/confirm'
|
||||
import RenameModal from '@/app/components/base/chat/chat-with-history/sidebar/rename-modal'
|
||||
import LogoSite from '@/app/components/base/logo/logo-site'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import type { ConversationItem } from '@/models/share'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
type Props = {
|
||||
isPanel?: boolean
|
||||
@ -27,6 +28,7 @@ type Props = {
|
||||
const Sidebar = ({ isPanel }: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
isInstalledApp,
|
||||
appData,
|
||||
handleNewConversation,
|
||||
pinnedConversationList,
|
||||
@ -44,7 +46,7 @@ const Sidebar = ({ isPanel }: Props) => {
|
||||
isResponding,
|
||||
} = useChatWithHistoryContext()
|
||||
const isSidebarCollapsed = sidebarCollapseState
|
||||
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const [showConfirm, setShowConfirm] = useState<ConversationItem | null>(null)
|
||||
const [showRename, setShowRename] = useState<ConversationItem | null>(null)
|
||||
|
||||
@ -136,42 +138,43 @@ const Sidebar = ({ isPanel }: Props) => {
|
||||
)}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-center justify-between p-3'>
|
||||
<MenuDropdown placement='top-start' data={appData?.site} />
|
||||
<MenuDropdown hideLogout={isInstalledApp} placement='top-start' data={appData?.site} />
|
||||
{/* powered by */}
|
||||
<div className='shrink-0'>
|
||||
{!appData?.custom_config?.remove_webapp_brand && (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
'flex shrink-0 items-center gap-1.5 px-1',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{appData?.custom_config?.replace_webapp_logo && (
|
||||
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
|
||||
)}
|
||||
{!appData?.custom_config?.replace_webapp_logo && (
|
||||
<LogoSite className='!h-5' />
|
||||
)}
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content') || ''}
|
||||
isShow
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!!showConfirm && (
|
||||
<Confirm
|
||||
title={t('share.chat.deleteConversation.title')}
|
||||
content={t('share.chat.deleteConversation.content') || ''}
|
||||
isShow
|
||||
onCancel={handleCancelConfirm}
|
||||
onConfirm={handleDelete}
|
||||
/>
|
||||
)}
|
||||
{showRename && (
|
||||
<RenameModal
|
||||
isShow
|
||||
onClose={handleCancelRename}
|
||||
saveLoading={conversationRenaming}
|
||||
name={showRename?.name || ''}
|
||||
onSave={handleRename}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ export const markdownContentSVG = `
|
||||
<svg width="400" height="600" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100%" height="100%" fill="#F0F8FF"/>
|
||||
|
||||
<text x="50%" y="60" font-family="楷体" font-size="32" fill="#4682B4" text-anchor="middle">创意Logo设计</text>
|
||||
<text x="50%" y="60" font-family="楷体" font-size="32" fill="#4682B4" text-anchor="middle">创意 Logo 设计</text>
|
||||
|
||||
<line x1="50" y1="80" x2="350" y2="80" stroke="#B0C4DE" stroke-width="2"/>
|
||||
|
||||
|
||||
@ -234,6 +234,4 @@ const Answer: FC<AnswerProps> = ({
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Answer, (prevProps, nextProps) =>
|
||||
prevProps.responding === false && nextProps.responding === false,
|
||||
)
|
||||
export default memo(Answer)
|
||||
|
||||
@ -29,6 +29,7 @@ import type { FileUpload } from '@/app/components/base/features/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type ChatInputAreaProps = {
|
||||
botName?: string
|
||||
showFeatureBar?: boolean
|
||||
showFileUpload?: boolean
|
||||
featureBarDisabled?: boolean
|
||||
@ -43,6 +44,7 @@ type ChatInputAreaProps = {
|
||||
disabled?: boolean
|
||||
}
|
||||
const ChatInputArea = ({
|
||||
botName,
|
||||
showFeatureBar,
|
||||
showFileUpload,
|
||||
featureBarDisabled,
|
||||
@ -190,9 +192,9 @@ const ChatInputArea = ({
|
||||
<Textarea
|
||||
ref={ref => textareaRef.current = ref as any}
|
||||
className={cn(
|
||||
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-tertiary outline-none',
|
||||
'body-lg-regular w-full resize-none bg-transparent p-1 leading-6 text-text-primary outline-none',
|
||||
)}
|
||||
placeholder={t('common.chat.inputPlaceholder') || ''}
|
||||
placeholder={t('common.chat.inputPlaceholder', { botName }) || ''}
|
||||
autoFocus
|
||||
minRows={1}
|
||||
onResize={handleTextareaResize}
|
||||
|
||||
@ -366,8 +366,9 @@ export const useChat = (
|
||||
if (!newResponseItem)
|
||||
return
|
||||
|
||||
const isUseAgentThought = newResponseItem.agent_thoughts?.length > 0 && newResponseItem.agent_thoughts[newResponseItem.agent_thoughts?.length - 1].thought === newResponseItem.answer
|
||||
updateChatTreeNode(responseItem.id, {
|
||||
content: newResponseItem.answer,
|
||||
content: isUseAgentThought ? '' : newResponseItem.answer,
|
||||
log: [
|
||||
...newResponseItem.message,
|
||||
...(newResponseItem.message[newResponseItem.message.length - 1].role !== 'assistant'
|
||||
@ -424,6 +425,8 @@ export const useChat = (
|
||||
const response = responseItem as any
|
||||
if (thought.message_id && !hasSetResponseId)
|
||||
response.id = thought.message_id
|
||||
if (thought.conversation_id)
|
||||
response.conversationId = thought.conversation_id
|
||||
|
||||
if (response.agent_thoughts.length === 0) {
|
||||
response.agent_thoughts.push(thought)
|
||||
|
||||
@ -274,7 +274,7 @@ const Chat: FC<ChatProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`absolute bottom-0 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
|
||||
className={`absolute bottom-0 z-10 flex justify-center bg-chat-input-mask ${(hasTryToAsk || !noChatInput || !noStopResponding) && chatFooterClassName}`}
|
||||
ref={chatFooterRef}
|
||||
>
|
||||
<div
|
||||
@ -303,6 +303,7 @@ const Chat: FC<ChatProps> = ({
|
||||
{
|
||||
!noChatInput && (
|
||||
<ChatInputArea
|
||||
botName={appData?.site.title || 'Bot'}
|
||||
disabled={inputDisabled}
|
||||
showFeatureBar={showFeatureBar}
|
||||
showFileUpload={showFileUpload}
|
||||
|
||||
@ -5,6 +5,8 @@ import type {
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import type { ChatItem } from '../types'
|
||||
@ -52,6 +54,8 @@ const Question: FC<QuestionProps> = ({
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false)
|
||||
const [editedContent, setEditedContent] = useState(content)
|
||||
const [contentWidth, setContentWidth] = useState(0)
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
setIsEditing(true)
|
||||
@ -75,14 +79,31 @@ const Question: FC<QuestionProps> = ({
|
||||
item.nextSibling && switchSibling?.(item.nextSibling)
|
||||
}, [switchSibling, item.prevSibling, item.nextSibling])
|
||||
|
||||
const getContentWidth = () => {
|
||||
if (contentRef.current)
|
||||
setContentWidth(contentRef.current?.clientWidth)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!contentRef.current)
|
||||
return
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
getContentWidth()
|
||||
})
|
||||
resizeObserver.observe(contentRef.current)
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className='mb-2 flex justify-end pl-14 last:mb-0'>
|
||||
<div className={cn('group relative mr-4 flex max-w-full items-start', isEditing && 'flex-1')}>
|
||||
<div className='mb-2 flex justify-end last:mb-0'>
|
||||
<div className={cn('group relative mr-4 flex max-w-full items-start pl-14', isEditing && 'flex-1')}>
|
||||
<div className={cn('mr-2 gap-1', isEditing ? 'hidden' : 'flex')}>
|
||||
<div className="
|
||||
absolutegap-0.5 hidden rounded-[10px] border-[0.5px] border-components-actionbar-border
|
||||
bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex
|
||||
">
|
||||
<div
|
||||
className="absolute hidden gap-0.5 rounded-[10px] border-[0.5px] border-components-actionbar-border bg-components-actionbar-bg p-0.5 shadow-md backdrop-blur-sm group-hover:flex"
|
||||
style={{ right: contentWidth + 8 }}
|
||||
>
|
||||
<ActionButton onClick={() => {
|
||||
copy(content)
|
||||
Toast.notify({ type: 'success', message: t('common.actionMsg.copySuccessfully') })
|
||||
@ -95,7 +116,8 @@ const Question: FC<QuestionProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className='w-full rounded-2xl bg-[#D1E9FF]/50 px-4 py-3 text-sm text-gray-900'
|
||||
ref={contentRef}
|
||||
className='w-full rounded-2xl bg-background-gradient-bg-fill-chat-bubble-bg-3 px-4 py-3 text-sm text-text-primary'
|
||||
style={theme?.chatBubbleColorStyle ? CssTransform(theme.chatBubbleColorStyle) : {}}
|
||||
>
|
||||
{
|
||||
|
||||
@ -41,6 +41,7 @@ export type ThoughtItem = {
|
||||
tool_input: string
|
||||
tool_labels?: { [key: string]: TypeWithI18N }
|
||||
message_id: string
|
||||
conversation_id: string
|
||||
observation: string
|
||||
position: number
|
||||
files?: string[]
|
||||
@ -142,5 +143,6 @@ export type InputForm = {
|
||||
label: string
|
||||
variable: any
|
||||
required: boolean
|
||||
hide: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ import SuggestedQuestions from '@/app/components/base/chat/chat/answer/suggested
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import cn from '@/utils/classnames'
|
||||
import type { FileEntity } from '../../file-uploader/types'
|
||||
import Avatar from '../../avatar'
|
||||
|
||||
const ChatWrapper = () => {
|
||||
const {
|
||||
@ -48,6 +49,8 @@ const ChatWrapper = () => {
|
||||
clearChatList,
|
||||
setClearChatList,
|
||||
setIsResponding,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const appConfig = useMemo(() => {
|
||||
const config = appParams || {}
|
||||
@ -82,6 +85,9 @@ const ChatWrapper = () => {
|
||||
)
|
||||
const inputsFormValue = currentConversationId ? currentConversationInputs : newConversationInputsRef?.current
|
||||
const inputDisabled = useMemo(() => {
|
||||
if (allInputsHidden)
|
||||
return false
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
@ -111,7 +117,7 @@ const ChatWrapper = () => {
|
||||
if (fileIsUploading)
|
||||
return true
|
||||
return false
|
||||
}, [inputsFormValue, inputsForms])
|
||||
}, [inputsFormValue, inputsForms, allInputsHidden])
|
||||
|
||||
useEffect(() => {
|
||||
if (currentChatInstanceRef.current)
|
||||
@ -160,7 +166,7 @@ const ChatWrapper = () => {
|
||||
const [collapsed, setCollapsed] = useState(!!currentConversationId)
|
||||
|
||||
const chatNode = useMemo(() => {
|
||||
if (!inputsForms.length)
|
||||
if (allInputsHidden || !inputsForms.length)
|
||||
return null
|
||||
if (isMobile) {
|
||||
if (!currentConversationId)
|
||||
@ -170,7 +176,7 @@ const ChatWrapper = () => {
|
||||
else {
|
||||
return <InputsForm collapsed={collapsed} setCollapsed={setCollapsed} />
|
||||
}
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed])
|
||||
}, [inputsForms.length, isMobile, currentConversationId, collapsed, allInputsHidden])
|
||||
|
||||
const welcome = useMemo(() => {
|
||||
const welcomeMessage = chatList.find(item => item.isOpeningStatement)
|
||||
@ -180,7 +186,7 @@ const ChatWrapper = () => {
|
||||
return null
|
||||
if (!welcomeMessage)
|
||||
return null
|
||||
if (!collapsed && inputsForms.length > 0)
|
||||
if (!collapsed && inputsForms.length > 0 && !allInputsHidden)
|
||||
return null
|
||||
if (welcomeMessage.suggestedQuestions && welcomeMessage.suggestedQuestions?.length > 0) {
|
||||
return (
|
||||
@ -215,7 +221,7 @@ const ChatWrapper = () => {
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState])
|
||||
}, [appData?.site.icon, appData?.site.icon_background, appData?.site.icon_type, appData?.site.icon_url, chatList, collapsed, currentConversationId, inputsForms.length, respondingState, allInputsHidden])
|
||||
|
||||
const answerIcon = isDify()
|
||||
? <LogoAvatar className='relative shrink-0' />
|
||||
@ -257,6 +263,14 @@ const ChatWrapper = () => {
|
||||
switchSibling={siblingMessageId => setTargetMessageId(siblingMessageId)}
|
||||
inputDisabled={inputDisabled}
|
||||
isMobile={isMobile}
|
||||
questionIcon={
|
||||
initUserVariables?.avatar_url
|
||||
? <Avatar
|
||||
avatar={initUserVariables.avatar_url}
|
||||
name={initUserVariables.name || 'user'}
|
||||
size={40}
|
||||
/> : undefined
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -17,6 +17,7 @@ import type {
|
||||
import { noop } from 'lodash-es'
|
||||
|
||||
export type EmbeddedChatbotContextValue = {
|
||||
userCanAccess?: boolean
|
||||
appInfoError?: any
|
||||
appInfoLoading?: boolean
|
||||
appMeta?: AppMeta
|
||||
@ -50,9 +51,15 @@ export type EmbeddedChatbotContextValue = {
|
||||
setIsResponding: (state: boolean) => void,
|
||||
currentConversationInputs: Record<string, any> | null,
|
||||
setCurrentConversationInputs: (v: Record<string, any>) => void,
|
||||
allInputsHidden: boolean
|
||||
initUserVariables?: {
|
||||
name?: string
|
||||
avatar_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>({
|
||||
userCanAccess: false,
|
||||
currentConversationId: '',
|
||||
appPrevChatList: [],
|
||||
pinnedConversationList: [],
|
||||
@ -77,5 +84,7 @@ export const EmbeddedChatbotContext = createContext<EmbeddedChatbotContextValue>
|
||||
setIsResponding: noop,
|
||||
currentConversationInputs: {},
|
||||
setCurrentConversationInputs: noop,
|
||||
allInputsHidden: false,
|
||||
initUserVariables: {},
|
||||
})
|
||||
export const useEmbeddedChatbotContext = () => useContext(EmbeddedChatbotContext)
|
||||
|
||||
@ -11,8 +11,9 @@ import Tooltip from '@/app/components/base/tooltip'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import ViewFormDropdown from '@/app/components/base/chat/embedded-chatbot/inputs-form/view-form-dropdown'
|
||||
import LogoSite from '@/app/components/base/logo/logo-site'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
export type IHeaderProps = {
|
||||
isMobile?: boolean
|
||||
@ -42,6 +43,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
const [parentOrigin, setParentOrigin] = useState('')
|
||||
const [showToggleExpandButton, setShowToggleExpandButton] = useState(false)
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const handleMessageReceived = useCallback((event: MessageEvent) => {
|
||||
let currentParentOrigin = parentOrigin
|
||||
@ -85,12 +87,13 @@ const Header: FC<IHeaderProps> = ({
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{appData?.custom_config?.replace_webapp_logo && (
|
||||
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
|
||||
)}
|
||||
{!appData?.custom_config?.replace_webapp_logo && (
|
||||
<LogoSite className='!h-5' />
|
||||
)}
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -132,7 +135,7 @@ const Header: FC<IHeaderProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn('flex h-14 shrink-0 items-center justify-between rounded-t-2xl px-3')}
|
||||
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? '')) }
|
||||
style={Object.assign({}, CssTransform(theme?.backgroundHeaderColorStyle ?? ''), CssTransform(theme?.headerBorderBottomStyle ?? ''))}
|
||||
>
|
||||
<div className="flex grow items-center space-x-3">
|
||||
{customerIcon}
|
||||
|
||||
@ -15,7 +15,7 @@ import type {
|
||||
Feedback,
|
||||
} from '../types'
|
||||
import { CONVERSATION_ID_INFO } from '../constants'
|
||||
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams } from '../utils'
|
||||
import { buildChatItemTree, getProcessedInputsFromUrlParams, getProcessedSystemVariablesFromUrlParams, getProcessedUserVariablesFromUrlParams } from '../utils'
|
||||
import { getProcessedFilesFromResponse } from '../../file-uploader/utils'
|
||||
import {
|
||||
fetchAppInfo,
|
||||
@ -36,6 +36,8 @@ import { InputVarType } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import { addFileInfos, sortAgentSorts } from '@/app/components/tools/utils'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useGetUserCanAccessApp } from '@/service/access-control'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
function getFormattedChatList(messages: any[]) {
|
||||
const newChatList: ChatItem[] = []
|
||||
@ -65,7 +67,13 @@ function getFormattedChatList(messages: any[]) {
|
||||
|
||||
export const useEmbeddedChatbot = () => {
|
||||
const isInstalledApp = false
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
const { data: appInfo, isLoading: appInfoLoading, error: appInfoError } = useSWR('appInfo', fetchAppInfo)
|
||||
const { isPending: isCheckingPermission, data: userCanAccessResult } = useGetUserCanAccessApp({
|
||||
appId: appInfo?.app_id,
|
||||
isInstalledApp,
|
||||
enabled: systemFeatures.webapp_auth.enabled,
|
||||
})
|
||||
|
||||
const appData = useMemo(() => {
|
||||
return appInfo
|
||||
@ -161,6 +169,7 @@ export const useEmbeddedChatbot = () => {
|
||||
const newConversationInputsRef = useRef<Record<string, any>>({})
|
||||
const [newConversationInputs, setNewConversationInputs] = useState<Record<string, any>>({})
|
||||
const [initInputs, setInitInputs] = useState<Record<string, any>>({})
|
||||
const [initUserVariables, setInitUserVariables] = useState<Record<string, any>>({})
|
||||
const handleNewConversationInputsChange = useCallback((newInputs: Record<string, any>) => {
|
||||
newConversationInputsRef.current = newInputs
|
||||
setNewConversationInputs(newInputs)
|
||||
@ -221,11 +230,17 @@ export const useEmbeddedChatbot = () => {
|
||||
})
|
||||
}, [initInputs, appParams])
|
||||
|
||||
const allInputsHidden = useMemo(() => {
|
||||
return inputsForms.length > 0 && inputsForms.every(item => item.hide === true)
|
||||
}, [inputsForms])
|
||||
|
||||
useEffect(() => {
|
||||
// init inputs from url params
|
||||
(async () => {
|
||||
const inputs = await getProcessedInputsFromUrlParams()
|
||||
const userVariables = await getProcessedUserVariablesFromUrlParams()
|
||||
setInitInputs(inputs)
|
||||
setInitUserVariables(userVariables)
|
||||
})()
|
||||
}, [])
|
||||
useEffect(() => {
|
||||
@ -292,6 +307,9 @@ export const useEmbeddedChatbot = () => {
|
||||
|
||||
const { notify } = useToastContext()
|
||||
const checkInputsRequired = useCallback((silent?: boolean) => {
|
||||
if (allInputsHidden)
|
||||
return true
|
||||
|
||||
let hasEmptyInput = ''
|
||||
let fileIsUploading = false
|
||||
const requiredVars = inputsForms.filter(({ required }) => required)
|
||||
@ -327,7 +345,7 @@ export const useEmbeddedChatbot = () => {
|
||||
}
|
||||
|
||||
return true
|
||||
}, [inputsForms, notify, t])
|
||||
}, [inputsForms, notify, t, allInputsHidden])
|
||||
const handleStartChat = useCallback((callback?: any) => {
|
||||
if (checkInputsRequired()) {
|
||||
setShowNewConversationItemInList(true)
|
||||
@ -364,7 +382,8 @@ export const useEmbeddedChatbot = () => {
|
||||
|
||||
return {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appInfoLoading: appInfoLoading || (systemFeatures.webapp_auth.enabled && isCheckingPermission),
|
||||
userCanAccess: systemFeatures.webapp_auth.enabled ? userCanAccessResult?.result : true,
|
||||
isInstalledApp,
|
||||
allowResetChat,
|
||||
appId,
|
||||
@ -401,5 +420,7 @@ export const useEmbeddedChatbot = () => {
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
'use client'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
@ -12,18 +14,22 @@ import { useEmbeddedChatbot } from './hooks'
|
||||
import { isDify } from './utils'
|
||||
import { useThemeContext } from './theme/theme-context'
|
||||
import { CssTransform } from './theme/utils'
|
||||
import { checkOrSetAccessToken } from '@/app/components/share/utils'
|
||||
import { checkOrSetAccessToken, removeAccessToken } from '@/app/components/share/utils'
|
||||
import AppUnavailable from '@/app/components/base/app-unavailable'
|
||||
import useBreakpoints, { MediaType } from '@/hooks/use-breakpoints'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import LogoHeader from '@/app/components/base/logo/logo-embedded-chat-header'
|
||||
import Header from '@/app/components/base/chat/embedded-chatbot/header'
|
||||
import ChatWrapper from '@/app/components/base/chat/embedded-chatbot/chat-wrapper'
|
||||
import LogoSite from '@/app/components/base/logo/logo-site'
|
||||
import DifyLogo from '@/app/components/base/logo/dify-logo'
|
||||
import cn from '@/utils/classnames'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
const Chatbot = () => {
|
||||
const {
|
||||
userCanAccess,
|
||||
isMobile,
|
||||
allowResetChat,
|
||||
appInfoError,
|
||||
@ -33,8 +39,10 @@ const Chatbot = () => {
|
||||
chatShouldReloadKey,
|
||||
handleNewConversation,
|
||||
themeBuilder,
|
||||
isInstalledApp,
|
||||
} = useEmbeddedChatbotContext()
|
||||
const { t } = useTranslation()
|
||||
const systemFeatures = useGlobalPublicStore(s => s.systemFeatures)
|
||||
|
||||
const customConfig = appData?.custom_config
|
||||
const site = appData?.site
|
||||
@ -43,14 +51,26 @@ const Chatbot = () => {
|
||||
|
||||
useEffect(() => {
|
||||
themeBuilder?.buildTheme(site?.chat_color_theme, site?.chat_color_theme_inverted)
|
||||
if (site) {
|
||||
if (customConfig)
|
||||
document.title = `${site.title}`
|
||||
else
|
||||
document.title = `${site.title} - Powered by Dify`
|
||||
}
|
||||
}, [site, customConfig, themeBuilder])
|
||||
|
||||
useDocumentTitle(site?.title || 'Chat')
|
||||
|
||||
const searchParams = useSearchParams()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
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(() => {
|
||||
removeAccessToken()
|
||||
const url = getSigninUrl()
|
||||
router.replace(url)
|
||||
}, [getSigninUrl, router])
|
||||
|
||||
if (appInfoLoading) {
|
||||
return (
|
||||
<>
|
||||
@ -66,6 +86,13 @@ const Chatbot = () => {
|
||||
)
|
||||
}
|
||||
|
||||
if (!userCanAccess) {
|
||||
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.' />
|
||||
{!isInstalledApp && <span className='system-sm-regular cursor-pointer text-text-tertiary' onClick={backToHome}>{t('common.userProfile.logout')}</span>}
|
||||
</div>
|
||||
}
|
||||
|
||||
if (appInfoError) {
|
||||
return (
|
||||
<>
|
||||
@ -114,12 +141,13 @@ const Chatbot = () => {
|
||||
'flex shrink-0 items-center gap-1.5 px-2',
|
||||
)}>
|
||||
<div className='system-2xs-medium-uppercase text-text-tertiary'>{t('share.chat.poweredBy')}</div>
|
||||
{appData?.custom_config?.replace_webapp_logo && (
|
||||
<img src={appData?.custom_config?.replace_webapp_logo} alt='logo' className='block h-5 w-auto' />
|
||||
)}
|
||||
{!appData?.custom_config?.replace_webapp_logo && (
|
||||
<LogoSite className='!h-5' />
|
||||
)}
|
||||
{
|
||||
systemFeatures.branding.enabled && systemFeatures.branding.workspace_logo
|
||||
? <img src={systemFeatures.branding.workspace_logo} alt='logo' className='block h-5 w-auto' />
|
||||
: appData?.custom_config?.replace_webapp_logo
|
||||
? <img src={`${appData?.custom_config?.replace_webapp_logo}`} alt='logo' className='block h-5 w-auto' />
|
||||
: <DifyLogo size='small' />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -137,6 +165,7 @@ const EmbeddedChatbotWrapper = () => {
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
userCanAccess,
|
||||
appParams,
|
||||
appMeta,
|
||||
appChatListDataLoading,
|
||||
@ -165,9 +194,12 @@ const EmbeddedChatbotWrapper = () => {
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
} = useEmbeddedChatbot()
|
||||
|
||||
return <EmbeddedChatbotContext.Provider value={{
|
||||
userCanAccess,
|
||||
appInfoError,
|
||||
appInfoLoading,
|
||||
appData,
|
||||
@ -201,6 +233,8 @@ const EmbeddedChatbotWrapper = () => {
|
||||
setIsResponding,
|
||||
currentConversationInputs,
|
||||
setCurrentConversationInputs,
|
||||
allInputsHidden,
|
||||
initUserVariables,
|
||||
}}>
|
||||
<Chatbot />
|
||||
</EmbeddedChatbotContext.Provider>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react'
|
||||
import React, { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useEmbeddedChatbotContext } from '../context'
|
||||
import Input from '@/app/components/base/input'
|
||||
@ -36,9 +36,11 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
})
|
||||
}, [newConversationInputsRef, handleNewConversationInputsChange, currentConversationInputs, setCurrentConversationInputs])
|
||||
|
||||
const visibleInputsForms = inputsForms.filter(form => form.hide !== true)
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{inputsForms.map(form => (
|
||||
{visibleInputsForms.map(form => (
|
||||
<div key={form.variable} className='space-y-1'>
|
||||
<div className='flex h-6 items-center gap-1'>
|
||||
<div className='system-md-semibold text-text-secondary'>{form.label}</div>
|
||||
@ -112,4 +114,4 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)
|
||||
}
|
||||
|
||||
export default InputsFormContent
|
||||
export default memo(InputsFormContent)
|
||||
|
||||
@ -22,8 +22,13 @@ const InputsFormNode = ({
|
||||
currentConversationId,
|
||||
themeBuilder,
|
||||
handleStartChat,
|
||||
allInputsHidden,
|
||||
inputsForms,
|
||||
} = useEmbeddedChatbotContext()
|
||||
|
||||
if (allInputsHidden || inputsForms.length === 0)
|
||||
return null
|
||||
|
||||
return (
|
||||
<div className={cn('mb-6 flex flex-col items-center px-4 pt-6', isMobile && 'mb-4 pt-4')}>
|
||||
<div className={cn(
|
||||
|
||||
@ -12,8 +12,7 @@ export class Theme {
|
||||
public colorPathOnHeader = 'text-text-primary-on-surface'
|
||||
public backgroundButtonDefaultColorStyle = 'backgroundColor: #1C64F2'
|
||||
public roundedBackgroundColorStyle = 'backgroundColor: rgb(245 248 255)'
|
||||
public chatBubbleColorStyle = 'backgroundColor: rgb(225 239 254)'
|
||||
public chatBubbleColor = 'rgb(225 239 254)'
|
||||
public chatBubbleColorStyle = ''
|
||||
|
||||
constructor(chatColorTheme: string | null = null, chatColorThemeInverted = false) {
|
||||
this.chatColorTheme = chatColorTheme
|
||||
@ -29,7 +28,6 @@ export class Theme {
|
||||
this.backgroundButtonDefaultColorStyle = `backgroundColor: ${this.primaryColor}; color: ${this.colorFontOnHeaderStyle};`
|
||||
this.roundedBackgroundColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.05)}`
|
||||
this.chatBubbleColorStyle = `backgroundColor: ${hexToRGBA(this.primaryColor, 0.15)}`
|
||||
this.chatBubbleColor = `${hexToRGBA(this.primaryColor, 0.15)}`
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,13 +15,25 @@ async function decodeBase64AndDecompress(base64String: string) {
|
||||
}
|
||||
}
|
||||
|
||||
async function getRawInputsFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inputs: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
entriesArray.forEach(([key, value]) => {
|
||||
if (!key.startsWith('sys.'))
|
||||
inputs[key] = decodeURIComponent(value)
|
||||
})
|
||||
return inputs
|
||||
}
|
||||
|
||||
async function getProcessedInputsFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const inputs: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
await Promise.all(
|
||||
entriesArray.map(async ([key, value]) => {
|
||||
if (!key.startsWith('sys.'))
|
||||
const prefixArray = ['sys.', 'user.']
|
||||
if (!prefixArray.some(prefix => key.startsWith(prefix)))
|
||||
inputs[key] = await decodeBase64AndDecompress(decodeURIComponent(value))
|
||||
}),
|
||||
)
|
||||
@ -41,6 +53,19 @@ async function getProcessedSystemVariablesFromUrlParams(): Promise<Record<string
|
||||
return systemVariables
|
||||
}
|
||||
|
||||
async function getProcessedUserVariablesFromUrlParams(): Promise<Record<string, any>> {
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
const userVariables: Record<string, any> = {}
|
||||
const entriesArray = Array.from(urlParams.entries())
|
||||
await Promise.all(
|
||||
entriesArray.map(async ([key, value]) => {
|
||||
if (key.startsWith('user.'))
|
||||
userVariables[key.slice(5)] = await decodeBase64AndDecompress(decodeURIComponent(value))
|
||||
}),
|
||||
)
|
||||
return userVariables
|
||||
}
|
||||
|
||||
function isValidGeneratedAnswer(item?: ChatItem | ChatItemInTree): boolean {
|
||||
return !!item && item.isAnswer && !item.id.startsWith('answer-placeholder-') && !item.isOpeningStatement
|
||||
}
|
||||
@ -184,8 +209,10 @@ function getThreadMessages(tree: ChatItemInTree[], targetMessageId?: string): Ch
|
||||
}
|
||||
|
||||
export {
|
||||
getRawInputsFromUrlParams,
|
||||
getProcessedInputsFromUrlParams,
|
||||
getProcessedSystemVariablesFromUrlParams,
|
||||
getProcessedUserVariablesFromUrlParams,
|
||||
isValidGeneratedAnswer,
|
||||
getLastAnswer,
|
||||
buildChatItemTree,
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import { RiCloseLine, RiInformation2Fill } from '@remixicon/react'
|
||||
import DialogWrapper from '@/app/components/base/features/new-feature-panel/dialog-wrapper'
|
||||
import { useDefaultModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
@ -19,8 +18,7 @@ import Moderation from '@/app/components/base/features/new-feature-panel/moderat
|
||||
import AnnotationReply from '@/app/components/base/features/new-feature-panel/annotation-reply'
|
||||
import type { PromptVariable } from '@/models/debug'
|
||||
import type { InputVar } from '@/app/components/workflow/types'
|
||||
import I18n from '@/context/i18n'
|
||||
import { LanguagesSupported } from '@/i18n/language'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
type Props = {
|
||||
show: boolean
|
||||
@ -48,7 +46,7 @@ const NewFeaturePanel = ({
|
||||
onAutoAddPromptVariable,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation()
|
||||
const { locale } = useContext(I18n)
|
||||
const docLink = useDocLink()
|
||||
const { data: speech2textDefaultModel } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||
const { data: text2speechDefaultModel } = useDefaultModel(ModelTypeEnum.tts)
|
||||
|
||||
@ -80,7 +78,7 @@ const NewFeaturePanel = ({
|
||||
<span>{isChatMode ? t('workflow.common.fileUploadTip') : t('workflow.common.ImageUploadLegacyTip')}</span>
|
||||
<a
|
||||
className='text-text-accent'
|
||||
href={`https://docs.dify.ai/${locale === LanguagesSupported[1] ? 'v/zh-hans/' : ''}guides/workflow/bulletin`}
|
||||
href={docLink('/guides/workflow/bulletin')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
>{t('workflow.common.featuresDocLink')}</a>
|
||||
</div>
|
||||
|
||||
@ -25,6 +25,7 @@ import { useModalContext } from '@/context/modal-context'
|
||||
import { CustomConfigurationStatusEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import cn from '@/utils/classnames'
|
||||
import { noop } from 'lodash-es'
|
||||
import { useDocLink } from '@/context/i18n'
|
||||
|
||||
const systemTypes = ['openai_moderation', 'keywords', 'api']
|
||||
|
||||
@ -46,6 +47,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const { notify } = useToastContext()
|
||||
const { locale } = useContext(I18n)
|
||||
const { data: modelProviders, isLoading, mutate } = useSWR('/workspaces/current/model-providers', fetchModelProviders)
|
||||
@ -316,7 +318,7 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div className='flex h-9 items-center justify-between'>
|
||||
<div className='text-sm font-medium text-text-primary'>{t('common.apiBasedExtension.selector.title')}</div>
|
||||
<a
|
||||
href={t('common.apiBasedExtension.linkUrl') || '/'}
|
||||
href={docLink('/guides/extension/api-based-extension/README')}
|
||||
target='_blank' rel='noopener noreferrer'
|
||||
className='group flex items-center text-xs text-text-tertiary hover:text-primary-600'
|
||||
>
|
||||
|
||||
@ -231,7 +231,7 @@ export const useFile = (fileConfig: FileUpload) => {
|
||||
url: res.url,
|
||||
}
|
||||
if (!isAllowedFileExtension(res.name, res.mime_type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
notify({ type: 'error', message: `${t('common.fileUploader.fileExtensionNotSupport')} ${file.type}` })
|
||||
handleRemoveFile(uploadingFile.id)
|
||||
}
|
||||
if (!checkSizeLimit(newFile.supportFileType, newFile.size))
|
||||
@ -257,7 +257,7 @@ export const useFile = (fileConfig: FileUpload) => {
|
||||
|
||||
const handleLocalFileUpload = useCallback((file: File) => {
|
||||
if (!isAllowedFileExtension(file.name, file.type, fileConfig.allowed_file_types || [], fileConfig.allowed_file_extensions || [])) {
|
||||
notify({ type: 'error', message: t('common.fileUploader.fileExtensionNotSupport') })
|
||||
notify({ type: 'error', message: `${t('common.fileUploader.fileExtensionNotSupport')} ${file.type}` })
|
||||
return
|
||||
}
|
||||
const allowedFileTypes = fileConfig.allowed_file_types
|
||||
|
||||
@ -22,7 +22,7 @@ import { FILE_EXTS } from '../prompt-editor/constants'
|
||||
jest.mock('mime', () => ({
|
||||
__esModule: true,
|
||||
default: {
|
||||
getExtension: jest.fn(),
|
||||
getAllExtensions: jest.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
@ -58,12 +58,27 @@ describe('file-uploader utils', () => {
|
||||
|
||||
describe('getFileExtension', () => {
|
||||
it('should get extension from mimetype', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('pdf')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileExtension('file', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype and file name 1', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileExtension('file.pdf', 'application/pdf')).toBe('pdf')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates with filename hint', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file.pem', 'application/x-x509-ca-cert')).toBe('pem')
|
||||
})
|
||||
|
||||
it('should get extension from mimetype with multiple ext candidates without filename hint', () => {
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['der', 'crt', 'pem']))
|
||||
expect(getFileExtension('file', 'application/x-x509-ca-cert')).toBe('der')
|
||||
})
|
||||
|
||||
it('should get extension from filename if mimetype fails', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue(null)
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileExtension('file.txt', '')).toBe('txt')
|
||||
expect(getFileExtension('file.txt.docx', '')).toBe('docx')
|
||||
expect(getFileExtension('file', '')).toBe('')
|
||||
@ -76,157 +91,157 @@ describe('file-uploader utils', () => {
|
||||
|
||||
describe('getFileAppearanceType', () => {
|
||||
it('should identify gif files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('gif')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['gif']))
|
||||
expect(getFileAppearanceType('image.gif', 'image/gif'))
|
||||
.toBe(FileAppearanceTypeEnum.gif)
|
||||
})
|
||||
|
||||
it('should identify image files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('jpg')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpg']))
|
||||
expect(getFileAppearanceType('image.jpg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('jpeg')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['jpeg']))
|
||||
expect(getFileAppearanceType('image.jpeg', 'image/jpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('png')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['png']))
|
||||
expect(getFileAppearanceType('image.png', 'image/png'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('webp')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webp']))
|
||||
expect(getFileAppearanceType('image.webp', 'image/webp'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('svg')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['svg']))
|
||||
expect(getFileAppearanceType('image.svg', 'image/svgxml'))
|
||||
.toBe(FileAppearanceTypeEnum.image)
|
||||
})
|
||||
|
||||
it('should identify video files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mp4')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp4']))
|
||||
expect(getFileAppearanceType('video.mp4', 'video/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mov')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mov']))
|
||||
expect(getFileAppearanceType('video.mov', 'video/quicktime'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mpeg')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpeg']))
|
||||
expect(getFileAppearanceType('video.mpeg', 'video/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('webm')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['webm']))
|
||||
expect(getFileAppearanceType('video.web', 'video/webm'))
|
||||
.toBe(FileAppearanceTypeEnum.video)
|
||||
})
|
||||
|
||||
it('should identify audio files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mp3')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mp3']))
|
||||
expect(getFileAppearanceType('audio.mp3', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('m4a')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['m4a']))
|
||||
expect(getFileAppearanceType('audio.m4a', 'audio/mp4'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('wav')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['wav']))
|
||||
expect(getFileAppearanceType('audio.wav', 'audio/vnd.wav'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('amr')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['amr']))
|
||||
expect(getFileAppearanceType('audio.amr', 'audio/AMR'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mpga')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mpga']))
|
||||
expect(getFileAppearanceType('audio.mpga', 'audio/mpeg'))
|
||||
.toBe(FileAppearanceTypeEnum.audio)
|
||||
})
|
||||
|
||||
it('should identify code files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('html')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['html']))
|
||||
expect(getFileAppearanceType('index.html', 'text/html'))
|
||||
.toBe(FileAppearanceTypeEnum.code)
|
||||
})
|
||||
|
||||
it('should identify PDF files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('pdf')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(getFileAppearanceType('doc.pdf', 'application/pdf'))
|
||||
.toBe(FileAppearanceTypeEnum.pdf)
|
||||
})
|
||||
|
||||
it('should identify markdown files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('md')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['md']))
|
||||
expect(getFileAppearanceType('file.md', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('markdown')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['markdown']))
|
||||
expect(getFileAppearanceType('file.markdown', 'text/markdown'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('mdx')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['mdx']))
|
||||
expect(getFileAppearanceType('file.mdx', 'text/mdx'))
|
||||
.toBe(FileAppearanceTypeEnum.markdown)
|
||||
})
|
||||
|
||||
it('should identify excel files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('xlsx')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xlsx']))
|
||||
expect(getFileAppearanceType('doc.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('xls')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xls']))
|
||||
expect(getFileAppearanceType('doc.xls', 'application/vnd.ms-excel'))
|
||||
.toBe(FileAppearanceTypeEnum.excel)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('doc')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['doc']))
|
||||
expect(getFileAppearanceType('doc.doc', 'application/msword'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('docx')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['docx']))
|
||||
expect(getFileAppearanceType('doc.docx', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'))
|
||||
.toBe(FileAppearanceTypeEnum.word)
|
||||
})
|
||||
|
||||
it('should identify word files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('ppt')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['ppt']))
|
||||
expect(getFileAppearanceType('doc.ppt', 'application/vnd.ms-powerpoint'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('pptx')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pptx']))
|
||||
expect(getFileAppearanceType('doc.pptx', 'application/vnd.openxmlformats-officedocument.presentationml.presentation'))
|
||||
.toBe(FileAppearanceTypeEnum.ppt)
|
||||
})
|
||||
|
||||
it('should identify document files', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('txt')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['txt']))
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('csv')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['csv']))
|
||||
expect(getFileAppearanceType('file.csv', 'text/csv'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('msg')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['msg']))
|
||||
expect(getFileAppearanceType('file.msg', 'application/vnd.ms-outlook'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('eml')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['eml']))
|
||||
expect(getFileAppearanceType('file.eml', 'message/rfc822'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('xml')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['xml']))
|
||||
expect(getFileAppearanceType('file.xml', 'application/rssxml'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
|
||||
jest.mocked(mime.getExtension).mockReturnValue('epub')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['epub']))
|
||||
expect(getFileAppearanceType('file.epub', 'application/epubzip'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
|
||||
it('should handle null mime extension', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue(null)
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(null)
|
||||
expect(getFileAppearanceType('file.txt', 'text/plain'))
|
||||
.toBe(FileAppearanceTypeEnum.document)
|
||||
})
|
||||
@ -360,7 +375,7 @@ describe('file-uploader utils', () => {
|
||||
|
||||
describe('isAllowedFileExtension', () => {
|
||||
it('should validate allowed file extensions', () => {
|
||||
jest.mocked(mime.getExtension).mockReturnValue('pdf')
|
||||
jest.mocked(mime.getAllExtensions).mockReturnValue(new Set(['pdf']))
|
||||
expect(isAllowedFileExtension(
|
||||
'test.pdf',
|
||||
'application/pdf',
|
||||
|
||||
@ -42,19 +42,38 @@ export const fileUpload: FileUpload = ({
|
||||
})
|
||||
}
|
||||
|
||||
const additionalExtensionMap = new Map<string, string[]>([
|
||||
['text/x-markdown', ['md']],
|
||||
])
|
||||
|
||||
export const getFileExtension = (fileName: string, fileMimetype: string, isRemote?: boolean) => {
|
||||
let extension = ''
|
||||
if (fileMimetype)
|
||||
extension = mime.getExtension(fileMimetype) || ''
|
||||
let extensions = new Set<string>()
|
||||
if (fileMimetype) {
|
||||
const extensionsFromMimeType = mime.getAllExtensions(fileMimetype) || new Set<string>()
|
||||
const additionalExtensions = additionalExtensionMap.get(fileMimetype) || []
|
||||
extensions = new Set<string>([
|
||||
...extensionsFromMimeType,
|
||||
...additionalExtensions,
|
||||
])
|
||||
}
|
||||
|
||||
if (fileName && !extension) {
|
||||
let extensionInFileName = ''
|
||||
if (fileName) {
|
||||
const fileNamePair = fileName.split('.')
|
||||
const fileNamePairLength = fileNamePair.length
|
||||
|
||||
if (fileNamePairLength > 1)
|
||||
extension = fileNamePair[fileNamePairLength - 1]
|
||||
if (fileNamePairLength > 1) {
|
||||
extensionInFileName = fileNamePair[fileNamePairLength - 1].toLowerCase()
|
||||
if (extensions.has(extensionInFileName))
|
||||
extension = extensionInFileName
|
||||
}
|
||||
}
|
||||
if (!extension) {
|
||||
if (extensions.size > 0)
|
||||
extension = extensions.values().next().value.toLowerCase()
|
||||
else
|
||||
extension = ''
|
||||
extension = extensionInFileName
|
||||
}
|
||||
|
||||
if (isRemote)
|
||||
|
||||
@ -0,0 +1,9 @@
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="Icon L">
|
||||
<g id="Vector">
|
||||
<path d="M2.66602 11.3333H0.666016L3.33268 8.66667L5.99935 11.3333H3.99935L3.99935 14H2.66602L2.66602 11.3333Z" fill="#354052"/>
|
||||
<path d="M2.66602 4.66667L2.66602 2L3.99935 2L3.99935 4.66667L5.99935 4.66667L3.33268 7.33333L0.666016 4.66667L2.66602 4.66667Z" fill="#354052"/>
|
||||
<path d="M7.33268 2.66667H13.9993V4H7.33268V2.66667ZM7.33268 12H13.9993V13.3333H7.33268V12ZM5.99935 7.33333H13.9993V8.66667H5.99935V7.33333Z" fill="#354052"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 579 B |
37
web/app/components/base/icons/src/public/llm/OpenaiTeal.json
Normal file
37
web/app/components/base/icons/src/public/llm/OpenaiTeal.json
Normal file
File diff suppressed because one or more lines are too long
20
web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx
Normal file
20
web/app/components/base/icons/src/public/llm/OpenaiTeal.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './OpenaiTeal.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiTeal'
|
||||
|
||||
export default Icon
|
||||
File diff suppressed because one or more lines are too long
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './OpenaiYellow.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'OpenaiYellow'
|
||||
|
||||
export default Icon
|
||||
@ -28,9 +28,11 @@ export { default as Microsoft } from './Microsoft'
|
||||
export { default as OpenaiBlack } from './OpenaiBlack'
|
||||
export { default as OpenaiBlue } from './OpenaiBlue'
|
||||
export { default as OpenaiGreen } from './OpenaiGreen'
|
||||
export { default as OpenaiTeal } from './OpenaiTeal'
|
||||
export { default as OpenaiText } from './OpenaiText'
|
||||
export { default as OpenaiTransparent } from './OpenaiTransparent'
|
||||
export { default as OpenaiViolet } from './OpenaiViolet'
|
||||
export { default as OpenaiYellow } from './OpenaiYellow'
|
||||
export { default as OpenllmText } from './OpenllmText'
|
||||
export { default as Openllm } from './Openllm'
|
||||
export { default as ReplicateText } from './ReplicateText'
|
||||
|
||||
@ -0,0 +1,62 @@
|
||||
{
|
||||
"icon": {
|
||||
"type": "element",
|
||||
"isRootNode": true,
|
||||
"name": "svg",
|
||||
"attributes": {
|
||||
"width": "16",
|
||||
"height": "16",
|
||||
"viewBox": "0 0 16 16",
|
||||
"fill": "none",
|
||||
"xmlns": "http://www.w3.org/2000/svg"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Icon L"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "g",
|
||||
"attributes": {
|
||||
"id": "Vector"
|
||||
},
|
||||
"children": [
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.66602 11.3333H0.666016L3.33268 8.66667L5.99935 11.3333H3.99935L3.99935 14H2.66602L2.66602 11.3333Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M2.66602 4.66667L2.66602 2L3.99935 2L3.99935 4.66667L5.99935 4.66667L3.33268 7.33333L0.666016 4.66667L2.66602 4.66667Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
},
|
||||
{
|
||||
"type": "element",
|
||||
"name": "path",
|
||||
"attributes": {
|
||||
"d": "M7.33268 2.66667H13.9993V4H7.33268V2.66667ZM7.33268 12H13.9993V13.3333H7.33268V12ZM5.99935 7.33333H13.9993V8.66667H5.99935V7.33333Z",
|
||||
"fill": "currentColor"
|
||||
},
|
||||
"children": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"name": "Collapse"
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
// GENERATE BY script
|
||||
// DON NOT EDIT IT MANUALLY
|
||||
|
||||
import * as React from 'react'
|
||||
import data from './Collapse.json'
|
||||
import IconBase from '@/app/components/base/icons/IconBase'
|
||||
import type { IconData } from '@/app/components/base/icons/IconBase'
|
||||
|
||||
const Icon = (
|
||||
{
|
||||
ref,
|
||||
...props
|
||||
}: React.SVGProps<SVGSVGElement> & {
|
||||
ref?: React.RefObject<React.MutableRefObject<HTMLOrSVGElement>>;
|
||||
},
|
||||
) => <IconBase {...props} ref={ref} data={data as IconData} />
|
||||
|
||||
Icon.displayName = 'Collapse'
|
||||
|
||||
export default Icon
|
||||
@ -1,5 +1,6 @@
|
||||
export { default as AlignLeft } from './AlignLeft'
|
||||
export { default as BezierCurve03 } from './BezierCurve03'
|
||||
export { default as Collapse } from './Collapse'
|
||||
export { default as Colors } from './Colors'
|
||||
export { default as ImageIndentLeft } from './ImageIndentLeft'
|
||||
export { default as LeftIndent02 } from './LeftIndent02'
|
||||
|
||||
44
web/app/components/base/logo/dify-logo.tsx
Normal file
44
web/app/components/base/logo/dify-logo.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import classNames from '@/utils/classnames'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { basePath } from '@/utils/var'
|
||||
export type LogoStyle = 'default' | 'monochromeWhite'
|
||||
|
||||
export const logoPathMap: Record<LogoStyle, string> = {
|
||||
default: '/logo/logo.svg',
|
||||
monochromeWhite: '/logo/logo-monochrome-white.svg',
|
||||
}
|
||||
|
||||
export type LogoSize = 'large' | 'medium' | 'small'
|
||||
|
||||
export const logoSizeMap: Record<LogoSize, string> = {
|
||||
large: 'w-16 h-7',
|
||||
medium: 'w-12 h-[22px]',
|
||||
small: 'w-9 h-4',
|
||||
}
|
||||
|
||||
type DifyLogoProps = {
|
||||
style?: LogoStyle
|
||||
size?: LogoSize
|
||||
className?: string
|
||||
}
|
||||
|
||||
const DifyLogo: FC<DifyLogoProps> = ({
|
||||
style = 'default',
|
||||
size = 'medium',
|
||||
className,
|
||||
}) => {
|
||||
const { theme } = useTheme()
|
||||
const themedStyle = (theme === 'dark' && style === 'default') ? 'monochromeWhite' : style
|
||||
|
||||
return (
|
||||
<img
|
||||
src={`${basePath}${logoPathMap[themedStyle]}`}
|
||||
className={classNames('block object-contain', logoSizeMap[size], className)}
|
||||
alt='Dify logo'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DifyLogo
|
||||
21
web/app/components/base/markdown-blocks/audio-block.tsx
Normal file
21
web/app/components/base/markdown-blocks/audio-block.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview AudioBlock component for rendering audio elements in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the AudioGallery component to display audio players.
|
||||
*/
|
||||
import React, { memo } from 'react'
|
||||
import AudioGallery from '@/app/components/base/audio-gallery'
|
||||
|
||||
const AudioBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0) {
|
||||
const src = node.properties?.src
|
||||
if (src)
|
||||
return <AudioGallery key={src} srcs={[src]} />
|
||||
return null
|
||||
}
|
||||
return <AudioGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
AudioBlock.displayName = 'AudioBlock'
|
||||
|
||||
export default AudioBlock
|
||||
@ -1,7 +1,7 @@
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import Button from '@/app/components/base/button'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
import { isValidUrl } from './utils'
|
||||
const MarkdownButton = ({ node }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
const variant = node.properties.dataVariant
|
||||
@ -9,25 +9,17 @@ const MarkdownButton = ({ node }: any) => {
|
||||
const link = node.properties.dataLink
|
||||
const size = node.properties.dataSize
|
||||
|
||||
function is_valid_url(url: string): boolean {
|
||||
try {
|
||||
const parsed_url = new URL(url)
|
||||
return ['http:', 'https:'].includes(parsed_url.protocol)
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return <Button
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn('!h-8 select-none !px-3')}
|
||||
className={cn('!h-auto min-h-8 select-none whitespace-normal !px-3')}
|
||||
onClick={() => {
|
||||
if (is_valid_url(link)) {
|
||||
if (link && isValidUrl(link)) {
|
||||
window.open(link, '_blank')
|
||||
return
|
||||
}
|
||||
if(!message)
|
||||
return
|
||||
onSend?.(message)
|
||||
}}
|
||||
>
|
||||
|
||||
441
web/app/components/base/markdown-blocks/code-block.tsx
Normal file
441
web/app/components/base/markdown-blocks/code-block.tsx
Normal file
@ -0,0 +1,441 @@
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import {
|
||||
atelierHeathDark,
|
||||
atelierHeathLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import SVGBtn from '@/app/components/base/svg'
|
||||
import Flowchart from '@/app/components/base/mermaid'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import SVGRenderer from '../svg-gallery' // Assumes svg-gallery.tsx is in /base directory
|
||||
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
|
||||
import ErrorBoundary from '@/app/components/base/markdown/error-boundary'
|
||||
|
||||
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
|
||||
const capitalizationLanguageNameMap: Record<string, string> = {
|
||||
sql: 'SQL',
|
||||
javascript: 'JavaScript',
|
||||
java: 'Java',
|
||||
typescript: 'TypeScript',
|
||||
vbscript: 'VBScript',
|
||||
css: 'CSS',
|
||||
html: 'HTML',
|
||||
xml: 'XML',
|
||||
php: 'PHP',
|
||||
python: 'Python',
|
||||
yaml: 'Yaml',
|
||||
mermaid: 'Mermaid',
|
||||
markdown: 'MarkDown',
|
||||
makefile: 'MakeFile',
|
||||
echarts: 'ECharts',
|
||||
shell: 'Shell',
|
||||
powershell: 'PowerShell',
|
||||
json: 'JSON',
|
||||
latex: 'Latex',
|
||||
svg: 'SVG',
|
||||
abc: 'ABC',
|
||||
}
|
||||
const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||
if (!language)
|
||||
return 'Plain'
|
||||
|
||||
if (language in capitalizationLanguageNameMap)
|
||||
return capitalizationLanguageNameMap[language]
|
||||
|
||||
return language.charAt(0).toUpperCase() + language.substring(1)
|
||||
}
|
||||
|
||||
// **Add code block
|
||||
// Avoid error #185 (Maximum update depth exceeded.
|
||||
// This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
|
||||
// React limits the number of nested updates to prevent infinite loops.)
|
||||
// Reference A: https://reactjs.org/docs/error-decoder.html?invariant=185
|
||||
// Reference B1: https://react.dev/reference/react/memo
|
||||
// Reference B2: https://react.dev/reference/react/useMemo
|
||||
// ****
|
||||
// The original error that occurred in the streaming response during the conversation:
|
||||
// Error: Minified React error 185;
|
||||
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
|
||||
// or use the non-minified dev environment for full errors and additional helpful warnings.
|
||||
|
||||
// Define ECharts event parameter types
|
||||
interface EChartsEventParams {
|
||||
type: string;
|
||||
seriesIndex?: number;
|
||||
dataIndex?: number;
|
||||
name?: string;
|
||||
value?: any;
|
||||
currentIndex?: number; // Added for timeline events
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
|
||||
const { theme } = useTheme()
|
||||
const [isSVG, setIsSVG] = useState(true)
|
||||
const [chartState, setChartState] = useState<'loading' | 'success' | 'error'>('loading')
|
||||
const [finalChartOption, setFinalChartOption] = useState<any>(null)
|
||||
const echartsRef = useRef<any>(null)
|
||||
const contentRef = useRef<string>('')
|
||||
const processedRef = useRef<boolean>(false) // Track if content was successfully processed
|
||||
const instanceIdRef = useRef<string>(`chart-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`) // Unique ID for logging
|
||||
const isInitialRenderRef = useRef<boolean>(true) // Track if this is initial render
|
||||
const chartInstanceRef = useRef<any>(null) // Direct reference to ECharts instance
|
||||
const resizeTimerRef = useRef<NodeJS.Timeout | null>(null) // For debounce handling
|
||||
const finishedEventCountRef = useRef<number>(0) // Track finished event trigger count
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match?.[1]
|
||||
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
|
||||
const isDarkMode = theme === Theme.dark
|
||||
|
||||
const echartsStyle = useMemo(() => ({
|
||||
height: '350px',
|
||||
width: '100%',
|
||||
}), [])
|
||||
|
||||
const echartsOpts = useMemo(() => ({
|
||||
renderer: 'canvas',
|
||||
width: 'auto',
|
||||
}) as any, [])
|
||||
|
||||
// Debounce resize operations
|
||||
const debouncedResize = useCallback(() => {
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
resizeTimerRef.current = null
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
// Handle ECharts instance initialization
|
||||
const handleChartReady = useCallback((instance: any) => {
|
||||
chartInstanceRef.current = instance
|
||||
|
||||
// Force resize to ensure timeline displays correctly
|
||||
setTimeout(() => {
|
||||
if (chartInstanceRef.current)
|
||||
chartInstanceRef.current.resize()
|
||||
}, 200)
|
||||
}, [])
|
||||
|
||||
// Store event handlers in useMemo to avoid recreating them
|
||||
const echartsEvents = useMemo(() => ({
|
||||
finished: (params: EChartsEventParams) => {
|
||||
// Limit finished event frequency to avoid infinite loops
|
||||
finishedEventCountRef.current++
|
||||
if (finishedEventCountRef.current > 3) {
|
||||
// Stop processing after 3 times to avoid infinite loops
|
||||
return
|
||||
}
|
||||
|
||||
if (chartInstanceRef.current) {
|
||||
// Use debounced resize
|
||||
debouncedResize()
|
||||
}
|
||||
},
|
||||
}), [debouncedResize])
|
||||
|
||||
// Handle container resize for echarts
|
||||
useEffect(() => {
|
||||
if (language !== 'echarts' || !chartInstanceRef.current) return
|
||||
|
||||
const handleResize = () => {
|
||||
if (chartInstanceRef.current)
|
||||
// Use debounced resize
|
||||
debouncedResize()
|
||||
}
|
||||
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
if (resizeTimerRef.current)
|
||||
clearTimeout(resizeTimerRef.current)
|
||||
}
|
||||
}, [language, debouncedResize])
|
||||
// Process chart data when content changes
|
||||
useEffect(() => {
|
||||
// Only process echarts content
|
||||
if (language !== 'echarts') return
|
||||
|
||||
// Reset state when new content is detected
|
||||
if (!contentRef.current) {
|
||||
setChartState('loading')
|
||||
processedRef.current = false
|
||||
}
|
||||
|
||||
const newContent = String(children).replace(/\n$/, '')
|
||||
|
||||
// Skip if content hasn't changed
|
||||
if (contentRef.current === newContent) return
|
||||
contentRef.current = newContent
|
||||
|
||||
const trimmedContent = newContent.trim()
|
||||
if (!trimmedContent) return
|
||||
|
||||
// Detect if this is historical data (already complete)
|
||||
// Historical data typically comes as a complete code block with complete JSON
|
||||
const isCompleteJson
|
||||
= (trimmedContent.startsWith('{') && trimmedContent.endsWith('}')
|
||||
&& trimmedContent.split('{').length === trimmedContent.split('}').length)
|
||||
|| (trimmedContent.startsWith('[') && trimmedContent.endsWith(']')
|
||||
&& trimmedContent.split('[').length === trimmedContent.split(']').length)
|
||||
|
||||
// If the JSON structure looks complete, try to parse it right away
|
||||
if (isCompleteJson && !processedRef.current) {
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setFinalChartOption(parsed)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// If we have a complete JSON structure but it doesn't parse,
|
||||
// it's likely an error rather than incomplete data
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, either the JSON isn't complete yet, or we failed to parse it
|
||||
// Check more conditions for streaming data
|
||||
const isIncomplete
|
||||
= trimmedContent.length < 5
|
||||
|| (trimmedContent.startsWith('{')
|
||||
&& (!trimmedContent.endsWith('}')
|
||||
|| trimmedContent.split('{').length !== trimmedContent.split('}').length))
|
||||
|| (trimmedContent.startsWith('[')
|
||||
&& (!trimmedContent.endsWith(']')
|
||||
|| trimmedContent.split('[').length !== trimmedContent.split('}').length))
|
||||
|| (trimmedContent.split('"').length % 2 !== 1)
|
||||
|| (trimmedContent.includes('{"') && !trimmedContent.includes('"}'))
|
||||
|
||||
// Only try to parse streaming data if it looks complete and hasn't been processed
|
||||
if (!isIncomplete && !processedRef.current) {
|
||||
let isValidOption = false
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmedContent)
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
setFinalChartOption(parsed)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
try {
|
||||
// eslint-disable-next-line no-new-func, sonarjs/code-eval
|
||||
const result = new Function(`return ${trimmedContent}`)()
|
||||
if (typeof result === 'object' && result !== null) {
|
||||
setFinalChartOption(result)
|
||||
isValidOption = true
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// Both parsing methods failed, but content looks complete
|
||||
setChartState('error')
|
||||
processedRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isValidOption) {
|
||||
setChartState('success')
|
||||
processedRef.current = true
|
||||
}
|
||||
}
|
||||
}, [language, children])
|
||||
|
||||
// Cache rendered content to avoid unnecessary re-renders
|
||||
const renderCodeContent = useMemo(() => {
|
||||
const content = String(children).replace(/\n$/, '')
|
||||
switch (language) {
|
||||
case 'mermaid':
|
||||
return <Flowchart PrimitiveCode={content} theme={theme as 'light' | 'dark'} />
|
||||
case 'echarts': {
|
||||
// Loading state: show loading indicator
|
||||
if (chartState === 'loading') {
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: isDarkMode ? 'var(--color-components-input-bg-normal)' : 'transparent',
|
||||
color: 'var(--color-text-secondary)',
|
||||
}}>
|
||||
<div style={{
|
||||
marginBottom: '12px',
|
||||
width: '24px',
|
||||
height: '24px',
|
||||
}}>
|
||||
{/* Rotating spinner that works in both light and dark modes */}
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ animation: 'spin 1.5s linear infinite' }}>
|
||||
<style>
|
||||
{`
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<circle opacity="0.2" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" />
|
||||
<path d="M12 2C6.47715 2 2 6.47715 2 12" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
<div style={{
|
||||
fontFamily: 'var(--font-family)',
|
||||
fontSize: '14px',
|
||||
}}>Chart loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Success state: show the chart
|
||||
if (chartState === 'success' && finalChartOption) {
|
||||
// Reset finished event counter
|
||||
finishedEventCountRef.current = 0
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minWidth: '300px',
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
transition: 'background-color 0.3s ease',
|
||||
}}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts
|
||||
ref={(e) => {
|
||||
if (e && isInitialRenderRef.current) {
|
||||
echartsRef.current = e
|
||||
isInitialRenderRef.current = false
|
||||
}
|
||||
}}
|
||||
option={finalChartOption}
|
||||
style={echartsStyle}
|
||||
theme={isDarkMode ? 'dark' : undefined}
|
||||
opts={echartsOpts}
|
||||
notMerge={false}
|
||||
lazyUpdate={false}
|
||||
onEvents={echartsEvents}
|
||||
onChartReady={handleChartReady}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state: show error message
|
||||
const errorOption = {
|
||||
title: {
|
||||
text: 'ECharts error - Wrong option.',
|
||||
},
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minWidth: '300px',
|
||||
minHeight: '350px',
|
||||
width: '100%',
|
||||
overflowX: 'auto',
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
transition: 'background-color 0.3s ease',
|
||||
}}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts
|
||||
ref={echartsRef}
|
||||
option={errorOption}
|
||||
style={echartsStyle}
|
||||
theme={isDarkMode ? 'dark' : undefined}
|
||||
opts={echartsOpts}
|
||||
notMerge={true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
case 'svg':
|
||||
if (isSVG) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SVGRenderer content={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'abc':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MarkdownMusic children={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
}, [children, language, isSVG, finalChartOption, props, theme, match, chartState, isDarkMode, echartsStyle, echartsOpts, handleChartReady, echartsEvents])
|
||||
|
||||
if (inline || !match)
|
||||
return <code {...props} className={className}>{children}</code>
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'>
|
||||
<div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{language === 'svg' && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
||||
<ActionButton>
|
||||
<CopyIcon content={String(children).replace(/\n$/, '')} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{renderCodeContent}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
export default CodeBlock
|
||||
13
web/app/components/base/markdown-blocks/img.tsx
Normal file
13
web/app/components/base/markdown-blocks/img.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Img component for rendering <img> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the ImageGallery component to display images.
|
||||
*/
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
const Img = ({ src }: any) => {
|
||||
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
|
||||
}
|
||||
|
||||
export default Img
|
||||
18
web/app/components/base/markdown-blocks/index.ts
Normal file
18
web/app/components/base/markdown-blocks/index.ts
Normal file
@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @fileoverview Barrel file for all markdown block components.
|
||||
* This allows for cleaner imports in other parts of the application.
|
||||
*/
|
||||
|
||||
export { default as AudioBlock } from './audio-block'
|
||||
export { default as CodeBlock } from './code-block'
|
||||
export { default as Img } from './img'
|
||||
export { default as Link } from './link'
|
||||
export { default as Paragraph } from './paragraph'
|
||||
export { default as PreCode } from './pre-code'
|
||||
export { default as ScriptBlock } from './script-block'
|
||||
export { default as VideoBlock } from './video-block'
|
||||
|
||||
// Assuming these are also standalone components in this directory intended for Markdown rendering
|
||||
export { default as MarkdownButton } from './button'
|
||||
export { default as MarkdownForm } from './form'
|
||||
export { default as ThinkBlock } from './think-block'
|
||||
26
web/app/components/base/markdown-blocks/link.tsx
Normal file
26
web/app/components/base/markdown-blocks/link.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @fileoverview Link component for rendering <a> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for "abbr:" type links for interactive chat actions.
|
||||
*/
|
||||
import React from 'react'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import { isValidUrl } from './utils'
|
||||
|
||||
const Link = ({ node, children, ...props }: any) => {
|
||||
const { onSend } = useChatContext()
|
||||
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
|
||||
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
|
||||
|
||||
return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr>
|
||||
}
|
||||
else {
|
||||
const href = props.href || node.properties?.href
|
||||
if(!href || !isValidUrl(href))
|
||||
return <span>{children}</span>
|
||||
|
||||
return <a href={href} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a>
|
||||
}
|
||||
}
|
||||
|
||||
export default Link
|
||||
27
web/app/components/base/markdown-blocks/paragraph.tsx
Normal file
27
web/app/components/base/markdown-blocks/paragraph.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @fileoverview Paragraph component for rendering <p> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Handles special rendering for paragraphs that directly contain an image.
|
||||
*/
|
||||
import React from 'react'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
|
||||
const Paragraph = (paragraph: any) => {
|
||||
const { node }: any = paragraph
|
||||
const children_node = node.children
|
||||
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[children_node[0].properties.src]} />
|
||||
{
|
||||
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
|
||||
<div className="mt-2">{paragraph.children.slice(1)}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{paragraph.children}</p>
|
||||
}
|
||||
|
||||
export default Paragraph
|
||||
21
web/app/components/base/markdown-blocks/pre-code.tsx
Normal file
21
web/app/components/base/markdown-blocks/pre-code.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview PreCode component for rendering <pre> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* This is a simple wrapper around the HTML <pre> element.
|
||||
*/
|
||||
import React, { useRef } from 'react'
|
||||
|
||||
function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null)
|
||||
|
||||
return (
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
></span>
|
||||
{props.children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export default PreCode
|
||||
15
web/app/components/base/markdown-blocks/script-block.tsx
Normal file
15
web/app/components/base/markdown-blocks/script-block.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
/**
|
||||
* @fileoverview ScriptBlock component for handling <script> tags in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Note: Current implementation returns the script tag as a string, which might not execute as expected in React.
|
||||
* This behavior is preserved from the original implementation and may need review for security and functionality.
|
||||
*/
|
||||
import { memo } from 'react'
|
||||
|
||||
const ScriptBlock = memo(({ node }: any) => {
|
||||
const scriptContent = node.children[0]?.value || ''
|
||||
return `<script>${scriptContent}</script>`
|
||||
})
|
||||
ScriptBlock.displayName = 'ScriptBlock'
|
||||
|
||||
export default ScriptBlock
|
||||
@ -41,9 +41,10 @@ const useThinkTimer = (children: any) => {
|
||||
const timerRef = useRef<NodeJS.Timeout>()
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) return
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
if (!isComplete)
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10)
|
||||
setElapsedTime(Math.floor((Date.now() - startTime) / 100) / 10)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
@ -53,11 +54,8 @@ const useThinkTimer = (children: any) => {
|
||||
}, [startTime, isComplete])
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEndThink(children)) {
|
||||
if (hasEndThink(children))
|
||||
setIsComplete(true)
|
||||
if (timerRef.current)
|
||||
clearInterval(timerRef.current)
|
||||
}
|
||||
}, [children])
|
||||
|
||||
return { elapsedTime, isComplete }
|
||||
@ -73,7 +71,7 @@ export const ThinkBlock = ({ children, ...props }: any) => {
|
||||
|
||||
return (
|
||||
<details {...(!isComplete && { open: true })} className="group">
|
||||
<summary className="flex cursor-pointer select-none list-none items-center whitespace-nowrap pl-2 font-bold text-gray-500">
|
||||
<summary className="flex cursor-pointer select-none list-none items-center whitespace-nowrap pl-2 font-bold text-text-secondary">
|
||||
<div className="flex shrink-0 items-center">
|
||||
<svg
|
||||
className="mr-2 h-3 w-3 transition-transform duration-500 group-open:rotate-90"
|
||||
@ -91,7 +89,7 @@ export const ThinkBlock = ({ children, ...props }: any) => {
|
||||
{isComplete ? `${t('common.chat.thought')}(${elapsedTime.toFixed(1)}s)` : `${t('common.chat.thinking')}(${elapsedTime.toFixed(1)}s)`}
|
||||
</div>
|
||||
</summary>
|
||||
<div className="ml-2 border-l border-gray-300 bg-gray-50 p-3 text-gray-500">
|
||||
<div className="ml-2 border-l border-components-panel-border bg-components-panel-bg-alt p-3 text-text-secondary">
|
||||
{displayContent}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
3
web/app/components/base/markdown-blocks/utils.ts
Normal file
3
web/app/components/base/markdown-blocks/utils.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export const isValidUrl = (url: string): boolean => {
|
||||
return ['http:', 'https:', '//', 'mailto:'].some(prefix => url.startsWith(prefix))
|
||||
}
|
||||
21
web/app/components/base/markdown-blocks/video-block.tsx
Normal file
21
web/app/components/base/markdown-blocks/video-block.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* @fileoverview VideoBlock component for rendering video elements in Markdown.
|
||||
* Extracted from the main markdown renderer for modularity.
|
||||
* Uses the VideoGallery component to display videos.
|
||||
*/
|
||||
import React, { memo } from 'react'
|
||||
import VideoGallery from '@/app/components/base/video-gallery'
|
||||
|
||||
const VideoBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0) {
|
||||
const src = node.properties?.src
|
||||
if (src)
|
||||
return <VideoGallery key={src} srcs={[src]} />
|
||||
return null
|
||||
}
|
||||
return <VideoGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
VideoBlock.displayName = 'VideoBlock'
|
||||
|
||||
export default VideoBlock
|
||||
@ -1,349 +0,0 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import ReactEcharts from 'echarts-for-react'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import SyntaxHighlighter from 'react-syntax-highlighter'
|
||||
import {
|
||||
atelierHeathDark,
|
||||
atelierHeathLight,
|
||||
} from 'react-syntax-highlighter/dist/esm/styles/hljs'
|
||||
import { Component, memo, useMemo, useRef, useState } from 'react'
|
||||
import { flow } from 'lodash-es'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import CopyIcon from '@/app/components/base/copy-icon'
|
||||
import SVGBtn from '@/app/components/base/svg'
|
||||
import Flowchart from '@/app/components/base/mermaid'
|
||||
import ImageGallery from '@/app/components/base/image-gallery'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import VideoGallery from '@/app/components/base/video-gallery'
|
||||
import AudioGallery from '@/app/components/base/audio-gallery'
|
||||
import MarkdownButton from '@/app/components/base/markdown-blocks/button'
|
||||
import MarkdownForm from '@/app/components/base/markdown-blocks/form'
|
||||
import MarkdownMusic from '@/app/components/base/markdown-blocks/music'
|
||||
import ThinkBlock from '@/app/components/base/markdown-blocks/think-block'
|
||||
import { Theme } from '@/types/app'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import cn from '@/utils/classnames'
|
||||
import SVGRenderer from './svg-gallery'
|
||||
|
||||
// Available language https://github.com/react-syntax-highlighter/react-syntax-highlighter/blob/master/AVAILABLE_LANGUAGES_HLJS.MD
|
||||
const capitalizationLanguageNameMap: Record<string, string> = {
|
||||
sql: 'SQL',
|
||||
javascript: 'JavaScript',
|
||||
java: 'Java',
|
||||
typescript: 'TypeScript',
|
||||
vbscript: 'VBScript',
|
||||
css: 'CSS',
|
||||
html: 'HTML',
|
||||
xml: 'XML',
|
||||
php: 'PHP',
|
||||
python: 'Python',
|
||||
yaml: 'Yaml',
|
||||
mermaid: 'Mermaid',
|
||||
markdown: 'MarkDown',
|
||||
makefile: 'MakeFile',
|
||||
echarts: 'ECharts',
|
||||
shell: 'Shell',
|
||||
powershell: 'PowerShell',
|
||||
json: 'JSON',
|
||||
latex: 'Latex',
|
||||
svg: 'SVG',
|
||||
abc: 'ABC',
|
||||
}
|
||||
const getCorrectCapitalizationLanguageName = (language: string) => {
|
||||
if (!language)
|
||||
return 'Plain'
|
||||
|
||||
if (language in capitalizationLanguageNameMap)
|
||||
return capitalizationLanguageNameMap[language]
|
||||
|
||||
return language.charAt(0).toUpperCase() + language.substring(1)
|
||||
}
|
||||
|
||||
const preprocessLaTeX = (content: string) => {
|
||||
if (typeof content !== 'string')
|
||||
return content
|
||||
|
||||
const codeBlockRegex = /```[\s\S]*?```/g
|
||||
const codeBlocks = content.match(codeBlockRegex) || []
|
||||
let processedContent = content.replace(codeBlockRegex, 'CODE_BLOCK_PLACEHOLDER')
|
||||
|
||||
processedContent = flow([
|
||||
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/\\\[(.*?)\\\]/gs, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
|
||||
])(processedContent)
|
||||
|
||||
codeBlocks.forEach((block) => {
|
||||
processedContent = processedContent.replace('CODE_BLOCK_PLACEHOLDER', block)
|
||||
})
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
const preprocessThinkTag = (content: string) => {
|
||||
const thinkOpenTagRegex = /<think>\n/g
|
||||
const thinkCloseTagRegex = /\n<\/think>/g
|
||||
return flow([
|
||||
(str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'),
|
||||
(str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'),
|
||||
])(content)
|
||||
}
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null)
|
||||
|
||||
return (
|
||||
<pre ref={ref}>
|
||||
<span
|
||||
className="copy-code-button"
|
||||
></span>
|
||||
{props.children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
// **Add code block
|
||||
// Avoid error #185 (Maximum update depth exceeded.
|
||||
// This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate.
|
||||
// React limits the number of nested updates to prevent infinite loops.)
|
||||
// Reference A: https://reactjs.org/docs/error-decoder.html?invariant=185
|
||||
// Reference B1: https://react.dev/reference/react/memo
|
||||
// Reference B2: https://react.dev/reference/react/useMemo
|
||||
// ****
|
||||
// The original error that occurred in the streaming response during the conversation:
|
||||
// Error: Minified React error 185;
|
||||
// visit https://reactjs.org/docs/error-decoder.html?invariant=185 for the full message
|
||||
// or use the non-minified dev environment for full errors and additional helpful warnings.
|
||||
|
||||
const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any) => {
|
||||
const { theme } = useTheme()
|
||||
const [isSVG, setIsSVG] = useState(true)
|
||||
const match = /language-(\w+)/.exec(className || '')
|
||||
const language = match?.[1]
|
||||
const languageShowName = getCorrectCapitalizationLanguageName(language || '')
|
||||
const chartData = useMemo(() => {
|
||||
if (language === 'echarts') {
|
||||
try {
|
||||
return JSON.parse(String(children).replace(/\n$/, ''))
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
return JSON.parse('{"title":{"text":"ECharts error - Wrong JSON format."}}')
|
||||
}, [language, children])
|
||||
|
||||
const renderCodeContent = useMemo(() => {
|
||||
const content = String(children).replace(/\n$/, '')
|
||||
switch (language) {
|
||||
case 'mermaid':
|
||||
if (isSVG)
|
||||
return <Flowchart PrimitiveCode={content} />
|
||||
break
|
||||
case 'echarts':
|
||||
return (
|
||||
<div style={{ minHeight: '350px', minWidth: '100%', overflowX: 'scroll' }}>
|
||||
<ErrorBoundary>
|
||||
<ReactEcharts option={chartData} style={{ minWidth: '700px' }} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)
|
||||
case 'svg':
|
||||
if (isSVG) {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<SVGRenderer content={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
break
|
||||
case 'abc':
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<MarkdownMusic children={content} />
|
||||
</ErrorBoundary>
|
||||
)
|
||||
default:
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
{...props}
|
||||
style={theme === Theme.light ? atelierHeathLight : atelierHeathDark}
|
||||
customStyle={{
|
||||
paddingLeft: 12,
|
||||
borderBottomLeftRadius: '10px',
|
||||
borderBottomRightRadius: '10px',
|
||||
backgroundColor: 'var(--color-components-input-bg-normal)',
|
||||
}}
|
||||
language={match?.[1]}
|
||||
showLineNumbers
|
||||
PreTag="div"
|
||||
>
|
||||
{content}
|
||||
</SyntaxHighlighter>
|
||||
)
|
||||
}
|
||||
}, [children, language, isSVG, chartData, props, theme, match])
|
||||
|
||||
if (inline || !match)
|
||||
return <code {...props} className={className}>{children}</code>
|
||||
|
||||
return (
|
||||
<div className='relative'>
|
||||
<div className='flex h-8 items-center justify-between rounded-t-[10px] border-b border-divider-subtle bg-components-input-bg-normal p-1 pl-3'>
|
||||
<div className='system-xs-semibold-uppercase text-text-secondary'>{languageShowName}</div>
|
||||
<div className='flex items-center gap-1'>
|
||||
{(['mermaid', 'svg']).includes(language!) && <SVGBtn isSVG={isSVG} setIsSVG={setIsSVG} />}
|
||||
<ActionButton>
|
||||
<CopyIcon content={String(children).replace(/\n$/, '')} />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{renderCodeContent}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
CodeBlock.displayName = 'CodeBlock'
|
||||
|
||||
const VideoBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0)
|
||||
return null
|
||||
return <VideoGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
VideoBlock.displayName = 'VideoBlock'
|
||||
|
||||
const AudioBlock: any = memo(({ node }: any) => {
|
||||
const srcs = node.children.filter((child: any) => 'properties' in child).map((child: any) => (child as any).properties.src)
|
||||
if (srcs.length === 0)
|
||||
return null
|
||||
return <AudioGallery key={srcs.join()} srcs={srcs} />
|
||||
})
|
||||
AudioBlock.displayName = 'AudioBlock'
|
||||
|
||||
const ScriptBlock = memo(({ node }: any) => {
|
||||
const scriptContent = node.children[0]?.value || ''
|
||||
return `<script>${scriptContent}</script>`
|
||||
})
|
||||
ScriptBlock.displayName = 'ScriptBlock'
|
||||
|
||||
const Paragraph = (paragraph: any) => {
|
||||
const { node }: any = paragraph
|
||||
const children_node = node.children
|
||||
if (children_node && children_node[0] && 'tagName' in children_node[0] && children_node[0].tagName === 'img') {
|
||||
return (
|
||||
<div className="markdown-img-wrapper">
|
||||
<ImageGallery srcs={[children_node[0].properties.src]} />
|
||||
{
|
||||
Array.isArray(paragraph.children) && paragraph.children.length > 1 && (
|
||||
<div className="mt-2">{paragraph.children.slice(1)}</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return <p>{paragraph.children}</p>
|
||||
}
|
||||
|
||||
const Img = ({ src }: any) => {
|
||||
return <div className="markdown-img-wrapper"><ImageGallery srcs={[src]} /></div>
|
||||
}
|
||||
|
||||
const Link = ({ node, children, ...props }: any) => {
|
||||
if (node.properties?.href && node.properties.href?.toString().startsWith('abbr')) {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const { onSend } = useChatContext()
|
||||
const hidden_text = decodeURIComponent(node.properties.href.toString().split('abbr:')[1])
|
||||
|
||||
return <abbr className="cursor-pointer underline !decoration-primary-700 decoration-dashed" onClick={() => onSend?.(hidden_text)} title={node.children[0]?.value || ''}>{node.children[0]?.value || ''}</abbr>
|
||||
}
|
||||
else {
|
||||
return <a {...props} target="_blank" className="cursor-pointer underline !decoration-primary-700 decoration-dashed">{children || 'Download'}</a>
|
||||
}
|
||||
}
|
||||
|
||||
export function Markdown(props: { content: string; className?: string; customDisallowedElements?: string[] }) {
|
||||
const latexContent = flow([
|
||||
preprocessThinkTag,
|
||||
preprocessLaTeX,
|
||||
])(props.content)
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
RemarkGfm,
|
||||
[RemarkMath, { singleDollarTextMath: false }],
|
||||
RemarkBreaks,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
RehypeRaw as any,
|
||||
// The Rehype plug-in is used to remove the ref attribute of an element
|
||||
() => {
|
||||
return (tree) => {
|
||||
const iterate = (node: any) => {
|
||||
if (node.type === 'element' && node.properties?.ref)
|
||||
delete node.properties.ref
|
||||
|
||||
if (node.type === 'element' && !/^[a-z][a-z0-9]*$/i.test(node.tagName)) {
|
||||
node.type = 'text'
|
||||
node.value = `<${node.tagName}`
|
||||
}
|
||||
|
||||
if (node.children)
|
||||
node.children.forEach(iterate)
|
||||
}
|
||||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
]}
|
||||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: Img,
|
||||
video: VideoBlock,
|
||||
audio: AudioBlock,
|
||||
a: Link,
|
||||
p: Paragraph,
|
||||
button: MarkdownButton,
|
||||
form: MarkdownForm,
|
||||
script: ScriptBlock as any,
|
||||
details: ThinkBlock,
|
||||
}}
|
||||
>
|
||||
{/* Markdown detect has problem. */}
|
||||
{latexContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// **Add an ECharts runtime error handler
|
||||
// Avoid error #7832 (Crash when ECharts accesses undefined objects)
|
||||
// This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash.
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
componentDidCatch(error: any, errorInfo: any) {
|
||||
this.setState({ hasError: true })
|
||||
console.error(error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
if (this.state.hasError)
|
||||
return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div>
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
33
web/app/components/base/markdown/error-boundary.tsx
Normal file
33
web/app/components/base/markdown/error-boundary.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @fileoverview ErrorBoundary component for React.
|
||||
* This component was extracted from the main markdown renderer.
|
||||
* It catches JavaScript errors anywhere in its child component tree,
|
||||
* logs those errors, and displays a fallback UI instead of the crashed component tree.
|
||||
* Primarily used around complex rendering logic like ECharts or SVG within Markdown.
|
||||
*/
|
||||
import React, { Component } from 'react'
|
||||
// **Add an ECharts runtime error handler
|
||||
// Avoid error #7832 (Crash when ECharts accesses undefined objects)
|
||||
// This can happen when a component attempts to access an undefined object that references an unregistered map, causing the program to crash.
|
||||
|
||||
export default class ErrorBoundary extends Component {
|
||||
constructor(props: any) {
|
||||
super(props)
|
||||
this.state = { hasError: false }
|
||||
}
|
||||
|
||||
componentDidCatch(error: any, errorInfo: any) {
|
||||
this.setState({ hasError: true })
|
||||
console.error(error, errorInfo)
|
||||
}
|
||||
|
||||
render() {
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
if (this.state.hasError)
|
||||
return <div>Oops! An error occurred. This could be due to an ECharts runtime error or invalid SVG content. <br />(see the browser console for more information)</div>
|
||||
// eslint-disable-next-line ts/ban-ts-comment
|
||||
// @ts-expect-error
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
88
web/app/components/base/markdown/index.tsx
Normal file
88
web/app/components/base/markdown/index.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import 'katex/dist/katex.min.css'
|
||||
import RemarkMath from 'remark-math'
|
||||
import RemarkBreaks from 'remark-breaks'
|
||||
import RehypeKatex from 'rehype-katex'
|
||||
import RemarkGfm from 'remark-gfm'
|
||||
import RehypeRaw from 'rehype-raw'
|
||||
import { flow } from 'lodash-es'
|
||||
import cn from '@/utils/classnames'
|
||||
import { customUrlTransform, preprocessLaTeX, preprocessThinkTag } from './markdown-utils'
|
||||
import {
|
||||
AudioBlock,
|
||||
CodeBlock,
|
||||
Img,
|
||||
Link,
|
||||
MarkdownButton,
|
||||
MarkdownForm,
|
||||
Paragraph,
|
||||
ScriptBlock,
|
||||
ThinkBlock,
|
||||
VideoBlock,
|
||||
} from '@/app/components/base/markdown-blocks'
|
||||
|
||||
/**
|
||||
* @fileoverview Main Markdown rendering component.
|
||||
* This file was refactored to extract individual block renderers and utility functions
|
||||
* into separate modules for better organization and maintainability as of [Date of refactor].
|
||||
* Further refactoring candidates (custom block components not fitting general categories)
|
||||
* are noted in their respective files if applicable.
|
||||
*/
|
||||
|
||||
export function Markdown(props: { content: string; className?: string; customDisallowedElements?: string[] }) {
|
||||
const latexContent = flow([
|
||||
preprocessThinkTag,
|
||||
preprocessLaTeX,
|
||||
])(props.content)
|
||||
|
||||
return (
|
||||
<div className={cn('markdown-body', '!text-text-primary', props.className)}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[
|
||||
RemarkGfm,
|
||||
[RemarkMath, { singleDollarTextMath: false }],
|
||||
RemarkBreaks,
|
||||
]}
|
||||
rehypePlugins={[
|
||||
RehypeKatex,
|
||||
RehypeRaw as any,
|
||||
// The Rehype plug-in is used to remove the ref attribute of an element
|
||||
() => {
|
||||
return (tree: any) => {
|
||||
const iterate = (node: any) => {
|
||||
if (node.type === 'element' && node.properties?.ref)
|
||||
delete node.properties.ref
|
||||
|
||||
if (node.type === 'element' && !/^[a-z][a-z0-9]*$/i.test(node.tagName)) {
|
||||
node.type = 'text'
|
||||
node.value = `<${node.tagName}`
|
||||
}
|
||||
|
||||
if (node.children)
|
||||
node.children.forEach(iterate)
|
||||
}
|
||||
tree.children.forEach(iterate)
|
||||
}
|
||||
},
|
||||
]}
|
||||
urlTransform={customUrlTransform}
|
||||
disallowedElements={['iframe', 'head', 'html', 'meta', 'link', 'style', 'body', ...(props.customDisallowedElements || [])]}
|
||||
components={{
|
||||
code: CodeBlock,
|
||||
img: Img,
|
||||
video: VideoBlock,
|
||||
audio: AudioBlock,
|
||||
a: Link,
|
||||
p: Paragraph,
|
||||
button: MarkdownButton,
|
||||
form: MarkdownForm,
|
||||
script: ScriptBlock as any,
|
||||
details: ThinkBlock,
|
||||
}}
|
||||
>
|
||||
{/* Markdown detect has problem. */}
|
||||
{latexContent}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
87
web/app/components/base/markdown/markdown-utils.ts
Normal file
87
web/app/components/base/markdown/markdown-utils.ts
Normal file
@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @fileoverview Utility functions for preprocessing Markdown content.
|
||||
* These functions were extracted from the main markdown renderer for better separation of concerns.
|
||||
* Includes preprocessing for LaTeX and custom "think" tags.
|
||||
*/
|
||||
import { flow } from 'lodash-es'
|
||||
|
||||
export const preprocessLaTeX = (content: string) => {
|
||||
if (typeof content !== 'string')
|
||||
return content
|
||||
|
||||
const codeBlockRegex = /```[\s\S]*?```/g
|
||||
const codeBlocks = content.match(codeBlockRegex) || []
|
||||
let processedContent = content.replace(codeBlockRegex, 'CODE_BLOCK_PLACEHOLDER')
|
||||
|
||||
processedContent = flow([
|
||||
(str: string) => str.replace(/\\\[(.*?)\\\]/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/\\\[([\s\S]*?)\\\]/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/\\\((.*?)\\\)/g, (_, equation) => `$$${equation}$$`),
|
||||
(str: string) => str.replace(/(^|[^\\])\$(.+?)\$/g, (_, prefix, equation) => `${prefix}$${equation}$`),
|
||||
])(processedContent)
|
||||
|
||||
codeBlocks.forEach((block) => {
|
||||
processedContent = processedContent.replace('CODE_BLOCK_PLACEHOLDER', block)
|
||||
})
|
||||
|
||||
return processedContent
|
||||
}
|
||||
|
||||
export const preprocessThinkTag = (content: string) => {
|
||||
const thinkOpenTagRegex = /(<think>\n)+/g
|
||||
const thinkCloseTagRegex = /\n<\/think>/g
|
||||
return flow([
|
||||
(str: string) => str.replace(thinkOpenTagRegex, '<details data-think=true>\n'),
|
||||
(str: string) => str.replace(thinkCloseTagRegex, '\n[ENDTHINKFLAG]</details>'),
|
||||
(str: string) => str.replace(/(<\/details>)(?![^\S\r\n]*[\r\n])(?![^\S\r\n]*$)/g, '$1\n'),
|
||||
])(content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a URI for use in react-markdown, ensuring security and compatibility.
|
||||
* This function is designed to work with react-markdown v9+ which has stricter
|
||||
* default URL handling.
|
||||
*
|
||||
* Behavior:
|
||||
* 1. Always allows the custom 'abbr:' protocol.
|
||||
* 2. Always allows page-local fragments (e.g., "#some-id").
|
||||
* 3. Always allows protocol-relative URLs (e.g., "//example.com/path").
|
||||
* 4. Always allows purely relative paths (e.g., "path/to/file", "/abs/path").
|
||||
* 5. Allows absolute URLs if their scheme is in a permitted list (case-insensitive):
|
||||
* 'http:', 'https:', 'mailto:', 'xmpp:', 'irc:', 'ircs:'.
|
||||
* 6. Intelligently distinguishes colons used for schemes from colons within
|
||||
* paths, query parameters, or fragments of relative-like URLs.
|
||||
* 7. Returns the original URI if allowed, otherwise returns `undefined` to
|
||||
* signal that the URI should be removed/disallowed by react-markdown.
|
||||
*/
|
||||
export const customUrlTransform = (uri: string): string | undefined => {
|
||||
const PERMITTED_SCHEME_REGEX = /^(https?|ircs?|mailto|xmpp|abbr):$/i
|
||||
|
||||
if (uri.startsWith('#'))
|
||||
return uri
|
||||
|
||||
if (uri.startsWith('//'))
|
||||
return uri
|
||||
|
||||
const colonIndex = uri.indexOf(':')
|
||||
|
||||
if (colonIndex === -1)
|
||||
return uri
|
||||
|
||||
const slashIndex = uri.indexOf('/')
|
||||
const questionMarkIndex = uri.indexOf('?')
|
||||
const hashIndex = uri.indexOf('#')
|
||||
|
||||
if (
|
||||
(slashIndex !== -1 && colonIndex > slashIndex)
|
||||
|| (questionMarkIndex !== -1 && colonIndex > questionMarkIndex)
|
||||
|| (hashIndex !== -1 && colonIndex > hashIndex)
|
||||
)
|
||||
return uri
|
||||
|
||||
const scheme = uri.substring(0, colonIndex + 1).toLowerCase()
|
||||
if (PERMITTED_SCHEME_REGEX.test(scheme))
|
||||
return uri
|
||||
|
||||
return undefined
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
import mermaid, { type MermaidConfig } from 'mermaid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
import { MoonIcon, SunIcon } from '@heroicons/react/24/solid'
|
||||
@ -68,14 +68,13 @@ const THEMES = {
|
||||
const initMermaid = () => {
|
||||
if (typeof window !== 'undefined' && !isMermaidInitialized) {
|
||||
try {
|
||||
mermaid.initialize({
|
||||
const config: MermaidConfig = {
|
||||
startOnLoad: false,
|
||||
fontFamily: 'sans-serif',
|
||||
securityLevel: 'loose',
|
||||
flowchart: {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 10,
|
||||
curve: 'basis',
|
||||
nodeSpacing: 50,
|
||||
rankSpacing: 70,
|
||||
@ -91,8 +90,13 @@ const initMermaid = () => {
|
||||
numberSectionStyles: 4,
|
||||
axisFormat: '%Y-%m-%d',
|
||||
},
|
||||
mindmap: {
|
||||
useMaxWidth: true,
|
||||
padding: 10,
|
||||
},
|
||||
maxTextSize: 50000,
|
||||
})
|
||||
}
|
||||
mermaid.initialize(config)
|
||||
isMermaidInitialized = true
|
||||
}
|
||||
catch (error) {
|
||||
@ -108,7 +112,7 @@ const Flowchart = React.forwardRef((props: {
|
||||
theme?: 'light' | 'dark'
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const [svgCode, setSvgCode] = useState<string | null>(null)
|
||||
const [svgString, setSvgString] = useState<string | null>(null)
|
||||
const [look, setLook] = useState<'classic' | 'handDrawn'>('classic')
|
||||
const [isInitialized, setIsInitialized] = useState(false)
|
||||
const [currentTheme, setCurrentTheme] = useState<'light' | 'dark'>(props.theme || 'light')
|
||||
@ -120,6 +124,7 @@ const Flowchart = React.forwardRef((props: {
|
||||
const [imagePreviewUrl, setImagePreviewUrl] = useState('')
|
||||
const [isCodeComplete, setIsCodeComplete] = useState(false)
|
||||
const codeCompletionCheckRef = useRef<NodeJS.Timeout>()
|
||||
const prevCodeRef = useRef<string>()
|
||||
|
||||
// Create cache key from code, style and theme
|
||||
const cacheKey = useMemo(() => {
|
||||
@ -164,50 +169,18 @@ const Flowchart = React.forwardRef((props: {
|
||||
*/
|
||||
const handleRenderError = (error: any) => {
|
||||
console.error('Mermaid rendering error:', error)
|
||||
const errorMsg = (error as Error).message
|
||||
|
||||
if (errorMsg.includes('getAttribute')) {
|
||||
diagramCache.clear()
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
})
|
||||
// On any render error, assume the mermaid state is corrupted and force a re-initialization.
|
||||
try {
|
||||
diagramCache.clear() // Clear cache to prevent using potentially corrupted SVGs
|
||||
isMermaidInitialized = false // <-- THE FIX: Force re-initialization
|
||||
initMermaid() // Re-initialize with the default safe configuration
|
||||
}
|
||||
else {
|
||||
setErrMsg(`Rendering chart failed, please refresh and try again ${look === 'handDrawn' ? 'Or try using classic mode' : ''}`)
|
||||
}
|
||||
|
||||
if (look === 'handDrawn') {
|
||||
try {
|
||||
// Clear possible cache issues
|
||||
diagramCache.delete(`${props.PrimitiveCode}-handDrawn-${currentTheme}`)
|
||||
|
||||
// Reset mermaid configuration
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'loose',
|
||||
theme: 'default',
|
||||
maxTextSize: 50000,
|
||||
})
|
||||
|
||||
// Try rendering with standard mode
|
||||
setLook('classic')
|
||||
setErrMsg('Hand-drawn mode is not supported for this diagram. Switched to classic mode.')
|
||||
|
||||
// Delay error clearing
|
||||
setTimeout(() => {
|
||||
if (containerRef.current) {
|
||||
// Try rendering again with standard mode, but can't call renderFlowchart directly due to circular dependency
|
||||
// Instead set state to trigger re-render
|
||||
setIsCodeComplete(true) // This will trigger useEffect re-render
|
||||
}
|
||||
}, 500)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('Reset after handDrawn error failed:', e)
|
||||
}
|
||||
catch (reinitError) {
|
||||
console.error('Failed to re-initialize Mermaid after error:', reinitError)
|
||||
}
|
||||
|
||||
setErrMsg(`Rendering failed: ${(error as Error).message || 'Unknown error. Please check the console.'}`)
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
||||
@ -218,51 +191,23 @@ const Flowchart = React.forwardRef((props: {
|
||||
setIsInitialized(true)
|
||||
}, [])
|
||||
|
||||
// Update theme when prop changes
|
||||
// Update theme when prop changes, but allow internal override.
|
||||
const prevThemeRef = useRef<string>()
|
||||
useEffect(() => {
|
||||
if (props.theme)
|
||||
// Only react if the theme prop from the outside has actually changed.
|
||||
if (props.theme && props.theme !== prevThemeRef.current) {
|
||||
// When the global theme prop changes, it should act as the source of truth,
|
||||
// overriding any local theme selection.
|
||||
diagramCache.clear()
|
||||
setSvgString(null)
|
||||
setCurrentTheme(props.theme)
|
||||
// Reset look to classic for a consistent state after a global change.
|
||||
setLook('classic')
|
||||
}
|
||||
// Update the ref to the current prop value for the next render.
|
||||
prevThemeRef.current = props.theme
|
||||
}, [props.theme])
|
||||
|
||||
// Validate mermaid code and check for completeness
|
||||
useEffect(() => {
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
|
||||
// Reset code complete status when code changes
|
||||
setIsCodeComplete(false)
|
||||
|
||||
// If no code or code is extremely short, don't proceed
|
||||
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10)
|
||||
return
|
||||
|
||||
// Check if code already in cache - if so we know it's valid
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setIsCodeComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Initial check using the extracted isMermaidCodeComplete function
|
||||
const isComplete = isMermaidCodeComplete(props.PrimitiveCode)
|
||||
if (isComplete) {
|
||||
setIsCodeComplete(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Set a delay to check again in case code is still being generated
|
||||
codeCompletionCheckRef.current = setTimeout(() => {
|
||||
setIsCodeComplete(isMermaidCodeComplete(props.PrimitiveCode))
|
||||
}, 300)
|
||||
|
||||
return () => {
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
}
|
||||
}, [props.PrimitiveCode, cacheKey])
|
||||
|
||||
/**
|
||||
* Renders flowchart based on provided code
|
||||
*/
|
||||
const renderFlowchart = useCallback(async (primitiveCode: string) => {
|
||||
if (!isInitialized || !containerRef.current) {
|
||||
setIsLoading(false)
|
||||
@ -270,15 +215,11 @@ const Flowchart = React.forwardRef((props: {
|
||||
return
|
||||
}
|
||||
|
||||
// Don't render if code is not complete yet
|
||||
if (!isCodeComplete) {
|
||||
setIsLoading(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Return cached result if available
|
||||
const cacheKey = `${primitiveCode}-${look}-${currentTheme}`
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
setErrMsg('')
|
||||
setSvgString(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
@ -289,16 +230,45 @@ const Flowchart = React.forwardRef((props: {
|
||||
try {
|
||||
let finalCode: string
|
||||
|
||||
// Check if it's a gantt chart
|
||||
const isGanttChart = primitiveCode.trim().startsWith('gantt')
|
||||
const trimmedCode = primitiveCode.trim()
|
||||
const isGantt = trimmedCode.startsWith('gantt')
|
||||
const isMindMap = trimmedCode.startsWith('mindmap')
|
||||
const isSequence = trimmedCode.startsWith('sequenceDiagram')
|
||||
|
||||
if (isGanttChart) {
|
||||
// For gantt charts, ensure each task is on its own line
|
||||
// and preserve exact whitespace/format
|
||||
finalCode = primitiveCode.trim()
|
||||
if (isGantt || isMindMap || isSequence) {
|
||||
if (isGantt) {
|
||||
finalCode = trimmedCode
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
// Gantt charts have specific syntax needs.
|
||||
const taskMatch = line.match(/^\s*([^:]+?)\s*:\s*(.*)/)
|
||||
if (!taskMatch)
|
||||
return line // Not a task line, return as is.
|
||||
|
||||
const taskName = taskMatch[1].trim()
|
||||
let paramsStr = taskMatch[2].trim()
|
||||
|
||||
// Rule 1: Correct multiple "after" dependencies ONLY if they exist.
|
||||
// This is a common mistake, e.g., "..., after task1, after task2, ..."
|
||||
const afterCount = (paramsStr.match(/after /g) || []).length
|
||||
if (afterCount > 1)
|
||||
paramsStr = paramsStr.replace(/,\s*after\s+/g, ' ')
|
||||
|
||||
// Rule 2: Normalize spacing between parameters for consistency.
|
||||
const finalParams = paramsStr.replace(/\s*,\s*/g, ', ').trim()
|
||||
return `${taskName} :${finalParams}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
else {
|
||||
// For mindmap and sequence charts, which are sensitive to syntax,
|
||||
// pass the code through directly.
|
||||
finalCode = trimmedCode
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Step 1: Clean and prepare Mermaid code using the extracted prepareMermaidCode function
|
||||
// This function handles flowcharts appropriately.
|
||||
finalCode = prepareMermaidCode(primitiveCode, look)
|
||||
}
|
||||
|
||||
@ -313,13 +283,12 @@ const Flowchart = React.forwardRef((props: {
|
||||
THEMES,
|
||||
)
|
||||
|
||||
// Step 4: Clean SVG code and convert to base64 using the extracted functions
|
||||
// Step 4: Clean up SVG code
|
||||
const cleanedSvg = cleanUpSvgCode(processedSvg)
|
||||
const base64Svg = await svgToBase64(cleanedSvg)
|
||||
|
||||
if (base64Svg && typeof base64Svg === 'string') {
|
||||
diagramCache.set(cacheKey, base64Svg)
|
||||
setSvgCode(base64Svg)
|
||||
if (cleanedSvg && typeof cleanedSvg === 'string') {
|
||||
diagramCache.set(cacheKey, cleanedSvg)
|
||||
setSvgString(cleanedSvg)
|
||||
}
|
||||
|
||||
setIsLoading(false)
|
||||
@ -328,12 +297,9 @@ const Flowchart = React.forwardRef((props: {
|
||||
// Error handling
|
||||
handleRenderError(error)
|
||||
}
|
||||
}, [chartId, isInitialized, cacheKey, isCodeComplete, look, currentTheme, t])
|
||||
}, [chartId, isInitialized, look, currentTheme, t])
|
||||
|
||||
/**
|
||||
* Configure mermaid based on selected style and theme
|
||||
*/
|
||||
const configureMermaid = useCallback(() => {
|
||||
const configureMermaid = useCallback((primitiveCode: string) => {
|
||||
if (typeof window !== 'undefined' && isInitialized) {
|
||||
const themeVars = THEMES[currentTheme]
|
||||
const config: any = {
|
||||
@ -352,21 +318,40 @@ const Flowchart = React.forwardRef((props: {
|
||||
numberSectionStyles: 4,
|
||||
axisFormat: '%Y-%m-%d',
|
||||
},
|
||||
mindmap: {
|
||||
useMaxWidth: true,
|
||||
padding: 10,
|
||||
},
|
||||
}
|
||||
|
||||
const isFlowchart = primitiveCode.trim().startsWith('graph') || primitiveCode.trim().startsWith('flowchart')
|
||||
|
||||
if (look === 'classic') {
|
||||
config.theme = currentTheme === 'dark' ? 'dark' : 'neutral'
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 12,
|
||||
nodeSpacing: 60,
|
||||
rankSpacing: 80,
|
||||
curve: 'linear',
|
||||
ranker: 'tight-tree',
|
||||
|
||||
if (isFlowchart) {
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
nodeSpacing: 60,
|
||||
rankSpacing: 80,
|
||||
curve: 'linear',
|
||||
ranker: 'tight-tree',
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTheme === 'dark') {
|
||||
config.themeVariables = {
|
||||
background: themeVars.background,
|
||||
primaryColor: themeVars.primaryColor,
|
||||
primaryBorderColor: themeVars.primaryBorderColor,
|
||||
primaryTextColor: themeVars.primaryTextColor,
|
||||
secondaryColor: themeVars.secondaryColor,
|
||||
tertiaryColor: themeVars.tertiaryColor,
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
else { // look === 'handDrawn'
|
||||
config.theme = 'default'
|
||||
config.themeCSS = `
|
||||
.node rect { fill-opacity: 0.85; }
|
||||
@ -378,27 +363,17 @@ const Flowchart = React.forwardRef((props: {
|
||||
config.themeVariables = {
|
||||
fontSize: '14px',
|
||||
fontFamily: 'sans-serif',
|
||||
primaryBorderColor: currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor,
|
||||
}
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
diagramPadding: 10,
|
||||
nodeSpacing: 40,
|
||||
rankSpacing: 60,
|
||||
curve: 'basis',
|
||||
}
|
||||
config.themeVariables.primaryBorderColor = currentTheme === 'dark' ? THEMES.dark.connectionColor : THEMES.light.connectionColor
|
||||
}
|
||||
|
||||
if (currentTheme === 'dark' && !config.themeVariables) {
|
||||
config.themeVariables = {
|
||||
background: themeVars.background,
|
||||
primaryColor: themeVars.primaryColor,
|
||||
primaryBorderColor: themeVars.primaryBorderColor,
|
||||
primaryTextColor: themeVars.primaryTextColor,
|
||||
secondaryColor: themeVars.secondaryColor,
|
||||
tertiaryColor: themeVars.tertiaryColor,
|
||||
fontFamily: 'sans-serif',
|
||||
if (isFlowchart) {
|
||||
config.flowchart = {
|
||||
htmlLabels: true,
|
||||
useMaxWidth: true,
|
||||
nodeSpacing: 40,
|
||||
rankSpacing: 60,
|
||||
curve: 'basis',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -414,44 +389,50 @@ const Flowchart = React.forwardRef((props: {
|
||||
return false
|
||||
}, [currentTheme, isInitialized, look])
|
||||
|
||||
// Effect for theme and style configuration
|
||||
// This is the main rendering effect.
|
||||
// It triggers whenever the code, theme, or style changes.
|
||||
useEffect(() => {
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (configureMermaid() && containerRef.current && isCodeComplete)
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
}, [look, props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, currentTheme, isCodeComplete, configureMermaid])
|
||||
|
||||
// Effect for rendering with debounce
|
||||
useEffect(() => {
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setSvgCode(diagramCache.get(cacheKey) || null)
|
||||
if (!isInitialized)
|
||||
return
|
||||
|
||||
// Don't render if code is too short
|
||||
if (!props.PrimitiveCode || props.PrimitiveCode.length < 10) {
|
||||
setIsLoading(false)
|
||||
setSvgString(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a timeout to handle streaming code and debounce rendering
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
|
||||
if (isCodeComplete) {
|
||||
renderTimeoutRef.current = setTimeout(() => {
|
||||
if (isInitialized)
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
}, 300)
|
||||
}
|
||||
else {
|
||||
setIsLoading(true)
|
||||
}
|
||||
setIsLoading(true)
|
||||
|
||||
renderTimeoutRef.current = setTimeout(() => {
|
||||
// Final validation before rendering
|
||||
if (!isMermaidCodeComplete(props.PrimitiveCode)) {
|
||||
setIsLoading(false)
|
||||
setErrMsg('Diagram code is not complete or invalid.')
|
||||
return
|
||||
}
|
||||
|
||||
const cacheKey = `${props.PrimitiveCode}-${look}-${currentTheme}`
|
||||
if (diagramCache.has(cacheKey)) {
|
||||
setErrMsg('')
|
||||
setSvgString(diagramCache.get(cacheKey) || null)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (configureMermaid(props.PrimitiveCode))
|
||||
renderFlowchart(props.PrimitiveCode)
|
||||
}, 300) // 300ms debounce
|
||||
|
||||
return () => {
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
}
|
||||
}, [props.PrimitiveCode, renderFlowchart, isInitialized, cacheKey, isCodeComplete])
|
||||
}, [props.PrimitiveCode, look, currentTheme, isInitialized, configureMermaid, renderFlowchart])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
@ -460,14 +441,22 @@ const Flowchart = React.forwardRef((props: {
|
||||
containerRef.current.innerHTML = ''
|
||||
if (renderTimeoutRef.current)
|
||||
clearTimeout(renderTimeoutRef.current)
|
||||
if (codeCompletionCheckRef.current)
|
||||
clearTimeout(codeCompletionCheckRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handlePreviewClick = async () => {
|
||||
if (svgString) {
|
||||
const base64 = await svgToBase64(svgString)
|
||||
setImagePreviewUrl(base64)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
setCurrentTheme(prevTheme => prevTheme === 'light' ? Theme.dark : Theme.light)
|
||||
const newTheme = currentTheme === 'light' ? 'dark' : 'light'
|
||||
// Ensure a full, clean re-render cycle, consistent with global theme change.
|
||||
diagramCache.clear()
|
||||
setSvgString(null)
|
||||
setCurrentTheme(newTheme)
|
||||
}
|
||||
|
||||
// Style classes for theme-dependent elements
|
||||
@ -516,14 +505,26 @@ const Flowchart = React.forwardRef((props: {
|
||||
<div
|
||||
key='classic'
|
||||
className={getLookButtonClass('classic')}
|
||||
onClick={() => setLook('classic')}
|
||||
onClick={() => {
|
||||
if (look !== 'classic') {
|
||||
diagramCache.clear()
|
||||
setSvgString(null)
|
||||
setLook('classic')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="msh-segmented-item-label">{t('app.mermaid.classic')}</div>
|
||||
</div>
|
||||
<div
|
||||
key='handDrawn'
|
||||
className={getLookButtonClass('handDrawn')}
|
||||
onClick={() => setLook('handDrawn')}
|
||||
onClick={() => {
|
||||
if (look !== 'handDrawn') {
|
||||
diagramCache.clear()
|
||||
setSvgString(null)
|
||||
setLook('handDrawn')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="msh-segmented-item-label">{t('app.mermaid.handDrawn')}</div>
|
||||
</div>
|
||||
@ -533,7 +534,7 @@ const Flowchart = React.forwardRef((props: {
|
||||
|
||||
<div ref={containerRef} style={{ position: 'absolute', visibility: 'hidden', height: 0, overflow: 'hidden' }} />
|
||||
|
||||
{isLoading && !svgCode && (
|
||||
{isLoading && !svgString && (
|
||||
<div className='px-[26px] py-4'>
|
||||
<LoadingAnim type='text'/>
|
||||
{!isCodeComplete && (
|
||||
@ -544,8 +545,8 @@ const Flowchart = React.forwardRef((props: {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svgCode && (
|
||||
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={() => setImagePreviewUrl(svgCode)}>
|
||||
{svgString && (
|
||||
<div className={themeClasses.mermaidDiv} style={{ objectFit: 'cover' }} onClick={handlePreviewClick}>
|
||||
<div className="absolute bottom-2 left-2 z-[100]">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
@ -560,11 +561,9 @@ const Flowchart = React.forwardRef((props: {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<img
|
||||
src={svgCode}
|
||||
alt="mermaid_chart"
|
||||
<div
|
||||
style={{ maxWidth: '100%' }}
|
||||
onError={() => { setErrMsg('Chart rendering failed, please refresh and retry') }}
|
||||
dangerouslySetInnerHTML={{ __html: svgString }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -3,48 +3,31 @@ export function cleanUpSvgCode(svgCode: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Preprocesses mermaid code to fix common syntax issues
|
||||
* Prepares mermaid code for rendering by sanitizing common syntax issues.
|
||||
* @param {string} mermaidCode - The mermaid code to prepare
|
||||
* @param {'classic' | 'handDrawn'} style - The rendering style
|
||||
* @returns {string} - The prepared mermaid code
|
||||
*/
|
||||
export function preprocessMermaidCode(code: string): string {
|
||||
if (!code || typeof code !== 'string')
|
||||
export const prepareMermaidCode = (mermaidCode: string, style: 'classic' | 'handDrawn'): string => {
|
||||
if (!mermaidCode || typeof mermaidCode !== 'string')
|
||||
return ''
|
||||
|
||||
// First check if this is a gantt chart
|
||||
if (code.trim().startsWith('gantt')) {
|
||||
// For gantt charts, we need to ensure each task is on its own line
|
||||
// Split the code into lines and process each line separately
|
||||
const lines = code.split('\n').map(line => line.trim())
|
||||
return lines.join('\n')
|
||||
}
|
||||
let code = mermaidCode.trim()
|
||||
|
||||
return code
|
||||
// Replace English colons with Chinese colons in section nodes to avoid parsing issues
|
||||
.replace(/section\s+([^:]+):/g, (match, sectionName) => `section ${sectionName}:`)
|
||||
// Fix common syntax issues
|
||||
.replace(/fifopacket/g, 'rect')
|
||||
// Clean up empty lines and extra spaces
|
||||
.trim()
|
||||
}
|
||||
// Security: Sanitize against javascript: protocol in click events (XSS vector)
|
||||
code = code.replace(/(\bclick\s+\w+\s+")javascript:[^"]*(")/g, '$1#$2')
|
||||
|
||||
/**
|
||||
* Prepares mermaid code based on selected style
|
||||
*/
|
||||
export function prepareMermaidCode(code: string, style: 'classic' | 'handDrawn'): string {
|
||||
let finalCode = preprocessMermaidCode(code)
|
||||
// Convenience: Basic BR replacement. This is a common and safe operation.
|
||||
code = code.replace(/<br\s*\/?>/g, '\n')
|
||||
|
||||
// Special handling for gantt charts
|
||||
if (finalCode.trim().startsWith('gantt')) {
|
||||
// For gantt charts, preserve the structure exactly as is
|
||||
return finalCode
|
||||
}
|
||||
let finalCode = code
|
||||
|
||||
// Hand-drawn style requires some specific clean-up.
|
||||
if (style === 'handDrawn') {
|
||||
finalCode = finalCode
|
||||
// Remove style definitions that interfere with hand-drawn style
|
||||
.replace(/style\s+[^\n]+/g, '')
|
||||
.replace(/linkStyle\s+[^\n]+/g, '')
|
||||
.replace(/^flowchart/, 'graph')
|
||||
// Remove any styles that might interfere with hand-drawn style
|
||||
.replace(/class="[^"]*"/g, '')
|
||||
.replace(/fill="[^"]*"/g, '')
|
||||
.replace(/stroke="[^"]*"/g, '')
|
||||
@ -78,7 +61,6 @@ export function svgToBase64(svgGraph: string): Promise<string> {
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error converting SVG to base64:', error)
|
||||
return Promise.resolve('')
|
||||
}
|
||||
}
|
||||
@ -111,13 +93,11 @@ export function processSvgForTheme(
|
||||
}
|
||||
else {
|
||||
let i = 0
|
||||
themes.dark.nodeColors.forEach(() => {
|
||||
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
processedSvg = processedSvg.replace(regex, (match: string) => {
|
||||
const colorIndex = i % themes.dark.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
|
||||
const colorIndex = i % themes.dark.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.dark.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
|
||||
processedSvg = processedSvg
|
||||
@ -135,14 +115,12 @@ export function processSvgForTheme(
|
||||
.replace(/stroke-width="1"/g, 'stroke-width="1.5"')
|
||||
}
|
||||
else {
|
||||
themes.light.nodeColors.forEach(() => {
|
||||
const regex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
let i = 0
|
||||
processedSvg = processedSvg.replace(regex, (match: string) => {
|
||||
const colorIndex = i % themes.light.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
let i = 0
|
||||
const nodeColorRegex = /fill="#[a-fA-F0-9]{6}"[^>]*class="node-[^"]*"/g
|
||||
processedSvg = processedSvg.replace(nodeColorRegex, (match: string) => {
|
||||
const colorIndex = i % themes.light.nodeColors.length
|
||||
i++
|
||||
return match.replace(/fill="#[a-fA-F0-9]{6}"/, `fill="${themes.light.nodeColors[colorIndex].bg}"`)
|
||||
})
|
||||
|
||||
processedSvg = processedSvg
|
||||
@ -173,27 +151,20 @@ export function isMermaidCodeComplete(code: string): boolean {
|
||||
return lines.length >= 3
|
||||
}
|
||||
|
||||
// Special handling for mindmaps
|
||||
if (trimmedCode.startsWith('mindmap')) {
|
||||
// For mindmaps, check if it has at least a root node
|
||||
const lines = trimmedCode.split('\n').filter(line => line.trim().length > 0)
|
||||
return lines.length >= 2
|
||||
}
|
||||
|
||||
// Check for basic syntax structure
|
||||
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram)/.test(trimmedCode)
|
||||
const hasValidStart = /^(graph|flowchart|sequenceDiagram|classDiagram|classDef|class|stateDiagram|gantt|pie|er|journey|requirementDiagram|mindmap)/.test(trimmedCode)
|
||||
|
||||
// Check for balanced brackets and parentheses
|
||||
const isBalanced = (() => {
|
||||
const stack = []
|
||||
const pairs = { '{': '}', '[': ']', '(': ')' }
|
||||
|
||||
for (const char of trimmedCode) {
|
||||
if (char in pairs) {
|
||||
stack.push(char)
|
||||
}
|
||||
else if (Object.values(pairs).includes(char)) {
|
||||
const last = stack.pop()
|
||||
if (pairs[last as keyof typeof pairs] !== char)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return stack.length === 0
|
||||
})()
|
||||
// The balanced bracket check was too strict and produced false negatives for valid
|
||||
// mermaid syntax like the asymmetric shape `A>B]`. Relying on Mermaid's own
|
||||
// parser is more robust.
|
||||
const isBalanced = true
|
||||
|
||||
// Check for common syntax errors
|
||||
const hasNoSyntaxErrors = !trimmedCode.includes('undefined')
|
||||
@ -204,7 +175,7 @@ export function isMermaidCodeComplete(code: string): boolean {
|
||||
return hasValidStart && isBalanced && hasNoSyntaxErrors
|
||||
}
|
||||
catch (error) {
|
||||
console.debug('Mermaid code validation error:', error)
|
||||
console.error('Mermaid code validation error:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,47 +2,55 @@
|
||||
|
||||
@layer components {
|
||||
.premium-badge {
|
||||
@apply inline-flex justify-center items-center rounded-md border box-border border-white/95 text-white
|
||||
@apply shrink-0 relative inline-flex justify-center items-center rounded-md box-border border border-transparent text-white shadow-xs hover:shadow-lg bg-origin-border overflow-hidden transition-all duration-100 ease-out;
|
||||
background-clip: padding-box, border-box;
|
||||
}
|
||||
.allowHover {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
/* m is for the regular button */
|
||||
.premium-badge-m {
|
||||
@apply border shadow-lg !p-1 h-6 w-auto
|
||||
@apply !p-1 h-6 w-auto
|
||||
}
|
||||
|
||||
.premium-badge-s {
|
||||
@apply border-[0.5px] shadow-xs !px-1 !py-[3px] h-[18px] w-auto
|
||||
@apply border-[0.5px] !px-1 !py-[3px] h-[18px] w-auto
|
||||
}
|
||||
|
||||
.premium-badge-blue {
|
||||
@apply bg-gradient-to-r from-[#5289ffe6] to-[#155aefe6] bg-util-colors-blue-blue-200
|
||||
@apply bg-util-colors-blue-blue-200;
|
||||
background-image: linear-gradient(90deg, #5289ffe6 0%, #155aefe6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #155aef 100%);
|
||||
}
|
||||
.premium-badge-blue.allowHover:hover {
|
||||
@apply bg-util-colors-blue-blue-300;
|
||||
background-image: linear-gradient(90deg, #296dffe6 0%, #004aebe6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #00329e 100%);
|
||||
}
|
||||
|
||||
.premium-badge-indigo {
|
||||
@apply bg-gradient-to-r from-[#8098f9e6] to-[#444ce7e6] bg-util-colors-indigo-indigo-200
|
||||
@apply bg-util-colors-indigo-indigo-200;
|
||||
background-image: linear-gradient(90deg, #8098f9e6 0%, #444ce7e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #6172f3 100%);
|
||||
}
|
||||
.premium-badge-indigo.allowHover:hover {
|
||||
@apply bg-util-colors-indigo-indigo-300;
|
||||
background-image: linear-gradient(90deg, #6172f3e6 0%, #2d31a6e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #2d31a6 100%);
|
||||
}
|
||||
|
||||
.premium-badge-gray {
|
||||
@apply bg-gradient-to-r from-[#98a2b2e6] to-[#676f83e6] bg-util-colors-gray-gray-200
|
||||
@apply bg-util-colors-gray-gray-200;
|
||||
background-image: linear-gradient(90deg, #98a2b2e6 0%, #676f83e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #676f83 100%);
|
||||
}
|
||||
.premium-badge-gray.allowHover:hover {
|
||||
@apply bg-util-colors-gray-gray-300;
|
||||
background-image: linear-gradient(90deg, #676f83e6 0%, #354052e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #354052 100%);
|
||||
}
|
||||
|
||||
.premium-badge-orange {
|
||||
@apply bg-gradient-to-r from-[#ff692ee6] to-[#e04f16e6] bg-util-colors-orange-orange-200
|
||||
@apply bg-util-colors-orange-orange-200;
|
||||
background-image: linear-gradient(90deg, #ff692ee6 0%, #e04f16e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #e62e05 100%);
|
||||
}
|
||||
|
||||
.premium-badge-blue.allowHover:hover {
|
||||
@apply bg-gradient-to-r from-[#296dffe6] to-[#004aebe6] bg-util-colors-blue-blue-300 cursor-pointer
|
||||
}
|
||||
|
||||
.premium-badge-indigo.allowHover:hover {
|
||||
@apply bg-gradient-to-r from-[#6172f3e6] to-[#2d31a6e6] bg-util-colors-indigo-indigo-300 cursor-pointer
|
||||
}
|
||||
|
||||
.premium-badge-gray.allowHover:hover {
|
||||
@apply bg-gradient-to-r from-[#676f83e6] to-[#354052e6] bg-util-colors-gray-gray-300 cursor-pointer
|
||||
}
|
||||
|
||||
.premium-badge-orange.allowHover:hover {
|
||||
@apply bg-gradient-to-r from-[#ff4405e6] to-[#b93815e6] bg-util-colors-orange-orange-300 cursor-pointer
|
||||
@apply bg-util-colors-orange-orange-300;
|
||||
background-image: linear-gradient(90deg, #ff4405e6 0%, #b93815e6 100%), linear-gradient(135deg, var(--color-premium-badge-border-highlight-color) 0%, #e62e05 100%);
|
||||
}
|
||||
}
|
||||
|
||||
@ -61,13 +61,9 @@ const PremiumBadge: React.FC<PremiumBadgeProps> = ({
|
||||
{children}
|
||||
<Highlight
|
||||
className={classNames(
|
||||
'absolute top-0 opacity-50 hover:opacity-80',
|
||||
'absolute top-0 opacity-50 right-1/2 translate-x-[20%] transition-all duration-100 ease-out hover:opacity-80 hover:translate-x-[30%]',
|
||||
size === 's' ? 'h-[18px] w-12' : 'h-6 w-12',
|
||||
)}
|
||||
style={{
|
||||
right: '50%',
|
||||
transform: 'translateX(10%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -74,9 +74,11 @@ export const useSelectOrDelete: UseSelectOrDeleteHandler = (nodeKey: string, com
|
||||
)
|
||||
|
||||
const handleSelect = useCallback((e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
if (!e.metaKey && !e.ctrlKey) {
|
||||
e.stopPropagation()
|
||||
clearSelection()
|
||||
setSelected(true)
|
||||
}
|
||||
}, [setSelected, clearSelection])
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@ -165,6 +165,7 @@ const ComponentPicker = ({
|
||||
isSupportFileVar={isSupportFileVar}
|
||||
onClose={handleClose}
|
||||
onBlur={handleClose}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react'
|
||||
@ -13,6 +14,7 @@ import {
|
||||
RiErrorWarningFill,
|
||||
RiMoreLine,
|
||||
} from '@remixicon/react'
|
||||
import { useReactFlow, useStoreApi } from 'reactflow'
|
||||
import { useSelectOrDelete } from '../../hooks'
|
||||
import type { WorkflowNodesMap } from './node'
|
||||
import { WorkflowVariableBlockNode } from './node'
|
||||
@ -66,6 +68,9 @@ const WorkflowVariableBlockComponent = ({
|
||||
const isChatVar = isConversationVar(variables)
|
||||
const isException = isExceptionVariable(varName, node?.type)
|
||||
|
||||
const reactflow = useReactFlow()
|
||||
const store = useStoreApi()
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor.hasNodes([WorkflowVariableBlockNode]))
|
||||
throw new Error('WorkflowVariableBlockPlugin: WorkflowVariableBlock not registered on editor')
|
||||
@ -83,6 +88,26 @@ const WorkflowVariableBlockComponent = ({
|
||||
)
|
||||
}, [editor])
|
||||
|
||||
const handleVariableJump = useCallback(() => {
|
||||
const workflowContainer = document.getElementById('workflow-container')
|
||||
const {
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
} = workflowContainer!
|
||||
|
||||
const {
|
||||
setViewport,
|
||||
} = reactflow
|
||||
const { transform } = store.getState()
|
||||
const zoom = transform[2]
|
||||
const position = node.position
|
||||
setViewport({
|
||||
x: (clientWidth - 400 - node.width! * zoom) / 2 - position!.x * zoom,
|
||||
y: (clientHeight - node.height! * zoom) / 2 - position!.y * zoom,
|
||||
zoom: transform[2],
|
||||
})
|
||||
}, [node, reactflow, store])
|
||||
|
||||
const Item = (
|
||||
<div
|
||||
className={cn(
|
||||
@ -90,6 +115,10 @@ const WorkflowVariableBlockComponent = ({
|
||||
isSelected ? ' border-state-accent-solid bg-state-accent-hover' : ' border-components-panel-border-subtle bg-components-badge-white-to-dark',
|
||||
!node && !isEnv && !isChatVar && '!border-state-destructive-solid !bg-state-destructive-hover',
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleVariableJump()
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{!isEnv && !isChatVar && (
|
||||
|
||||
@ -64,7 +64,7 @@ export type GetVarType = (payload: {
|
||||
export type WorkflowVariableBlockType = {
|
||||
show?: boolean
|
||||
variables?: NodeOutPutVar[]
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type'>>
|
||||
workflowNodesMap?: Record<string, Pick<Node['data'], 'title' | 'type' | 'height' | 'width' | 'position'>>
|
||||
onInsert?: () => void
|
||||
onDelete?: () => void
|
||||
getVarType?: GetVarType
|
||||
|
||||
@ -15,7 +15,7 @@ export default function Group({ children, value, onChange, className = '' }: TRa
|
||||
onChange?.(value)
|
||||
}
|
||||
return (
|
||||
<div className={cn('flex items-center bg-gray-50', s.container, className)}>
|
||||
<div className={cn('flex items-center bg-workflow-block-parma-bg text-text-secondary', s.container, className)}>
|
||||
<RadioGroupContext.Provider value={{ value, onChange: onRadioChange }}>
|
||||
{children}
|
||||
</RadioGroupContext.Provider>
|
||||
|
||||
@ -37,14 +37,15 @@ export default function Radio({
|
||||
const isChecked = groupContext ? groupContext.value === value : checked
|
||||
const divClassName = `
|
||||
flex items-center py-1 relative
|
||||
px-7 cursor-pointer hover:bg-gray-200 rounded
|
||||
px-7 cursor-pointer text-text-secondary rounded
|
||||
hover:bg-components-option-card-option-bg-hover hover:shadow-xs
|
||||
`
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
s.label,
|
||||
disabled ? s.disabled : '',
|
||||
isChecked ? 'bg-white shadow' : '',
|
||||
isChecked ? 'bg-components-option-card-option-bg-hover shadow-xs' : '',
|
||||
divClassName,
|
||||
className)}
|
||||
onClick={() => handleChange(value)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user