mirror of
https://github.com/langgenius/dify.git
synced 2026-05-03 17:08:03 +08:00
Merge branch 'main' into feat/rag-2
This commit is contained in:
@ -12,7 +12,6 @@ import {
|
||||
RiFileUploadLine,
|
||||
} from '@remixicon/react'
|
||||
import AppIcon from '../base/app-icon'
|
||||
import cn from '@/utils/classnames'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { ToastContext } from '@/app/components/base/toast'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -31,6 +30,7 @@ import Divider from '../base/divider'
|
||||
import type { Operation } from './app-operations'
|
||||
import AppOperations from './app-operations'
|
||||
import dynamic from 'next/dynamic'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const SwitchAppModal = dynamic(() => import('@/app/components/app/switch-app-modal'), {
|
||||
ssr: false,
|
||||
@ -256,32 +256,40 @@ const AppInfo = ({ expand, onlyShowDetail = false, openState = false, onDetailEx
|
||||
}}
|
||||
className='block w-full'
|
||||
>
|
||||
<div className={cn('flex rounded-lg', expand ? 'flex-col gap-2 p-2 pb-2.5' : 'items-start justify-center gap-1 p-1', open && 'bg-state-base-hover', isCurrentWorkspaceEditor && 'cursor-pointer hover:bg-state-base-hover')}>
|
||||
<div className={`flex items-center self-stretch ${expand ? 'justify-between' : 'flex-col gap-1'}`}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
<div className='flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<div className='flex flex-col gap-2 rounded-lg p-1 hover:bg-state-base-hover'>
|
||||
<div className='flex items-center gap-1'>
|
||||
<div className={cn(!expand && 'ml-1')}>
|
||||
<AppIcon
|
||||
size={expand ? 'large' : 'small'}
|
||||
iconType={appDetail.icon_type}
|
||||
icon={appDetail.icon}
|
||||
background={appDetail.icon_background}
|
||||
imageUrl={appDetail.icon_url}
|
||||
/>
|
||||
</div>
|
||||
{expand && (
|
||||
<div className='ml-auto flex items-center justify-center rounded-md p-0.5'>
|
||||
<div className='flex h-5 w-5 items-center justify-center'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!expand && (
|
||||
<div className='flex items-center justify-center'>
|
||||
<div className='flex h-5 w-5 items-center justify-center rounded-md p-0.5'>
|
||||
<RiEqualizer2Line className='h-4 w-4 text-text-tertiary' />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn(
|
||||
'flex flex-col items-start gap-1 transition-all duration-200 ease-in-out',
|
||||
expand
|
||||
? 'w-auto opacity-100'
|
||||
: 'pointer-events-none w-0 overflow-hidden opacity-0',
|
||||
)}>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
|
||||
)}
|
||||
{expand && (
|
||||
<div className='flex flex-col items-start gap-1'>
|
||||
<div className='flex w-full'>
|
||||
<div className='system-md-semibold truncate whitespace-nowrap text-text-secondary'>{appDetail.name}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
<div className='system-2xs-medium-uppercase whitespace-nowrap text-text-tertiary'>{appDetail.mode === 'advanced-chat' ? t('app.types.advanced') : appDetail.mode === 'agent-chat' ? t('app.types.agent') : appDetail.mode === 'chat' ? t('app.types.chatbot') : appDetail.mode === 'completion' ? t('app.types.completion') : t('app.types.workflow')}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
@ -32,7 +32,7 @@ const AccessControlDialog = ({
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-background-overlay bg-opacity-25" />
|
||||
<div className="bg-background-overlay/25 fixed inset-0" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 flex items-center justify-center">
|
||||
|
||||
@ -106,7 +106,7 @@ function SelectedGroupsBreadCrumb() {
|
||||
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>
|
||||
<span className={classNames('system-xs-regular text-text-tertiary', selectedGroupsForBreadcrumb.length > 0 && 'cursor-pointer text-text-accent')} onClick={handleReset}>{t('app.accessControlDialog.operateGroupAndMember.allMembers')}</span>
|
||||
{selectedGroupsForBreadcrumb.map((group, index) => {
|
||||
return <div key={index} className='system-xs-regular flex items-center gap-x-0.5 text-text-tertiary'>
|
||||
<span>/</span>
|
||||
@ -198,7 +198,7 @@ type BaseItemProps = {
|
||||
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)}>
|
||||
return <div className={classNames('flex cursor-pointer items-center space-x-2 p-1 pl-2 hover:rounded-lg hover:bg-state-base-hover', className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -4,7 +4,6 @@ import React, { useRef, useState } from 'react'
|
||||
import { useGetState, useInfiniteScroll } from 'ahooks'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Link from 'next/link'
|
||||
import produce from 'immer'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -29,9 +28,10 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [selected, setSelected] = React.useState<DataSet[]>(selectedIds.map(id => ({ id }) as any))
|
||||
const [selected, setSelected] = React.useState<DataSet[]>([])
|
||||
const [loaded, setLoaded] = React.useState(false)
|
||||
const [datasets, setDataSets] = React.useState<DataSet[] | null>(null)
|
||||
const [hasInitialized, setHasInitialized] = React.useState(false)
|
||||
const hasNoData = !datasets || datasets?.length === 0
|
||||
const canSelectMulti = true
|
||||
|
||||
@ -49,19 +49,17 @@ const SelectDataSet: FC<ISelectDataSetProps> = ({
|
||||
const newList = [...(datasets || []), ...data.filter(item => item.indexing_technique || item.provider === 'external')]
|
||||
setDataSets(newList)
|
||||
setLoaded(true)
|
||||
if (!selected.find(item => !item.name))
|
||||
return { list: [] }
|
||||
|
||||
const newSelected = produce(selected, (draft) => {
|
||||
selected.forEach((item, index) => {
|
||||
if (!item.name) { // not fetched database
|
||||
const newItem = newList.find(i => i.id === item.id)
|
||||
if (newItem)
|
||||
draft[index] = newItem
|
||||
}
|
||||
})
|
||||
})
|
||||
setSelected(newSelected)
|
||||
// Initialize selected datasets based on selectedIds and available datasets
|
||||
if (!hasInitialized) {
|
||||
if (selectedIds.length > 0) {
|
||||
const validSelectedDatasets = selectedIds
|
||||
.map(id => newList.find(item => item.id === id))
|
||||
.filter(Boolean) as DataSet[]
|
||||
setSelected(validSelectedDatasets)
|
||||
}
|
||||
setHasInitialized(true)
|
||||
}
|
||||
}
|
||||
return { list: [] }
|
||||
},
|
||||
|
||||
@ -40,13 +40,13 @@ type CategoryItemProps = {
|
||||
}
|
||||
function CategoryItem({ category, active, onClick }: CategoryItemProps) {
|
||||
return <li
|
||||
className={classNames('p-1 pl-3 h-8 rounded-lg flex items-center gap-2 group cursor-pointer hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')}
|
||||
className={classNames('group flex h-8 cursor-pointer items-center gap-2 rounded-lg p-1 pl-3 hover:bg-state-base-hover [&.active]:bg-state-base-active', active && 'active')}
|
||||
onClick={() => { onClick?.(category) }}>
|
||||
{category === AppCategories.RECOMMENDED && <div className='inline-flex h-5 w-5 items-center justify-center rounded-md'>
|
||||
<RiThumbUpLine className='h-4 w-4 text-components-menu-item-text group-[.active]:text-components-menu-item-text-active' />
|
||||
</div>}
|
||||
<AppCategoryLabel category={category}
|
||||
className={classNames('system-sm-medium text-components-menu-item-text group-[.active]:text-components-menu-item-text-active group-hover:text-components-menu-item-text-hover', active && 'system-sm-semibold')} />
|
||||
className={classNames('system-sm-medium text-components-menu-item-text group-hover:text-components-menu-item-text-hover group-[.active]:text-components-menu-item-text-active', active && 'system-sm-semibold')} />
|
||||
</li >
|
||||
}
|
||||
|
||||
|
||||
@ -82,8 +82,11 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate }: CreateAppProps)
|
||||
localStorage.setItem(NEED_REFRESH_APP_LIST_KEY, '1')
|
||||
getRedirection(isCurrentWorkspaceEditor, app, push)
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.newApp.appCreateFailed') })
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: e.message || t('app.newApp.appCreateFailed'),
|
||||
})
|
||||
}
|
||||
isCreatingRef.current = false
|
||||
}, [name, notify, t, appMode, appIcon, description, onSuccess, onClose, push, isCurrentWorkspaceEditor])
|
||||
|
||||
@ -117,8 +117,11 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
if (onRefresh)
|
||||
onRefresh()
|
||||
}
|
||||
catch {
|
||||
notify({ type: 'error', message: t('app.editFailed') })
|
||||
catch (e: any) {
|
||||
notify({
|
||||
type: 'error',
|
||||
message: e.message || t('app.editFailed'),
|
||||
})
|
||||
}
|
||||
}, [app.id, notify, onRefresh, t])
|
||||
|
||||
@ -364,7 +367,7 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => {
|
||||
</div>
|
||||
<div className='title-wrapper h-[90px] px-[14px] text-xs leading-normal text-text-tertiary'>
|
||||
<div
|
||||
className={cn(tags.length ? 'line-clamp-2' : 'line-clamp-4', 'group-hover:line-clamp-2')}
|
||||
className='line-clamp-2'
|
||||
title={app.description}
|
||||
>
|
||||
{app.description}
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import Link from 'next/link'
|
||||
import { RiCloseLine, RiDiscordFill, RiGithubFill } from '@remixicon/react'
|
||||
import { RiDiscordFill, RiGithubFill } from '@remixicon/react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type CustomLinkProps = {
|
||||
@ -26,24 +26,9 @@ const CustomLink = React.memo(({
|
||||
|
||||
const Footer = () => {
|
||||
const { t } = useTranslation()
|
||||
const [isVisible, setIsVisible] = useState(true)
|
||||
|
||||
const handleClose = () => {
|
||||
setIsVisible(false)
|
||||
}
|
||||
|
||||
if (!isVisible)
|
||||
return null
|
||||
|
||||
return (
|
||||
<footer className='relative shrink-0 grow-0 px-12 py-2'>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className='absolute right-2 top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-full transition-colors duration-200 ease-in-out hover:bg-components-main-nav-nav-button-bg-active'
|
||||
aria-label="Close footer"
|
||||
>
|
||||
<RiCloseLine className='h-4 w-4 text-text-tertiary hover:text-text-secondary' />
|
||||
</button>
|
||||
<h3 className='text-gradient text-xl font-semibold leading-tight'>{t('app.join')}</h3>
|
||||
<p className='system-sm-regular mt-1 text-text-tertiary'>{t('app.communityIntro')}</p>
|
||||
<div className='mt-3 flex items-center gap-2'>
|
||||
|
||||
@ -1,14 +1,11 @@
|
||||
'use client'
|
||||
import { useEducationInit } from '@/app/education-apply/hooks'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import List from './list'
|
||||
import Footer from './footer'
|
||||
import useDocumentTitle from '@/hooks/use-document-title'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const Apps = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
|
||||
useDocumentTitle(t('common.menus.apps'))
|
||||
useEducationInit()
|
||||
@ -16,9 +13,6 @@ const Apps = () => {
|
||||
return (
|
||||
<div className='relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-background-body'>
|
||||
<List />
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
</div >
|
||||
)
|
||||
}
|
||||
|
||||
@ -32,6 +32,8 @@ import TagFilter from '@/app/components/base/tag-management/filter'
|
||||
import CheckboxWithLabel from '@/app/components/datasets/create/website/base/checkbox-with-label'
|
||||
import dynamic from 'next/dynamic'
|
||||
import Empty from './empty'
|
||||
import Footer from './footer'
|
||||
import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
|
||||
const TagManagementModal = dynamic(() => import('@/app/components/base/tag-management'), {
|
||||
ssr: false,
|
||||
@ -66,6 +68,7 @@ const getKey = (
|
||||
|
||||
const List = () => {
|
||||
const { t } = useTranslation()
|
||||
const { systemFeatures } = useGlobalPublicStore()
|
||||
const router = useRouter()
|
||||
const { isCurrentWorkspaceEditor, isCurrentWorkspaceDatasetOperator } = useAppContext()
|
||||
const showTagManagementModal = useTagStore(s => s.showTagManagementModal)
|
||||
@ -229,6 +232,9 @@ const List = () => {
|
||||
<span className="system-xs-regular">{t('app.newApp.dropDSLToCreateApp')}</span>
|
||||
</div>
|
||||
)}
|
||||
{!systemFeatures.branding.enabled && (
|
||||
<Footer />
|
||||
)}
|
||||
<CheckModal />
|
||||
<div ref={anchorRef} className='h-0'> </div>
|
||||
{showTagManagementModal && (
|
||||
|
||||
@ -94,7 +94,7 @@ const ImageInput: FC<UploaderProps> = ({
|
||||
<div
|
||||
className={classNames(
|
||||
isDragActive && 'border-primary-600',
|
||||
'relative aspect-square border-[1.5px] border-dashed rounded-lg flex flex-col justify-center items-center text-gray-500')}
|
||||
'relative flex aspect-square flex-col items-center justify-center rounded-lg border-[1.5px] border-dashed text-gray-500')}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
|
||||
@ -112,7 +112,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
? <div className='h-full px-4 py-2'>
|
||||
<textarea
|
||||
ref={contentEditableRef}
|
||||
className={classNames(editAreaClassName, 'block w-full h-full resize-none')}
|
||||
className={classNames(editAreaClassName, 'block h-full w-full resize-none')}
|
||||
placeholder={placeholder}
|
||||
onChange={onValueChange}
|
||||
value={currentValue}
|
||||
@ -130,7 +130,7 @@ const BlockInput: FC<IBlockInputProps> = ({
|
||||
</div>)
|
||||
|
||||
return (
|
||||
<div className={classNames('block-input w-full overflow-y-auto bg-white border-none rounded-xl')}>
|
||||
<div className={classNames('block-input w-full overflow-y-auto rounded-xl border-none bg-white')}>
|
||||
{textAreaContent}
|
||||
{/* footer */}
|
||||
{!readonly && (
|
||||
|
||||
@ -51,7 +51,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{loading && <Spinner loading={loading} className={classNames('!text-white !h-3 !w-3 !border-2 !ml-1', spinnerClassName)} />}
|
||||
{loading && <Spinner loading={loading} className={classNames('!ml-1 !h-3 !w-3 !border-2 !text-white', spinnerClassName)} />}
|
||||
</button>
|
||||
)
|
||||
},
|
||||
|
||||
@ -78,7 +78,6 @@ const DatePicker = ({
|
||||
setCurrentDate(prev => getDateWithTimezone({ date: prev, timezone }))
|
||||
setSelectedDate(prev => prev ? getDateWithTimezone({ date: prev, timezone }) : undefined)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [timezone])
|
||||
|
||||
const handleClickTrigger = (e: React.MouseEvent) => {
|
||||
@ -192,7 +191,7 @@ const DatePicker = ({
|
||||
setView(ViewType.date)
|
||||
}
|
||||
|
||||
const timeFormat = needTimePicker ? 'MMMM D, YYYY hh:mm A' : 'MMMM D, YYYY'
|
||||
const timeFormat = needTimePicker ? t('time.dateFormats.displayWithTime') : t('time.dateFormats.display')
|
||||
const displayValue = value?.format(timeFormat) || ''
|
||||
const displayTime = selectedDate?.format('hh:mm A') || '--:-- --'
|
||||
const placeholderDate = isOpen && selectedDate ? selectedDate.format(timeFormat) : (placeholder || t('time.defaultPlaceholder'))
|
||||
|
||||
@ -90,3 +90,49 @@ export const convertTimezoneToOffsetStr = (timezone?: string) => {
|
||||
return DEFAULT_OFFSET_STR
|
||||
return `UTC${tzItem.name.charAt(0)}${tzItem.name.charAt(2)}`
|
||||
}
|
||||
|
||||
// Parse date with multiple format support
|
||||
export const parseDateWithFormat = (dateString: string, format?: string): Dayjs | null => {
|
||||
if (!dateString) return null
|
||||
|
||||
// If format is specified, use it directly
|
||||
if (format) {
|
||||
const parsed = dayjs(dateString, format, true)
|
||||
return parsed.isValid() ? parsed : null
|
||||
}
|
||||
|
||||
// Try common date formats
|
||||
const formats = [
|
||||
'YYYY-MM-DD', // Standard format
|
||||
'YYYY/MM/DD', // Slash format
|
||||
'DD-MM-YYYY', // European format
|
||||
'DD/MM/YYYY', // European slash format
|
||||
'MM-DD-YYYY', // US format
|
||||
'MM/DD/YYYY', // US slash format
|
||||
'YYYY-MM-DDTHH:mm:ss.SSSZ', // ISO format
|
||||
'YYYY-MM-DDTHH:mm:ssZ', // ISO format (no milliseconds)
|
||||
'YYYY-MM-DD HH:mm:ss', // Standard datetime format
|
||||
]
|
||||
|
||||
for (const fmt of formats) {
|
||||
const parsed = dayjs(dateString, fmt, true)
|
||||
if (parsed.isValid())
|
||||
return parsed
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Format date output with localization support
|
||||
export const formatDateForOutput = (date: Dayjs, includeTime: boolean = false, locale: string = 'en-US'): string => {
|
||||
if (!date || !date.isValid()) return ''
|
||||
|
||||
if (includeTime) {
|
||||
// Output format with time
|
||||
return date.format('YYYY-MM-DDTHH:mm:ss.SSSZ')
|
||||
}
|
||||
else {
|
||||
// Date-only output format without timezone
|
||||
return date.format('YYYY-MM-DD')
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,16 +47,16 @@ const CustomDialog = ({
|
||||
<div className="flex min-h-full items-center justify-center">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={classNames(
|
||||
'w-full max-w-[800px] p-6 overflow-hidden transition-all transform bg-components-panel-bg border-[0.5px] border-components-panel-border shadow-xl rounded-2xl',
|
||||
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95',
|
||||
'data-[enter]:opacity-100 data-[enter]:scale-100',
|
||||
'data-[leave]:opacity-0 data-[enter]:scale-95',
|
||||
'w-full max-w-[800px] overflow-hidden rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg p-6 shadow-xl transition-all',
|
||||
'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'data-[enter]:scale-95 data-[leave]:opacity-0',
|
||||
className,
|
||||
)}>
|
||||
{Boolean(title) && (
|
||||
<DialogTitle
|
||||
as={titleAs || 'h3'}
|
||||
className={classNames('pr-8 pb-3 title-2xl-semi-bold text-text-primary', titleClassName)}
|
||||
className={classNames('title-2xl-semi-bold pb-3 pr-8 text-text-primary', titleClassName)}
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
||||
@ -24,7 +24,7 @@ const DialogWrapper = ({
|
||||
<Dialog as="div" className="relative z-40" onClose={close}>
|
||||
<TransitionChild>
|
||||
<div className={cn(
|
||||
'fixed inset-0 bg-black bg-opacity-25',
|
||||
'fixed inset-0 bg-black/25',
|
||||
'data-[closed]:opacity-0',
|
||||
'data-[enter]:opacity-100 data-[enter]:duration-300 data-[enter]:ease-out',
|
||||
'data-[leave]:opacity-0 data-[leave]:duration-200 data-[leave]:ease-in',
|
||||
|
||||
@ -36,7 +36,7 @@ describe('file-uploader utils', () => {
|
||||
})
|
||||
|
||||
describe('fileUpload', () => {
|
||||
it('should handle successful file upload', async () => {
|
||||
it('should handle successful file upload', () => {
|
||||
const mockFile = new File(['test'], 'test.txt')
|
||||
const mockCallbacks = {
|
||||
onProgressCallback: jest.fn(),
|
||||
@ -46,13 +46,12 @@ describe('file-uploader utils', () => {
|
||||
|
||||
jest.mocked(upload).mockResolvedValue({ id: '123' })
|
||||
|
||||
await fileUpload({
|
||||
fileUpload({
|
||||
file: mockFile,
|
||||
...mockCallbacks,
|
||||
})
|
||||
|
||||
expect(upload).toHaveBeenCalled()
|
||||
expect(mockCallbacks.onSuccessCallback).toHaveBeenCalledWith({ id: '123' })
|
||||
})
|
||||
})
|
||||
|
||||
@ -284,7 +283,23 @@ describe('file-uploader utils', () => {
|
||||
})
|
||||
|
||||
describe('getProcessedFilesFromResponse', () => {
|
||||
it('should process files correctly', () => {
|
||||
beforeEach(() => {
|
||||
jest.mocked(mime.getAllExtensions).mockImplementation((mimeType: string) => {
|
||||
const mimeMap: Record<string, Set<string>> = {
|
||||
'image/jpeg': new Set(['jpg', 'jpeg']),
|
||||
'image/png': new Set(['png']),
|
||||
'image/gif': new Set(['gif']),
|
||||
'video/mp4': new Set(['mp4']),
|
||||
'audio/mp3': new Set(['mp3']),
|
||||
'application/pdf': new Set(['pdf']),
|
||||
'text/plain': new Set(['txt']),
|
||||
'application/json': new Set(['json']),
|
||||
}
|
||||
return mimeMap[mimeType] || new Set()
|
||||
})
|
||||
})
|
||||
|
||||
it('should process files correctly without type correction', () => {
|
||||
const files = [{
|
||||
related_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
extension: '.jpeg',
|
||||
@ -294,6 +309,8 @@ describe('file-uploader utils', () => {
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://upload.dify.dev/files/xxx/file-preview',
|
||||
upload_file_id: '2a38e2ca-1295-415d-a51d-65d4ff9912d9',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
@ -309,6 +326,215 @@ describe('file-uploader utils', () => {
|
||||
url: 'https://upload.dify.dev/files/xxx/file-preview',
|
||||
})
|
||||
})
|
||||
|
||||
it('should correct image file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('image')
|
||||
})
|
||||
|
||||
it('should correct video file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.mp4',
|
||||
filename: 'video.mp4',
|
||||
size: 1024,
|
||||
mime_type: 'video/mp4',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/video.mp4',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('video')
|
||||
})
|
||||
|
||||
it('should correct audio file misclassified as document', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.mp3',
|
||||
filename: 'audio.mp3',
|
||||
size: 1024,
|
||||
mime_type: 'audio/mp3',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/audio.mp3',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('audio')
|
||||
})
|
||||
|
||||
it('should correct document file misclassified as image', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.pdf',
|
||||
filename: 'document.pdf',
|
||||
size: 1024,
|
||||
mime_type: 'application/pdf',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://example.com/document.pdf',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type conflict', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.pdf',
|
||||
filename: 'document.pdf',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/document.pdf',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should NOT correct when filename and MIME type both point to wrong type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'image',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('image')
|
||||
})
|
||||
|
||||
it('should handle files with missing filename', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '',
|
||||
filename: '',
|
||||
size: 1024,
|
||||
mime_type: 'image/jpeg',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/file',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle files with missing MIME type', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.jpg',
|
||||
filename: 'image.jpg',
|
||||
size: 1024,
|
||||
mime_type: '',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/image.jpg',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle files with unknown extensions', () => {
|
||||
const files = [{
|
||||
related_id: '123',
|
||||
extension: '.unknown',
|
||||
filename: 'file.unknown',
|
||||
size: 1024,
|
||||
mime_type: 'application/unknown',
|
||||
transfer_method: TransferMethod.local_file,
|
||||
type: 'document',
|
||||
url: 'https://example.com/file.unknown',
|
||||
upload_file_id: '123',
|
||||
remote_url: '',
|
||||
}]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
expect(result[0].supportFileType).toBe('document')
|
||||
})
|
||||
|
||||
it('should handle multiple different file types correctly', () => {
|
||||
const files = [
|
||||
{
|
||||
related_id: '1',
|
||||
extension: '.jpg',
|
||||
filename: 'correct-image.jpg',
|
||||
mime_type: 'image/jpeg',
|
||||
type: 'image',
|
||||
size: 1024,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/correct-image.jpg',
|
||||
upload_file_id: '1',
|
||||
remote_url: '',
|
||||
},
|
||||
{
|
||||
related_id: '2',
|
||||
extension: '.png',
|
||||
filename: 'misclassified-image.png',
|
||||
mime_type: 'image/png',
|
||||
type: 'document',
|
||||
size: 2048,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/misclassified-image.png',
|
||||
upload_file_id: '2',
|
||||
remote_url: '',
|
||||
},
|
||||
{
|
||||
related_id: '3',
|
||||
extension: '.pdf',
|
||||
filename: 'conflicted.pdf',
|
||||
mime_type: 'image/jpeg',
|
||||
type: 'document',
|
||||
size: 3072,
|
||||
transfer_method: TransferMethod.local_file,
|
||||
url: 'https://example.com/conflicted.pdf',
|
||||
upload_file_id: '3',
|
||||
remote_url: '',
|
||||
},
|
||||
]
|
||||
|
||||
const result = getProcessedFilesFromResponse(files)
|
||||
|
||||
expect(result[0].supportFileType).toBe('image') // correct, no change
|
||||
expect(result[1].supportFileType).toBe('image') // corrected from document to image
|
||||
expect(result[2].supportFileType).toBe('document') // conflict, no change
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileNameFromUrl', () => {
|
||||
|
||||
@ -70,10 +70,13 @@ export const getFileExtension = (fileName: string, fileMimetype: string, isRemot
|
||||
}
|
||||
}
|
||||
if (!extension) {
|
||||
if (extensions.size > 0)
|
||||
extension = extensions.values().next().value.toLowerCase()
|
||||
else
|
||||
if (extensions.size > 0) {
|
||||
const firstExtension = extensions.values().next().value
|
||||
extension = firstExtension ? firstExtension.toLowerCase() : ''
|
||||
}
|
||||
else {
|
||||
extension = extensionInFileName
|
||||
}
|
||||
}
|
||||
|
||||
if (isRemote)
|
||||
@ -145,6 +148,19 @@ export const getProcessedFiles = (files: FileEntity[]) => {
|
||||
|
||||
export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
|
||||
return files.map((fileItem) => {
|
||||
let supportFileType = fileItem.type
|
||||
|
||||
if (fileItem.filename && fileItem.mime_type) {
|
||||
const detectedTypeFromFileName = getSupportFileType(fileItem.filename, '')
|
||||
const detectedTypeFromMime = getSupportFileType('', fileItem.mime_type)
|
||||
|
||||
if (detectedTypeFromFileName
|
||||
&& detectedTypeFromMime
|
||||
&& detectedTypeFromFileName === detectedTypeFromMime
|
||||
&& detectedTypeFromFileName !== fileItem.type)
|
||||
supportFileType = detectedTypeFromFileName
|
||||
}
|
||||
|
||||
return {
|
||||
id: fileItem.related_id,
|
||||
name: fileItem.filename,
|
||||
@ -152,7 +168,7 @@ export const getProcessedFilesFromResponse = (files: FileResponse[]) => {
|
||||
type: fileItem.mime_type,
|
||||
progress: 100,
|
||||
transferMethod: fileItem.transfer_method,
|
||||
supportFileType: fileItem.type,
|
||||
supportFileType,
|
||||
uploadedId: fileItem.upload_file_id || fileItem.related_id,
|
||||
url: fileItem.url || fileItem.remote_url,
|
||||
}
|
||||
|
||||
@ -48,9 +48,9 @@ export default function FullScreenModal({
|
||||
<DialogPanel className={classNames(
|
||||
'h-full',
|
||||
overflowVisible ? 'overflow-visible' : 'overflow-hidden',
|
||||
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95',
|
||||
'data-[enter]:opacity-100 data-[enter]:scale-100',
|
||||
'data-[leave]:opacity-0 data-[enter]:scale-95',
|
||||
'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'data-[enter]:scale-95 data-[leave]:opacity-0',
|
||||
className,
|
||||
)}>
|
||||
{closable
|
||||
|
||||
@ -16,8 +16,8 @@ const GridMask: FC<GridMaskProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames('relative bg-saas-background', wrapperClassName)}>
|
||||
<div className={classNames('absolute inset-0 w-full h-full z-0 opacity-70', canvasClassName, Style.gridBg)} />
|
||||
<div className={classNames('absolute w-full h-full z-[1] bg-grid-mask-background rounded-lg', gradientClassName)} />
|
||||
<div className={classNames('absolute inset-0 z-0 h-full w-full opacity-70', canvasClassName, Style.gridBg)} />
|
||||
<div className={classNames('absolute z-[1] h-full w-full rounded-lg bg-grid-mask-background', gradientClassName)} />
|
||||
<div className='relative z-[2]'>{children}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -32,7 +32,7 @@ export type InputProps = {
|
||||
unit?: string
|
||||
} & Omit<React.InputHTMLAttributes<HTMLInputElement>, 'size'> & VariantProps<typeof inputVariants>
|
||||
|
||||
const Input = ({
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(({
|
||||
size,
|
||||
disabled,
|
||||
destructive,
|
||||
@ -47,12 +47,13 @@ const Input = ({
|
||||
onChange = noop,
|
||||
unit,
|
||||
...props
|
||||
}: InputProps) => {
|
||||
}, ref) => {
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<div className={cn('relative w-full', wrapperClassName)}>
|
||||
{showLeftIcon && <RiSearchLine className={cn('absolute left-2 top-1/2 h-4 w-4 -translate-y-1/2 text-components-input-text-placeholder')} />}
|
||||
<input
|
||||
ref={ref}
|
||||
style={styleCss}
|
||||
className={cn(
|
||||
'w-full appearance-none border border-transparent bg-components-input-bg-normal py-[7px] text-components-input-text-filled caret-primary-600 outline-none placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
@ -92,6 +93,8 @@ const Input = ({
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export default Input
|
||||
|
||||
@ -13,7 +13,7 @@ const LogoSite: FC<LogoSiteProps> = ({
|
||||
return (
|
||||
<img
|
||||
src={`${basePath}/logo/logo.png`}
|
||||
className={classNames('block w-[22.651px] h-[24.5px]', className)}
|
||||
className={classNames('block h-[24.5px] w-[22.651px]', className)}
|
||||
alt='logo'
|
||||
/>
|
||||
)
|
||||
|
||||
@ -81,7 +81,6 @@ const CodeBlock: any = memo(({ inline, className, children = '', ...props }: any
|
||||
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
|
||||
|
||||
@ -7,6 +7,7 @@ import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import Checkbox from '@/app/components/base/checkbox'
|
||||
import Select from '@/app/components/base/select'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
import { formatDateForOutput } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
|
||||
enum DATA_FORMAT {
|
||||
TEXT = 'text',
|
||||
@ -51,8 +52,20 @@ const MarkdownForm = ({ node }: any) => {
|
||||
const getFormValues = (children: any) => {
|
||||
const values: { [key: string]: any } = {}
|
||||
children.forEach((child: any) => {
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName))
|
||||
values[child.properties.name] = formValues[child.properties.name]
|
||||
if ([SUPPORTED_TAGS.INPUT, SUPPORTED_TAGS.TEXTAREA].includes(child.tagName)) {
|
||||
let value = formValues[child.properties.name]
|
||||
|
||||
if (child.tagName === SUPPORTED_TAGS.INPUT
|
||||
&& (child.properties.type === SUPPORTED_TYPES.DATE || child.properties.type === SUPPORTED_TYPES.DATETIME)) {
|
||||
if (value && typeof value.format === 'function') {
|
||||
// Format date output consistently
|
||||
const includeTime = child.properties.type === SUPPORTED_TYPES.DATETIME
|
||||
value = formatDateForOutput(value, includeTime)
|
||||
}
|
||||
}
|
||||
|
||||
values[child.properties.name] = value
|
||||
}
|
||||
})
|
||||
return values
|
||||
}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import mermaid, { type MermaidConfig } from 'mermaid'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
||||
@ -122,14 +122,6 @@ const Flowchart = React.forwardRef((props: {
|
||||
const renderTimeoutRef = useRef<NodeJS.Timeout>()
|
||||
const [errMsg, setErrMsg] = useState('')
|
||||
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(() => {
|
||||
return `${props.PrimitiveCode}-${look}-${currentTheme}`
|
||||
}, [props.PrimitiveCode, look, currentTheme])
|
||||
|
||||
/**
|
||||
* Renders Mermaid chart
|
||||
@ -537,11 +529,9 @@ const Flowchart = React.forwardRef((props: {
|
||||
{isLoading && !svgString && (
|
||||
<div className='px-[26px] py-4'>
|
||||
<LoadingAnim type='text'/>
|
||||
{!isCodeComplete && (
|
||||
<div className="mt-2 text-sm text-gray-500">
|
||||
{t('common.wait_for_completion', 'Waiting for diagram code to complete...')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -50,11 +50,11 @@ export default function Modal({
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<TransitionChild>
|
||||
<DialogPanel className={classNames(
|
||||
'w-full max-w-[480px] transform rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all',
|
||||
'w-full max-w-[480px] rounded-2xl bg-components-panel-bg p-6 text-left align-middle shadow-xl transition-all',
|
||||
overflowVisible ? 'overflow-visible' : 'overflow-hidden',
|
||||
'duration-100 ease-in data-[closed]:opacity-0 data-[closed]:scale-95',
|
||||
'data-[enter]:opacity-100 data-[enter]:scale-100',
|
||||
'data-[leave]:opacity-0 data-[enter]:scale-95',
|
||||
'duration-100 ease-in data-[closed]:scale-95 data-[closed]:opacity-0',
|
||||
'data-[enter]:scale-100 data-[enter]:opacity-100',
|
||||
'data-[enter]:scale-95 data-[leave]:opacity-0',
|
||||
className,
|
||||
)}>
|
||||
{title && <DialogTitle
|
||||
|
||||
@ -61,7 +61,7 @@ const PremiumBadge: React.FC<PremiumBadgeProps> = ({
|
||||
{children}
|
||||
<Highlight
|
||||
className={classNames(
|
||||
'absolute top-0 opacity-50 right-1/2 translate-x-[20%] transition-all duration-100 ease-out hover:opacity-80 hover:translate-x-[30%]',
|
||||
'absolute right-1/2 top-0 translate-x-[20%] opacity-50 transition-all duration-100 ease-out hover:translate-x-[30%] hover:opacity-80',
|
||||
size === 's' ? 'h-[18px] w-12' : 'h-6 w-12',
|
||||
)}
|
||||
/>
|
||||
|
||||
61
web/app/components/base/select/locale-signin.tsx
Normal file
61
web/app/components/base/select/locale-signin.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react'
|
||||
import { Fragment } from 'react'
|
||||
import { GlobeAltIcon } from '@heroicons/react/24/outline'
|
||||
|
||||
type ISelectProps = {
|
||||
items: Array<{ value: string; name: string }>
|
||||
value?: string
|
||||
className?: string
|
||||
onChange?: (value: string) => void
|
||||
}
|
||||
|
||||
export default function LocaleSigninSelect({
|
||||
items,
|
||||
value,
|
||||
onChange,
|
||||
}: ISelectProps) {
|
||||
const item = items.filter(item => item.value === value)[0]
|
||||
|
||||
return (
|
||||
<div className="w-56 text-right">
|
||||
<Menu as="div" className="relative inline-block text-left">
|
||||
<div>
|
||||
<MenuButton className="h-[44px]justify-center inline-flex w-full items-center rounded-lg border border-components-button-secondary-border px-[10px] py-[6px] text-[13px] font-medium text-text-primary hover:bg-state-base-hover">
|
||||
<GlobeAltIcon className="mr-1 h-5 w-5" aria-hidden="true" />
|
||||
{item?.name}
|
||||
</MenuButton>
|
||||
</div>
|
||||
<Transition
|
||||
as={Fragment}
|
||||
enter="transition ease-out duration-100"
|
||||
enterFrom="transform opacity-0 scale-95"
|
||||
enterTo="transform opacity-100 scale-100"
|
||||
leave="transition ease-in duration-75"
|
||||
leaveFrom="transform opacity-100 scale-100"
|
||||
leaveTo="transform opacity-0 scale-95"
|
||||
>
|
||||
<MenuItems className="absolute right-0 z-10 mt-2 w-[200px] origin-top-right divide-y divide-divider-regular rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg focus:outline-none">
|
||||
<div className="px-1 py-1 ">
|
||||
{items.map((item) => {
|
||||
return <MenuItem key={item.value}>
|
||||
<button
|
||||
className={'group flex w-full items-center rounded-lg px-3 py-2 text-sm text-text-secondary data-[active]:bg-state-base-hover'}
|
||||
onClick={(evt) => {
|
||||
evt.preventDefault()
|
||||
onChange && onChange(item.value)
|
||||
}}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
</MenuItem>
|
||||
})}
|
||||
|
||||
</div>
|
||||
|
||||
</MenuItems>
|
||||
</Transition>
|
||||
</Menu>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -24,7 +24,7 @@ export const SkeletonRow: FC<SkeletonProps> = (props) => {
|
||||
export const SkeletonRectangle: FC<SkeletonProps> = (props) => {
|
||||
const { className, children, ...rest } = props
|
||||
return (
|
||||
<div className={classNames('h-2 rounded-sm opacity-20 bg-text-quaternary my-1', className)} {...rest}>
|
||||
<div className={classNames('my-1 h-2 rounded-sm bg-text-quaternary opacity-20', className)} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
@ -33,7 +33,7 @@ export const SkeletonRectangle: FC<SkeletonProps> = (props) => {
|
||||
export const SkeletonPoint: FC<SkeletonProps> = (props) => {
|
||||
const { className, ...rest } = props
|
||||
return (
|
||||
<div className={classNames('text-text-quaternary text-xs font-medium', className)} {...rest}>·</div>
|
||||
<div className={classNames('text-xs font-medium text-text-quaternary', className)} {...rest}>·</div>
|
||||
)
|
||||
}
|
||||
/** Usage
|
||||
|
||||
@ -63,8 +63,8 @@ const Switch = (
|
||||
className={classNames(
|
||||
wrapStyle[size],
|
||||
enabled ? 'bg-components-toggle-bg' : 'bg-components-toggle-bg-unchecked',
|
||||
'relative inline-flex flex-shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
disabled ? '!opacity-50 !cursor-not-allowed' : '',
|
||||
'relative inline-flex shrink-0 cursor-pointer rounded-[5px] border-2 border-transparent transition-colors duration-200 ease-in-out',
|
||||
disabled ? '!cursor-not-allowed !opacity-50' : '',
|
||||
size === 'xs' && 'rounded-sm',
|
||||
className,
|
||||
)}
|
||||
@ -75,7 +75,7 @@ const Switch = (
|
||||
circleStyle[size],
|
||||
enabled ? translateLeft[size] : 'translate-x-0',
|
||||
size === 'xs' && 'rounded-[1px]',
|
||||
'pointer-events-none inline-block transform rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
|
||||
'pointer-events-none inline-block rounded-[3px] bg-components-toggle-knob shadow ring-0 transition duration-200 ease-in-out',
|
||||
)}
|
||||
/>
|
||||
</OriginalSwitch>
|
||||
|
||||
@ -25,8 +25,8 @@ const TabSliderNew: FC<TabSliderProps> = ({
|
||||
key={option.value}
|
||||
onClick={() => onChange(option.value)}
|
||||
className={cn(
|
||||
'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-components-main-nav-nav-button-bg-active',
|
||||
value === option.value && 'border-components-main-nav-nav-button-border bg-components-main-nav-nav-button-bg-active text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
'mr-1 flex h-[32px] cursor-pointer items-center rounded-lg border-[0.5px] border-transparent px-3 py-[7px] text-[13px] font-medium leading-[18px] text-text-tertiary hover:bg-state-base-hover',
|
||||
value === option.value && 'border-components-main-nav-nav-button-border bg-state-base-hover text-components-main-nav-nav-button-text-active shadow-xs',
|
||||
)}
|
||||
>
|
||||
{option.icon}
|
||||
|
||||
@ -31,10 +31,10 @@ const COLOR_MAP = {
|
||||
export default function Tag({ children, color = 'green', className = '', bordered = false, hideBg = false }: ITagProps) {
|
||||
return (
|
||||
<div className={
|
||||
classNames('px-2.5 py-px text-xs leading-5 rounded-md inline-flex items-center flex-shrink-0',
|
||||
classNames('inline-flex shrink-0 items-center rounded-md px-2.5 py-px text-xs leading-5',
|
||||
COLOR_MAP[color] ? `${COLOR_MAP[color].text} ${COLOR_MAP[color].bg}` : '',
|
||||
bordered ? 'border-[1px]' : '',
|
||||
hideBg ? 'bg-opacity-0' : '',
|
||||
hideBg ? 'bg-transparent' : '',
|
||||
className)} >
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@ -71,14 +71,14 @@ const Pricing: FC<Props> = ({
|
||||
{
|
||||
value: 'cloud',
|
||||
text: <div className={
|
||||
classNames('inline-flex items-center system-md-semibold-uppercase text-text-secondary',
|
||||
classNames('system-md-semibold-uppercase inline-flex items-center text-text-secondary',
|
||||
currentPlan === 'cloud' && 'text-text-accent-light-mode-only')} >
|
||||
<RiCloudFill className='mr-2 size-4' />{t('billing.plansCommon.cloud')}</div>,
|
||||
},
|
||||
{
|
||||
value: 'self',
|
||||
text: <div className={
|
||||
classNames('inline-flex items-center system-md-semibold-uppercase text-text-secondary',
|
||||
classNames('system-md-semibold-uppercase inline-flex items-center text-text-secondary',
|
||||
currentPlan === 'self' && 'text-text-accent-light-mode-only')}>
|
||||
<RiTerminalBoxFill className='mr-2 size-4' />{t('billing.plansCommon.self')}</div>,
|
||||
}]}
|
||||
|
||||
@ -70,7 +70,7 @@ const style = {
|
||||
priceTip: 'text-text-primary-on-surface',
|
||||
description: 'text-text-primary-on-surface',
|
||||
bg: 'border-effects-highlight bg-[#155AEF] text-text-primary-on-surface',
|
||||
btnStyle: 'bg-white bg-opacity-96 hover:opacity-85 border-[0.5px] border-components-button-secondary-border text-[#155AEF] shadow-xs',
|
||||
btnStyle: 'bg-white/96 hover:opacity-85 border-[0.5px] border-components-button-secondary-border text-[#155AEF] shadow-xs',
|
||||
values: 'text-text-primary-on-surface',
|
||||
tooltipIconColor: 'text-text-primary-on-surface',
|
||||
},
|
||||
|
||||
@ -17,15 +17,15 @@ export const StepperStep: FC<StepperStepProps> = (props) => {
|
||||
const label = isActive ? `STEP ${index + 1}` : `${index + 1}`
|
||||
return <div className='flex items-center gap-2'>
|
||||
<div className={classNames(
|
||||
'h-5 py-1 rounded-3xl flex-col justify-center items-center gap-2 inline-flex',
|
||||
'inline-flex h-5 flex-col items-center justify-center gap-2 rounded-3xl py-1',
|
||||
isActive
|
||||
? 'px-2 bg-state-accent-solid'
|
||||
? 'bg-state-accent-solid px-2'
|
||||
: !isDisabled
|
||||
? 'w-5 border border-text-quaternary'
|
||||
: 'w-5 border border-divider-deep',
|
||||
)}>
|
||||
<div className={classNames(
|
||||
'text-center system-2xs-semibold-uppercase',
|
||||
'system-2xs-semibold-uppercase text-center',
|
||||
isActive
|
||||
? 'text-text-primary-on-surface'
|
||||
: !isDisabled
|
||||
@ -37,7 +37,7 @@ export const StepperStep: FC<StepperStepProps> = (props) => {
|
||||
</div>
|
||||
<div className={classNames('system-xs-medium-uppercase',
|
||||
isActive
|
||||
? 'text-text-accent system-xs-semibold-uppercase'
|
||||
? 'system-xs-semibold-uppercase text-text-accent'
|
||||
: !isDisabled
|
||||
? 'text-text-tertiary'
|
||||
: 'text-text-quaternary',
|
||||
|
||||
@ -20,10 +20,10 @@ const FullScreenDrawer: FC<IFullScreenDrawerProps> = ({
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelClassName={classNames('!p-0 bg-components-panel-bg',
|
||||
panelClassName={classNames('bg-components-panel-bg !p-0',
|
||||
fullScreen
|
||||
? '!max-w-full !w-full'
|
||||
: 'mt-16 mr-2 mb-2 !max-w-[560px] !w-[560px] border-[0.5px] border-components-panel-border rounded-xl',
|
||||
? '!w-full !max-w-full'
|
||||
: 'mb-2 mr-2 mt-16 !w-[560px] !max-w-[560px] rounded-xl border-[0.5px] border-components-panel-border',
|
||||
)}
|
||||
mask={false}
|
||||
unmount
|
||||
|
||||
@ -286,7 +286,7 @@ const EmbeddingDetail: FC<IEmbeddingDetailProps> = ({
|
||||
{/* progress bar */}
|
||||
<div className={cn(
|
||||
'flex h-2 w-full items-center overflow-hidden rounded-md border border-components-progress-bar-border',
|
||||
isEmbedding ? 'bg-components-progress-bar-bg bg-opacity-50' : 'bg-components-progress-bar-bg',
|
||||
isEmbedding ? 'bg-components-progress-bar-bg/50' : 'bg-components-progress-bar-bg',
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react'
|
||||
import React from 'react'
|
||||
import type { FC } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { RiBookOpenLine } from '@remixicon/react'
|
||||
@ -28,10 +28,8 @@ const Form: FC<FormProps> = React.memo(({
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const docLink = useDocLink()
|
||||
const [changeKey, setChangeKey] = useState('')
|
||||
|
||||
const handleFormChange = (key: string, val: string) => {
|
||||
setChangeKey(key)
|
||||
if (key === 'name') {
|
||||
onChange({ ...value, [key]: val })
|
||||
}
|
||||
|
||||
@ -56,7 +56,7 @@ export const EditSlice: FC<EditSliceProps> = (props) => {
|
||||
return (
|
||||
<>
|
||||
<SliceContainer {...rest}
|
||||
className={classNames('block mr-0', className)}
|
||||
className={classNames('mr-0 block', className)}
|
||||
ref={(ref) => {
|
||||
refs.setReference(ref)
|
||||
if (ref)
|
||||
|
||||
@ -13,7 +13,7 @@ export const SliceContainer: FC<SliceContainerProps> = (
|
||||
) => {
|
||||
const { className, ...rest } = props
|
||||
return <span {...rest} ref={ref} className={classNames(
|
||||
'group align-bottom mr-1 select-none text-sm',
|
||||
'group mr-1 select-none align-bottom text-sm',
|
||||
className,
|
||||
)} />
|
||||
}
|
||||
@ -30,7 +30,7 @@ export const SliceLabel: FC<SliceLabelProps> = (
|
||||
const { className, children, labelInnerClassName, ...rest } = props
|
||||
return <span {...rest} ref={ref} className={classNames(
|
||||
baseStyle,
|
||||
'px-1 bg-state-base-hover-alt group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface uppercase text-text-tertiary',
|
||||
'bg-state-base-hover-alt px-1 uppercase text-text-tertiary group-hover:bg-state-accent-solid group-hover:text-text-primary-on-surface',
|
||||
className,
|
||||
)}>
|
||||
<span className={classNames('text-nowrap', labelInnerClassName)}>
|
||||
@ -51,7 +51,7 @@ export const SliceContent: FC<SliceContentProps> = (
|
||||
const { className, children, ...rest } = props
|
||||
return <span {...rest} ref={ref} className={classNames(
|
||||
baseStyle,
|
||||
'px-1 bg-state-base-hover group-hover:bg-state-accent-hover-alt group-hover:text-text-primary leading-7 whitespace-pre-line break-all',
|
||||
'whitespace-pre-line break-all bg-state-base-hover px-1 leading-7 group-hover:bg-state-accent-hover-alt group-hover:text-text-primary',
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
@ -70,7 +70,7 @@ export const SliceDivider: FC<SliceDividerProps> = (
|
||||
const { className, ...rest } = props
|
||||
return <span {...rest} ref={ref} className={classNames(
|
||||
baseStyle,
|
||||
'bg-state-base-active group-hover:bg-state-accent-solid text-sm px-[1px]',
|
||||
'bg-state-base-active px-[1px] text-sm group-hover:bg-state-accent-solid',
|
||||
className,
|
||||
)}>
|
||||
{/* use a zero-width space to make the hover area bigger */}
|
||||
|
||||
@ -29,8 +29,8 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [name, setName] = useState<string>(dataset.name)
|
||||
const [description, setDescription] = useState<string>(dataset.description)
|
||||
const [externalKnowledgeId] = useState<string>(dataset.external_knowledge_info.external_knowledge_id)
|
||||
const [externalKnowledgeApiId] = useState<string>(dataset.external_knowledge_info.external_knowledge_api_id)
|
||||
const externalKnowledgeId = dataset.external_knowledge_info.external_knowledge_id
|
||||
const externalKnowledgeApiId = dataset.external_knowledge_info.external_knowledge_api_id
|
||||
const [appIcon, setAppIcon] = useState<AppIconSelection>(
|
||||
dataset.icon_info?.icon_type === 'image'
|
||||
? { type: 'image' as const, url: dataset.icon_info?.icon_url || '', fileId: dataset.icon_info?.icon || '' }
|
||||
|
||||
@ -66,10 +66,10 @@ function CopyButton({ code }: { code: string }) {
|
||||
<button
|
||||
type="button"
|
||||
className={classNames(
|
||||
'group/button absolute top-3.5 right-4 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100',
|
||||
'group/button absolute right-4 top-3.5 overflow-hidden rounded-full py-1 pl-2 pr-3 text-2xs font-medium opacity-0 backdrop-blur transition focus:opacity-100 group-hover:opacity-100',
|
||||
copied
|
||||
? 'bg-emerald-400/10 ring-1 ring-inset ring-emerald-400/20'
|
||||
: 'bg-white/5 hover:bg-white/7.5 dark:bg-white/2.5 dark:hover:bg-white/5',
|
||||
: 'hover:bg-white/7.5 dark:bg-white/2.5 bg-white/5 dark:hover:bg-white/5',
|
||||
)}
|
||||
onClick={() => {
|
||||
writeTextToClipboard(code).then(() => {
|
||||
|
||||
@ -23,7 +23,9 @@ const SecretKeyGenerateModal = ({
|
||||
const { t } = useTranslation()
|
||||
return (
|
||||
<Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`px-8 ${className}`}>
|
||||
<XMarkIcon className={`absolute h-6 w-6 cursor-pointer text-text-tertiary ${s.close}`} onClick={onClose} />
|
||||
<div className="-mr-2 -mt-6 mb-4 flex justify-end">
|
||||
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
|
||||
</div>
|
||||
<p className='mt-1 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.generateTips')}</p>
|
||||
<div className='my-4'>
|
||||
<InputCopy className='w-full' value={newKey?.token} />
|
||||
|
||||
@ -84,7 +84,9 @@ const SecretKeyModal = ({
|
||||
|
||||
return (
|
||||
<Modal isShow={isShow} onClose={onClose} title={`${t('appApi.apiKeyModal.apiSecretKey')}`} className={`${s.customModal} flex flex-col px-8`}>
|
||||
<XMarkIcon className={`absolute h-6 w-6 cursor-pointer text-text-tertiary ${s.close}`} onClick={onClose} />
|
||||
<div className="-mr-2 -mt-6 mb-4 flex justify-end">
|
||||
<XMarkIcon className="h-6 w-6 cursor-pointer text-text-tertiary" onClick={onClose} />
|
||||
</div>
|
||||
<p className='mt-1 shrink-0 text-[13px] font-normal leading-5 text-text-tertiary'>{t('appApi.apiKeyModal.apiSecretKeyTips')}</p>
|
||||
{!apiKeysList && <div className='mt-4'><Loading /></div>}
|
||||
{
|
||||
|
||||
@ -277,6 +277,85 @@ The text generation application offers non-session support and is ideal for tran
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='File Preview'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
|
||||
|
||||
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
|
||||
|
||||
### Path Parameters
|
||||
- `file_id` (string) Required
|
||||
The unique identifier of the file to preview, obtained from the File Upload API response.
|
||||
|
||||
### Query Parameters
|
||||
- `as_attachment` (boolean) Optional
|
||||
Whether to force download the file as an attachment. Default is `false` (preview in browser).
|
||||
|
||||
### Response
|
||||
Returns the file content with appropriate headers for browser display or download.
|
||||
- `Content-Type` Set based on file mime type
|
||||
- `Content-Length` File size in bytes (if available)
|
||||
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
|
||||
- `Cache-Control` Caching headers for performance
|
||||
- `Accept-Ranges` Set to "bytes" for audio/video files
|
||||
|
||||
### Errors
|
||||
- 400, `invalid_param`, abnormal parameter input
|
||||
- 403, `file_access_denied`, file access denied or file does not belong to current application
|
||||
- 404, `file_not_found`, file not found or has been deleted
|
||||
- 500, internal server error
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### Request Example
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Download as Attachment
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Response Headers Example
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - Image Preview' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Download Response Headers
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - File Download' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/completion-messages/:task_id/stop'
|
||||
method='POST'
|
||||
|
||||
@ -276,6 +276,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='ファイルプレビュー'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
|
||||
|
||||
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
|
||||
|
||||
### パスパラメータ
|
||||
- `file_id` (string) 必須
|
||||
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
|
||||
|
||||
### クエリパラメータ
|
||||
- `as_attachment` (boolean) オプション
|
||||
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
|
||||
|
||||
### レスポンス
|
||||
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
|
||||
- `Content-Type` ファイル MIME タイプに基づいて設定
|
||||
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
|
||||
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
|
||||
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
|
||||
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
|
||||
|
||||
### エラー
|
||||
- 400, `invalid_param`, パラメータ入力異常
|
||||
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
|
||||
- 404, `file_not_found`, ファイルが見つからないか削除されています
|
||||
- 500, サーバー内部エラー
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### リクエスト例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 添付ファイルとしてダウンロード
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### レスポンスヘッダー例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'ヘッダー - 画像プレビュー' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### ファイルダウンロードレスポンスヘッダー
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/completion-messages/:task_id/stop'
|
||||
method='POST'
|
||||
|
||||
@ -252,6 +252,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='文件预览'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
|
||||
|
||||
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
|
||||
|
||||
### 路径参数
|
||||
- `file_id` (string) 必需
|
||||
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
|
||||
|
||||
### 查询参数
|
||||
- `as_attachment` (boolean) 可选
|
||||
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
|
||||
|
||||
### 响应
|
||||
返回带有适当浏览器显示或下载标头的文件内容。
|
||||
- `Content-Type` 根据文件 MIME 类型设置
|
||||
- `Content-Length` 文件大小(以字节为单位,如果可用)
|
||||
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
|
||||
- `Cache-Control` 用于性能的缓存标头
|
||||
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
|
||||
|
||||
### 错误
|
||||
- 400, `invalid_param`, 参数输入异常
|
||||
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
|
||||
- 404, `file_not_found`, 文件未找到或已被删除
|
||||
- 500, 服务内部错误
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### 请求示例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 作为附件下载
|
||||
<CodeGroup title="下载请求" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 响应标头示例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - 图片预览' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 文件下载响应标头
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - 文件下载' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/completion-messages/:task_id/stop'
|
||||
method='POST'
|
||||
|
||||
@ -392,6 +392,85 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='File Preview'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
|
||||
|
||||
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
|
||||
|
||||
### Path Parameters
|
||||
- `file_id` (string) Required
|
||||
The unique identifier of the file to preview, obtained from the File Upload API response.
|
||||
|
||||
### Query Parameters
|
||||
- `as_attachment` (boolean) Optional
|
||||
Whether to force download the file as an attachment. Default is `false` (preview in browser).
|
||||
|
||||
### Response
|
||||
Returns the file content with appropriate headers for browser display or download.
|
||||
- `Content-Type` Set based on file mime type
|
||||
- `Content-Length` File size in bytes (if available)
|
||||
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
|
||||
- `Cache-Control` Caching headers for performance
|
||||
- `Accept-Ranges` Set to "bytes" for audio/video files
|
||||
|
||||
### Errors
|
||||
- 400, `invalid_param`, abnormal parameter input
|
||||
- 403, `file_access_denied`, file access denied or file does not belong to current application
|
||||
- 404, `file_not_found`, file not found or has been deleted
|
||||
- 500, internal server error
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### Request Example
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Download as Attachment
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Response Headers Example
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - Image Preview' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Download Response Headers
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - File Download' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -653,7 +732,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
- `message_files` (array[object]) Message files
|
||||
- `id` (string) ID
|
||||
- `type` (string) File type, image for images
|
||||
- `url` (string) Preview image URL
|
||||
- `url` (string) File preview URL, use the File Preview API (`/files/{file_id}/preview`) to access the file
|
||||
- `belongs_to` (string) belongs to,user orassistant
|
||||
- `answer` (string) Response message content
|
||||
- `created_at` (timestamp) Creation timestamp, e.g., 1705395332
|
||||
|
||||
@ -392,6 +392,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='ファイルプレビュー'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
|
||||
|
||||
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
|
||||
|
||||
### パスパラメータ
|
||||
- `file_id` (string) 必須
|
||||
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
|
||||
|
||||
### クエリパラメータ
|
||||
- `as_attachment` (boolean) オプション
|
||||
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
|
||||
|
||||
### レスポンス
|
||||
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
|
||||
- `Content-Type` ファイル MIME タイプに基づいて設定
|
||||
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
|
||||
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
|
||||
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
|
||||
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
|
||||
|
||||
### エラー
|
||||
- 400, `invalid_param`, パラメータ入力異常
|
||||
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
|
||||
- 404, `file_not_found`, ファイルが見つからないか削除されています
|
||||
- 500, サーバー内部エラー
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### リクエスト例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL - ブラウザプレビュー' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 添付ファイルとしてダウンロード
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### レスポンスヘッダー例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'ヘッダー - 画像プレビュー' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### ダウンロードレスポンスヘッダー
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -654,7 +734,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
- `message_files` (array[object]) メッセージファイル
|
||||
- `id` (string) ID
|
||||
- `type` (string) ファイルタイプ、画像の場合はimage
|
||||
- `url` (string) プレビュー画像URL
|
||||
- `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI(`/files/{file_id}/preview`)を使用してください
|
||||
- `belongs_to` (string) 所属、userまたはassistant
|
||||
- `answer` (string) 応答メッセージ内容
|
||||
- `created_at` (timestamp) 作成タイムスタンプ、例:1705395332
|
||||
|
||||
@ -399,6 +399,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='文件预览'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
|
||||
|
||||
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
|
||||
|
||||
### 路径参数
|
||||
- `file_id` (string) 必需
|
||||
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
|
||||
|
||||
### 查询参数
|
||||
- `as_attachment` (boolean) 可选
|
||||
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
|
||||
|
||||
### 响应
|
||||
返回带有适当浏览器显示或下载标头的文件内容。
|
||||
- `Content-Type` 根据文件 MIME 类型设置
|
||||
- `Content-Length` 文件大小(以字节为单位,如果可用)
|
||||
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
|
||||
- `Cache-Control` 用于性能的缓存标头
|
||||
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
|
||||
|
||||
### 错误
|
||||
- 400, `invalid_param`, 参数输入异常
|
||||
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
|
||||
- 404, `file_not_found`, 文件未找到或已被删除
|
||||
- 500, 服务内部错误
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### 请求示例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 作为附件下载
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 响应标头示例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - 图片预览' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 文件下载响应标头
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - 文件下载' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -661,7 +741,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
- `message_files` (array[object]) 消息文件
|
||||
- `id` (string) ID
|
||||
- `type` (string) 文件类型,image 图片
|
||||
- `url` (string) 预览图片地址
|
||||
- `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件
|
||||
- `belongs_to` (string) 文件归属方,user 或 assistant
|
||||
- `answer` (string) 回答消息内容
|
||||
- `created_at` (timestamp) 创建时间
|
||||
|
||||
@ -356,6 +356,85 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='File Preview'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
|
||||
|
||||
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
|
||||
|
||||
### Path Parameters
|
||||
- `file_id` (string) Required
|
||||
The unique identifier of the file to preview, obtained from the File Upload API response.
|
||||
|
||||
### Query Parameters
|
||||
- `as_attachment` (boolean) Optional
|
||||
Whether to force download the file as an attachment. Default is `false` (preview in browser).
|
||||
|
||||
### Response
|
||||
Returns the file content with appropriate headers for browser display or download.
|
||||
- `Content-Type` Set based on file mime type
|
||||
- `Content-Length` File size in bytes (if available)
|
||||
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
|
||||
- `Cache-Control` Caching headers for performance
|
||||
- `Accept-Ranges` Set to "bytes" for audio/video files
|
||||
|
||||
### Errors
|
||||
- 400, `invalid_param`, abnormal parameter input
|
||||
- 403, `file_access_denied`, file access denied or file does not belong to current application
|
||||
- 404, `file_not_found`, file not found or has been deleted
|
||||
- 500, internal server error
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### Request Example
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Download as Attachment
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Response Headers Example
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - Image Preview' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Download Response Headers
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - File Download' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -617,7 +696,7 @@ Chat applications support session persistence, allowing previous chat history to
|
||||
- `message_files` (array[object]) Message files
|
||||
- `id` (string) ID
|
||||
- `type` (string) File type, image for images
|
||||
- `url` (string) Preview image URL
|
||||
- `url` (string) File preview URL, use the File Preview API (`/files/{file_id}/preview`) to access the file
|
||||
- `belongs_to` (string) belongs to,user or assistant
|
||||
- `agent_thoughts` (array[object]) Agent thought(Empty if it's a Basic Assistant)
|
||||
- `id` (string) Agent thought ID, every iteration has a unique agent thought ID
|
||||
|
||||
@ -356,6 +356,85 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='ファイルプレビュー'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
|
||||
|
||||
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
|
||||
|
||||
### パスパラメータ
|
||||
- `file_id` (string) 必須
|
||||
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
|
||||
|
||||
### クエリパラメータ
|
||||
- `as_attachment` (boolean) オプション
|
||||
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
|
||||
|
||||
### レスポンス
|
||||
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
|
||||
- `Content-Type` ファイル MIME タイプに基づいて設定
|
||||
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
|
||||
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
|
||||
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
|
||||
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
|
||||
|
||||
### エラー
|
||||
- 400, `invalid_param`, パラメータ入力異常
|
||||
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
|
||||
- 404, `file_not_found`, ファイルが見つからないか削除されています
|
||||
- 500, サーバー内部エラー
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### リクエスト例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 添付ファイルとしてダウンロード
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### レスポンスヘッダー例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - 画像プレビュー' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### ダウンロードレスポンスヘッダー
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - ファイルダウンロード' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -618,7 +697,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
- `message_files` (array[object]) メッセージファイル
|
||||
- `id` (string) ID
|
||||
- `type` (string) ファイルタイプ、画像の場合はimage
|
||||
- `url` (string) プレビュー画像URL
|
||||
- `url` (string) ファイルプレビューURL、ファイルアクセスにはファイルプレビューAPI(`/files/{file_id}/preview`)を使用してください
|
||||
- `belongs_to` (string) 所属、ユーザーまたはアシスタント
|
||||
- `agent_thoughts` (array[object]) エージェントの思考(基本アシスタントの場合は空)
|
||||
- `id` (string) エージェント思考ID、各反復には一意のエージェント思考IDがあります
|
||||
|
||||
@ -371,6 +371,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='文件预览'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
|
||||
|
||||
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
|
||||
|
||||
### 路径参数
|
||||
- `file_id` (string) 必需
|
||||
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
|
||||
|
||||
### 查询参数
|
||||
- `as_attachment` (boolean) 可选
|
||||
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
|
||||
|
||||
### 响应
|
||||
返回带有适当浏览器显示或下载标头的文件内容。
|
||||
- `Content-Type` 根据文件 MIME 类型设置
|
||||
- `Content-Length` 文件大小(以字节为单位,如果可用)
|
||||
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
|
||||
- `Cache-Control` 用于性能的缓存标头
|
||||
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
|
||||
|
||||
### 错误
|
||||
- 400, `invalid_param`, 参数输入异常
|
||||
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
|
||||
- 404, `file_not_found`, 文件未找到或已被删除
|
||||
- 500, 服务内部错误
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### 请求示例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 作为附件下载
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\\\\n--header 'Authorization: Bearer {api_key}' \\\\\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 响应标头示例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - 图片预览' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 文件下载响应标头
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - 文件下载' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/chat-messages/:task_id/stop'
|
||||
method='POST'
|
||||
@ -631,7 +711,7 @@ import { Row, Col, Properties, Property, Heading, SubProperty } from '../md.tsx'
|
||||
- `message_files` (array[object]) 消息文件
|
||||
- `id` (string) ID
|
||||
- `type` (string) 文件类型,image 图片
|
||||
- `url` (string) 预览图片地址
|
||||
- `url` (string) 文件预览地址,使用文件预览 API (`/files/{file_id}/preview`) 访问文件
|
||||
- `belongs_to` (string) 文件归属方,user 或 assistant
|
||||
- `agent_thoughts` (array[object]) Agent思考内容(仅Agent模式下不为空)
|
||||
- `id` (string) agent_thought ID,每一轮Agent迭代都会有一个唯一的id
|
||||
|
||||
@ -747,6 +747,86 @@ Workflow applications offers non-session support and is ideal for translation, a
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='File Preview'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
Preview or download uploaded files. This endpoint allows you to access files that have been previously uploaded via the File Upload API.
|
||||
|
||||
<i>Files can only be accessed if they belong to messages within the requesting application.</i>
|
||||
|
||||
### Path Parameters
|
||||
- `file_id` (string) Required
|
||||
The unique identifier of the file to preview, obtained from the File Upload API response.
|
||||
|
||||
### Query Parameters
|
||||
- `as_attachment` (boolean) Optional
|
||||
Whether to force download the file as an attachment. Default is `false` (preview in browser).
|
||||
|
||||
### Response
|
||||
Returns the file content with appropriate headers for browser display or download.
|
||||
- `Content-Type` Set based on file mime type
|
||||
- `Content-Length` File size in bytes (if available)
|
||||
- `Content-Disposition` Set to "attachment" if `as_attachment=true`
|
||||
- `Cache-Control` Caching headers for performance
|
||||
- `Accept-Ranges` Set to "bytes" for audio/video files
|
||||
|
||||
### Errors
|
||||
- 400, `invalid_param`, abnormal parameter input
|
||||
- 403, `file_access_denied`, file access denied or file does not belong to current application
|
||||
- 404, `file_not_found`, file not found or has been deleted
|
||||
- 500, internal server error
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### Request Example
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Download as Attachment
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### Response Headers Example
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - Image Preview' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### Download Response Headers
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - File Download' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/workflows/logs'
|
||||
method='GET'
|
||||
|
||||
@ -742,6 +742,86 @@ import { Row, Col, Properties, Property, Heading, SubProperty, Paragraph } from
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='ファイルプレビュー'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
アップロードされたファイルをプレビューまたはダウンロードします。このエンドポイントを使用すると、以前にファイルアップロード API でアップロードされたファイルにアクセスできます。
|
||||
|
||||
<i>ファイルは、リクエストしているアプリケーションのメッセージ範囲内にある場合のみアクセス可能です。</i>
|
||||
|
||||
### パスパラメータ
|
||||
- `file_id` (string) 必須
|
||||
プレビューするファイルの一意識別子。ファイルアップロード API レスポンスから取得します。
|
||||
|
||||
### クエリパラメータ
|
||||
- `as_attachment` (boolean) オプション
|
||||
ファイルを添付ファイルとして強制ダウンロードするかどうか。デフォルトは `false`(ブラウザでプレビュー)。
|
||||
|
||||
### レスポンス
|
||||
ブラウザ表示またはダウンロード用の適切なヘッダー付きでファイル内容を返します。
|
||||
- `Content-Type` ファイル MIME タイプに基づいて設定
|
||||
- `Content-Length` ファイルサイズ(バイト、利用可能な場合)
|
||||
- `Content-Disposition` `as_attachment=true` の場合は "attachment" に設定
|
||||
- `Cache-Control` パフォーマンス向上のためのキャッシュヘッダー
|
||||
- `Accept-Ranges` 音声/動画ファイルの場合は "bytes" に設定
|
||||
|
||||
### エラー
|
||||
- 400, `invalid_param`, パラメータ入力異常
|
||||
- 403, `file_access_denied`, ファイルアクセス拒否またはファイルが現在のアプリケーションに属していません
|
||||
- 404, `file_not_found`, ファイルが見つからないか削除されています
|
||||
- 500, サーバー内部エラー
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### リクエスト例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL - ブラウザプレビュー' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 添付ファイルとしてダウンロード
|
||||
<CodeGroup title="Download Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\n--header 'Authorization: Bearer {api_key}' \\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### レスポンスヘッダー例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'ヘッダー - 画像プレビュー' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### ダウンロードレスポンスヘッダー
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'ヘッダー - ファイルダウンロード' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/workflows/logs'
|
||||
method='GET'
|
||||
|
||||
@ -730,6 +730,85 @@ Workflow 应用无会话支持,适合用于翻译/文章写作/总结 AI 等
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/files/:file_id/preview'
|
||||
method='GET'
|
||||
title='文件预览'
|
||||
name='#file-preview'
|
||||
/>
|
||||
<Row>
|
||||
<Col>
|
||||
预览或下载已上传的文件。此端点允许您访问先前通过文件上传 API 上传的文件。
|
||||
|
||||
<i>文件只能在属于请求应用程序的消息范围内访问。</i>
|
||||
|
||||
### 路径参数
|
||||
- `file_id` (string) 必需
|
||||
要预览的文件的唯一标识符,从文件上传 API 响应中获得。
|
||||
|
||||
### 查询参数
|
||||
- `as_attachment` (boolean) 可选
|
||||
是否强制将文件作为附件下载。默认为 `false`(在浏览器中预览)。
|
||||
|
||||
### 响应
|
||||
返回带有适当浏览器显示或下载标头的文件内容。
|
||||
- `Content-Type` 根据文件 MIME 类型设置
|
||||
- `Content-Length` 文件大小(以字节为单位,如果可用)
|
||||
- `Content-Disposition` 如果 `as_attachment=true` 则设置为 "attachment"
|
||||
- `Cache-Control` 用于性能的缓存标头
|
||||
- `Accept-Ranges` 对于音频/视频文件设置为 "bytes"
|
||||
|
||||
### 错误
|
||||
- 400, `invalid_param`, 参数输入异常
|
||||
- 403, `file_access_denied`, 文件访问被拒绝或文件不属于当前应用程序
|
||||
- 404, `file_not_found`, 文件未找到或已被删除
|
||||
- 500, 服务内部错误
|
||||
|
||||
</Col>
|
||||
<Col sticky>
|
||||
### 请求示例
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \\\n--header 'Authorization: Bearer {api_key}'`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview' \
|
||||
--header 'Authorization: Bearer {api_key}'
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 作为附件下载
|
||||
<CodeGroup title="Request" tag="GET" label="/files/:file_id/preview?as_attachment=true" targetCode={`curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \\\\\\n--header 'Authorization: Bearer {api_key}' \\\\\\n--output downloaded_file.png`}>
|
||||
|
||||
```bash {{ title: 'cURL' }}
|
||||
curl -X GET '${props.appDetail.api_base_url}/files/72fa9618-8f89-4a37-9b33-7e1178a24a67/preview?as_attachment=true' \
|
||||
--header 'Authorization: Bearer {api_key}' \
|
||||
--output downloaded_file.png
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
### 响应标头示例
|
||||
<CodeGroup title="Response Headers">
|
||||
```http {{ title: 'Headers - 图片预览' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
### 文件下载响应标头
|
||||
<CodeGroup title="Download Response Headers">
|
||||
```http {{ title: 'Headers - 文件下载' }}
|
||||
Content-Type: image/png
|
||||
Content-Length: 1024
|
||||
Content-Disposition: attachment; filename*=UTF-8''example.png
|
||||
Cache-Control: public, max-age=3600
|
||||
```
|
||||
</CodeGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
---
|
||||
|
||||
<Heading
|
||||
url='/workflows/logs'
|
||||
method='GET'
|
||||
|
||||
@ -51,7 +51,6 @@ const Apps = ({
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const [currentType, setCurrentType] = useState<string>('')
|
||||
const [currCategory, setCurrCategory] = useTabSearchParams({
|
||||
defaultTab: allCategoriesEn,
|
||||
disableSearchParams: false,
|
||||
@ -74,28 +73,7 @@ const Apps = ({
|
||||
},
|
||||
)
|
||||
|
||||
const filteredList = useMemo(() => {
|
||||
if (currCategory === allCategoriesEn) {
|
||||
if (!currentType)
|
||||
return allList
|
||||
else if (currentType === 'chatbot')
|
||||
return allList.filter(item => (item.app.mode === 'chat' || item.app.mode === 'advanced-chat'))
|
||||
else if (currentType === 'agent')
|
||||
return allList.filter(item => (item.app.mode === 'agent-chat'))
|
||||
else
|
||||
return allList.filter(item => (item.app.mode === 'workflow'))
|
||||
}
|
||||
else {
|
||||
if (!currentType)
|
||||
return allList.filter(item => item.category === currCategory)
|
||||
else if (currentType === 'chatbot')
|
||||
return allList.filter(item => (item.app.mode === 'chat' || item.app.mode === 'advanced-chat') && item.category === currCategory)
|
||||
else if (currentType === 'agent')
|
||||
return allList.filter(item => (item.app.mode === 'agent-chat') && item.category === currCategory)
|
||||
else
|
||||
return allList.filter(item => (item.app.mode === 'workflow') && item.category === currCategory)
|
||||
}
|
||||
}, [currentType, currCategory, allCategoriesEn, allList])
|
||||
const filteredList = allList.filter(item => currCategory === allCategoriesEn || item.category === currCategory)
|
||||
|
||||
const searchFilteredList = useMemo(() => {
|
||||
if (!searchKeywords || !filteredList || filteredList.length === 0)
|
||||
|
||||
@ -49,7 +49,6 @@ const SideBar: FC<IExploreSideBarProps> = ({
|
||||
const segments = useSelectedLayoutSegments()
|
||||
const lastSegment = segments.slice(-1)[0]
|
||||
const isDiscoverySelected = lastSegment === 'apps'
|
||||
const isChatSelected = lastSegment === 'chat'
|
||||
const { installedApps, setInstalledApps, setIsFetchingInstalledApps } = useContext(ExploreContext)
|
||||
const { isFetching: isFetchingInstalledApps, data: ret, refetch: fetchInstalledAppList } = useGetInstalledApps()
|
||||
const { mutateAsync: uninstallApp } = useUninstallApp()
|
||||
|
||||
53
web/app/components/goto-anything/actions/app.tsx
Normal file
53
web/app/components/goto-anything/actions/app.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import type { ActionItem, AppSearchResult } from './types'
|
||||
import type { App } from '@/types/app'
|
||||
import { fetchAppList } from '@/service/apps'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import { AppTypeIcon } from '../../app/type-selector'
|
||||
import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
|
||||
const parser = (apps: App[]): AppSearchResult[] => {
|
||||
return apps.map(app => ({
|
||||
id: app.id,
|
||||
title: app.name,
|
||||
description: app.description,
|
||||
type: 'app' as const,
|
||||
path: getRedirectionPath(true, {
|
||||
id: app.id,
|
||||
mode: app.mode,
|
||||
}),
|
||||
icon: (
|
||||
<div className='relative shrink-0'>
|
||||
<AppIcon
|
||||
size='large'
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<AppTypeIcon wrapperClassName='absolute -bottom-0.5 -right-0.5 w-4 h-4 rounded-[4px] border border-divider-regular outline outline-components-panel-on-panel-item-bg'
|
||||
className='h-3 w-3' type={app.mode} />
|
||||
</div>
|
||||
),
|
||||
data: app,
|
||||
}))
|
||||
}
|
||||
|
||||
export const appAction: ActionItem = {
|
||||
key: '@app',
|
||||
shortcut: '@app',
|
||||
title: 'Search Applications',
|
||||
description: 'Search and navigate to your applications',
|
||||
// action,
|
||||
search: async (_, searchTerm = '', locale) => {
|
||||
const response = (await fetchAppList({
|
||||
url: 'apps',
|
||||
params: {
|
||||
page: 1,
|
||||
name: searchTerm,
|
||||
},
|
||||
}))
|
||||
const apps = response.data || []
|
||||
|
||||
return parser(apps)
|
||||
},
|
||||
}
|
||||
68
web/app/components/goto-anything/actions/index.ts
Normal file
68
web/app/components/goto-anything/actions/index.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { appAction } from './app'
|
||||
import { knowledgeAction } from './knowledge'
|
||||
import { pluginAction } from './plugin'
|
||||
import { workflowNodesAction } from './workflow-nodes'
|
||||
import type { ActionItem, SearchResult } from './types'
|
||||
|
||||
export const Actions = {
|
||||
app: appAction,
|
||||
knowledge: knowledgeAction,
|
||||
plugin: pluginAction,
|
||||
node: workflowNodesAction,
|
||||
}
|
||||
|
||||
export const searchAnything = async (
|
||||
locale: string,
|
||||
query: string,
|
||||
actionItem?: ActionItem,
|
||||
): Promise<SearchResult[]> => {
|
||||
if (actionItem) {
|
||||
const searchTerm = query.replace(actionItem.key, '').replace(actionItem.shortcut, '').trim()
|
||||
return await actionItem.search(query, searchTerm, locale)
|
||||
}
|
||||
|
||||
if (query.startsWith('@'))
|
||||
return []
|
||||
|
||||
// Use Promise.allSettled to handle partial failures gracefully
|
||||
const searchPromises = Object.values(Actions).map(async (action) => {
|
||||
try {
|
||||
const results = await action.search(query, query, locale)
|
||||
return { success: true, data: results, actionType: action.key }
|
||||
}
|
||||
catch (error) {
|
||||
console.warn(`Search failed for ${action.key}:`, error)
|
||||
return { success: false, data: [], actionType: action.key, error }
|
||||
}
|
||||
})
|
||||
|
||||
const settledResults = await Promise.allSettled(searchPromises)
|
||||
|
||||
const allResults: SearchResult[] = []
|
||||
const failedActions: string[] = []
|
||||
|
||||
settledResults.forEach((result, index) => {
|
||||
if (result.status === 'fulfilled' && result.value.success) {
|
||||
allResults.push(...result.value.data)
|
||||
}
|
||||
else {
|
||||
const actionKey = Object.values(Actions)[index]?.key || 'unknown'
|
||||
failedActions.push(actionKey)
|
||||
}
|
||||
})
|
||||
|
||||
if (failedActions.length > 0)
|
||||
console.warn(`Some search actions failed: ${failedActions.join(', ')}`)
|
||||
|
||||
return allResults
|
||||
}
|
||||
|
||||
export const matchAction = (query: string, actions: Record<string, ActionItem>) => {
|
||||
return Object.values(actions).find((action) => {
|
||||
const reg = new RegExp(`^(${action.key}|${action.shortcut})(?:\\s|$)`)
|
||||
return reg.test(query)
|
||||
})
|
||||
}
|
||||
|
||||
export * from './types'
|
||||
export { appAction, knowledgeAction, pluginAction, workflowNodesAction }
|
||||
50
web/app/components/goto-anything/actions/knowledge.tsx
Normal file
50
web/app/components/goto-anything/actions/knowledge.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import type { ActionItem, KnowledgeSearchResult } from './types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import { fetchDatasets } from '@/service/datasets'
|
||||
import { Folder } from '../../base/icons/src/vender/solid/files'
|
||||
import cn from '@/utils/classnames'
|
||||
|
||||
const EXTERNAL_PROVIDER = 'external' as const
|
||||
const isExternalProvider = (provider: string): boolean => provider === EXTERNAL_PROVIDER
|
||||
|
||||
const parser = (datasets: DataSet[]): KnowledgeSearchResult[] => {
|
||||
return datasets.map((dataset) => {
|
||||
const path = isExternalProvider(dataset.provider) ? `/datasets/${dataset.id}/hitTesting` : `/datasets/${dataset.id}/documents`
|
||||
return {
|
||||
id: dataset.id,
|
||||
title: dataset.name,
|
||||
description: dataset.description,
|
||||
type: 'knowledge' as const,
|
||||
path,
|
||||
icon: (
|
||||
<div className={cn(
|
||||
'flex shrink-0 items-center justify-center rounded-md border-[0.5px] border-[#E0EAFF] bg-[#F5F8FF] p-2.5',
|
||||
!dataset.embedding_available && 'opacity-50 hover:opacity-100',
|
||||
)}>
|
||||
<Folder className='h-5 w-5 text-[#444CE7]' />
|
||||
</div>
|
||||
),
|
||||
data: dataset,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const knowledgeAction: ActionItem = {
|
||||
key: '@knowledge',
|
||||
shortcut: '@kb',
|
||||
title: 'Search Knowledge Bases',
|
||||
description: 'Search and navigate to your knowledge bases',
|
||||
// action,
|
||||
search: async (_, searchTerm = '', locale) => {
|
||||
const response = await fetchDatasets({
|
||||
url: '/datasets',
|
||||
params: {
|
||||
page: 1,
|
||||
limit: 10,
|
||||
keyword: searchTerm,
|
||||
},
|
||||
})
|
||||
|
||||
return parser(response.data)
|
||||
},
|
||||
}
|
||||
41
web/app/components/goto-anything/actions/plugin.tsx
Normal file
41
web/app/components/goto-anything/actions/plugin.tsx
Normal file
@ -0,0 +1,41 @@
|
||||
import type { ActionItem, PluginSearchResult } from './types'
|
||||
import { renderI18nObject } from '@/i18n-config'
|
||||
import Icon from '../../plugins/card/base/card-icon'
|
||||
import { postMarketplace } from '@/service/base'
|
||||
import type { Plugin, PluginsFromMarketplaceResponse } from '../../plugins/types'
|
||||
import { getPluginIconInMarketplace } from '../../plugins/marketplace/utils'
|
||||
|
||||
const parser = (plugins: Plugin[], locale: string): PluginSearchResult[] => {
|
||||
return plugins.map((plugin) => {
|
||||
return {
|
||||
id: plugin.name,
|
||||
title: renderI18nObject(plugin.label, locale) || plugin.name,
|
||||
description: renderI18nObject(plugin.brief, locale) || '',
|
||||
type: 'plugin' as const,
|
||||
icon: <Icon src={plugin.icon} />,
|
||||
data: plugin,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export const pluginAction: ActionItem = {
|
||||
key: '@plugin',
|
||||
shortcut: '@plugin',
|
||||
title: 'Search Plugins',
|
||||
description: 'Search and navigate to your plugins',
|
||||
search: async (_, searchTerm = '', locale) => {
|
||||
const response = await postMarketplace<{ data: PluginsFromMarketplaceResponse }>('/plugins/search/advanced', {
|
||||
body: {
|
||||
page: 1,
|
||||
page_size: 10,
|
||||
query: searchTerm,
|
||||
type: 'plugin',
|
||||
},
|
||||
})
|
||||
const list = (response.data.plugins || []).map(plugin => ({
|
||||
...plugin,
|
||||
icon: getPluginIconInMarketplace(plugin),
|
||||
}))
|
||||
return parser(list, locale!)
|
||||
},
|
||||
}
|
||||
54
web/app/components/goto-anything/actions/types.ts
Normal file
54
web/app/components/goto-anything/actions/types.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { TypeWithI18N } from '../../base/form/types'
|
||||
import type { App } from '@/types/app'
|
||||
import type { Plugin } from '../../plugins/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { CommonNodeType } from '../../workflow/types'
|
||||
|
||||
export type SearchResultType = 'app' | 'knowledge' | 'plugin' | 'workflow-node'
|
||||
|
||||
export type BaseSearchResult<T = any> = {
|
||||
id: string
|
||||
title: string
|
||||
description?: string
|
||||
type: SearchResultType
|
||||
path?: string
|
||||
icon?: ReactNode
|
||||
data: T
|
||||
}
|
||||
|
||||
export type AppSearchResult = {
|
||||
type: 'app'
|
||||
} & BaseSearchResult<App>
|
||||
|
||||
export type PluginSearchResult = {
|
||||
type: 'plugin'
|
||||
} & BaseSearchResult<Plugin>
|
||||
|
||||
export type KnowledgeSearchResult = {
|
||||
type: 'knowledge'
|
||||
} & BaseSearchResult<DataSet>
|
||||
|
||||
export type WorkflowNodeSearchResult = {
|
||||
type: 'workflow-node'
|
||||
metadata?: {
|
||||
nodeId: string
|
||||
nodeData: CommonNodeType
|
||||
}
|
||||
} & BaseSearchResult<CommonNodeType>
|
||||
|
||||
export type SearchResult = AppSearchResult | PluginSearchResult | KnowledgeSearchResult | WorkflowNodeSearchResult
|
||||
|
||||
export type ActionItem = {
|
||||
key: '@app' | '@knowledge' | '@plugin' | '@node'
|
||||
shortcut: string
|
||||
title: string | TypeWithI18N
|
||||
description: string
|
||||
action?: (data: SearchResult) => void
|
||||
searchFn?: (searchTerm: string) => SearchResult[]
|
||||
search: (
|
||||
query: string,
|
||||
searchTerm: string,
|
||||
locale?: string,
|
||||
) => (Promise<SearchResult[]> | SearchResult[])
|
||||
}
|
||||
44
web/app/components/goto-anything/actions/workflow-nodes.tsx
Normal file
44
web/app/components/goto-anything/actions/workflow-nodes.tsx
Normal file
@ -0,0 +1,44 @@
|
||||
import type { ActionItem } from './types'
|
||||
import { BoltIcon } from '@heroicons/react/24/outline'
|
||||
import i18n from 'i18next'
|
||||
|
||||
// Create the workflow nodes action
|
||||
export const workflowNodesAction: ActionItem = {
|
||||
key: '@node',
|
||||
shortcut: '@node',
|
||||
title: 'Search Workflow Nodes',
|
||||
description: 'Find and jump to nodes in the current workflow by name or type',
|
||||
searchFn: undefined, // Will be set by useWorkflowSearch hook
|
||||
search: async (_, searchTerm = '', locale) => {
|
||||
try {
|
||||
// Use the searchFn if available (set by useWorkflowSearch hook)
|
||||
if (workflowNodesAction.searchFn) {
|
||||
// searchFn already returns SearchResult[] type, no need to use parser
|
||||
return workflowNodesAction.searchFn(searchTerm)
|
||||
}
|
||||
|
||||
// If not in workflow context or search function not registered
|
||||
if (!searchTerm.trim()) {
|
||||
return [{
|
||||
id: 'help',
|
||||
title: i18n.t('app.gotoAnything.actions.searchWorkflowNodes', { lng: locale }),
|
||||
description: i18n.t('app.gotoAnything.actions.searchWorkflowNodesHelp', { lng: locale }),
|
||||
type: 'workflow-node',
|
||||
path: '#',
|
||||
data: {} as any,
|
||||
icon: (
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-blue-50 text-blue-600">
|
||||
<BoltIcon className="h-5 w-5" />
|
||||
</div>
|
||||
),
|
||||
}]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Error searching workflow nodes:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
}
|
||||
50
web/app/components/goto-anything/context.tsx
Normal file
50
web/app/components/goto-anything/context.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import React, { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* Interface for the GotoAnything context
|
||||
*/
|
||||
type GotoAnythingContextType = {
|
||||
/**
|
||||
* Whether the current page is a workflow page
|
||||
*/
|
||||
isWorkflowPage: boolean
|
||||
}
|
||||
|
||||
// Create context with default values
|
||||
const GotoAnythingContext = createContext<GotoAnythingContextType>({
|
||||
isWorkflowPage: false,
|
||||
})
|
||||
|
||||
/**
|
||||
* Hook to use the GotoAnything context
|
||||
*/
|
||||
export const useGotoAnythingContext = () => useContext(GotoAnythingContext)
|
||||
|
||||
type GotoAnythingProviderProps = {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider component for GotoAnything context
|
||||
*/
|
||||
export const GotoAnythingProvider: React.FC<GotoAnythingProviderProps> = ({ children }) => {
|
||||
const [isWorkflowPage, setIsWorkflowPage] = useState(false)
|
||||
const pathname = usePathname()
|
||||
|
||||
// Update context based on current pathname
|
||||
useEffect(() => {
|
||||
// Check if current path contains workflow
|
||||
const isWorkflow = pathname?.includes('/workflow') || false
|
||||
setIsWorkflowPage(isWorkflow)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<GotoAnythingContext.Provider value={{ isWorkflowPage }}>
|
||||
{children}
|
||||
</GotoAnythingContext.Provider>
|
||||
)
|
||||
}
|
||||
395
web/app/components/goto-anything/index.tsx
Normal file
395
web/app/components/goto-anything/index.tsx
Normal file
@ -0,0 +1,395 @@
|
||||
'use client'
|
||||
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import Modal from '@/app/components/base/modal'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { useDebounce, useKeyPress } from 'ahooks'
|
||||
import { getKeyboardKeyCodeBySystem, isEventTargetInputArea, isMac } from '@/app/components/workflow/utils/common'
|
||||
import { selectWorkflowNode } from '@/app/components/workflow/utils/node-navigation'
|
||||
import { RiSearchLine } from '@remixicon/react'
|
||||
import { Actions as AllActions, type SearchResult, matchAction, searchAnything } from './actions'
|
||||
import { GotoAnythingProvider, useGotoAnythingContext } from './context'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useGetLanguage } from '@/context/i18n'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import InstallFromMarketplace from '../plugins/install-plugin/install-from-marketplace'
|
||||
import type { Plugin } from '../plugins/types'
|
||||
import { Command } from 'cmdk'
|
||||
|
||||
type Props = {
|
||||
onHide?: () => void
|
||||
}
|
||||
const GotoAnything: FC<Props> = ({
|
||||
onHide,
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const defaultLocale = useGetLanguage()
|
||||
const { isWorkflowPage } = useGotoAnythingContext()
|
||||
const { t } = useTranslation()
|
||||
const [show, setShow] = useState<boolean>(false)
|
||||
const [searchQuery, setSearchQuery] = useState<string>('')
|
||||
const [cmdVal, setCmdVal] = useState<string>('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
// Filter actions based on context
|
||||
const Actions = useMemo(() => {
|
||||
// Create a filtered copy of actions based on current page context
|
||||
if (isWorkflowPage) {
|
||||
// Include all actions on workflow pages
|
||||
return AllActions
|
||||
}
|
||||
else {
|
||||
// Exclude node action on non-workflow pages
|
||||
const { app, knowledge, plugin } = AllActions
|
||||
return { app, knowledge, plugin }
|
||||
}
|
||||
}, [isWorkflowPage])
|
||||
|
||||
const [activePlugin, setActivePlugin] = useState<Plugin>()
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleToggleModal = useCallback((e: KeyboardEvent) => {
|
||||
// Allow closing when modal is open, even if focus is in the search input
|
||||
if (!show && isEventTargetInputArea(e.target as HTMLElement))
|
||||
return
|
||||
e.preventDefault()
|
||||
setShow((prev) => {
|
||||
if (!prev) {
|
||||
// Opening modal - reset search state
|
||||
setSearchQuery('')
|
||||
}
|
||||
return !prev
|
||||
})
|
||||
}, [show])
|
||||
|
||||
useKeyPress(`${getKeyboardKeyCodeBySystem('ctrl')}.k`, handleToggleModal, {
|
||||
exactMatch: true,
|
||||
useCapture: true,
|
||||
})
|
||||
|
||||
useKeyPress(['esc'], (e) => {
|
||||
if (show) {
|
||||
e.preventDefault()
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
}
|
||||
})
|
||||
|
||||
const searchQueryDebouncedValue = useDebounce(searchQuery.trim(), {
|
||||
wait: 300,
|
||||
})
|
||||
|
||||
const searchMode = useMemo(() => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return action ? action.key : 'general'
|
||||
}, [searchQueryDebouncedValue, Actions])
|
||||
|
||||
const { data: searchResults = [], isLoading, isError, error } = useQuery(
|
||||
{
|
||||
queryKey: [
|
||||
'goto-anything',
|
||||
'search-result',
|
||||
searchQueryDebouncedValue,
|
||||
searchMode,
|
||||
isWorkflowPage,
|
||||
defaultLocale,
|
||||
Object.keys(Actions).sort().join(','),
|
||||
],
|
||||
queryFn: async () => {
|
||||
const query = searchQueryDebouncedValue.toLowerCase()
|
||||
const action = matchAction(query, Actions)
|
||||
return await searchAnything(defaultLocale, query, action)
|
||||
},
|
||||
enabled: !!searchQueryDebouncedValue,
|
||||
staleTime: 30000,
|
||||
gcTime: 300000,
|
||||
},
|
||||
)
|
||||
|
||||
// Handle navigation to selected result
|
||||
const handleNavigate = useCallback((result: SearchResult) => {
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
|
||||
switch (result.type) {
|
||||
case 'plugin':
|
||||
setActivePlugin(result.data)
|
||||
break
|
||||
case 'workflow-node':
|
||||
// Handle workflow node selection and navigation
|
||||
if (result.metadata?.nodeId)
|
||||
selectWorkflowNode(result.metadata.nodeId, true)
|
||||
|
||||
break
|
||||
default:
|
||||
if (result.path)
|
||||
router.push(result.path)
|
||||
}
|
||||
}, [router])
|
||||
|
||||
// Group results by type
|
||||
const groupedResults = useMemo(() => searchResults.reduce((acc, result) => {
|
||||
if (!acc[result.type])
|
||||
acc[result.type] = []
|
||||
|
||||
acc[result.type].push(result)
|
||||
return acc
|
||||
}, {} as { [key: string]: SearchResult[] }),
|
||||
[searchResults])
|
||||
|
||||
const emptyResult = useMemo(() => {
|
||||
if (searchResults.length || !searchQueryDebouncedValue.trim() || isLoading)
|
||||
return null
|
||||
|
||||
const isCommandSearch = searchMode !== 'general'
|
||||
const commandType = isCommandSearch ? searchMode.replace('@', '') : ''
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className='text-sm font-medium text-red-500'>{t('app.gotoAnything.searchTemporarilyUnavailable')}</div>
|
||||
<div className='mt-1 text-xs text-text-quaternary'>
|
||||
{t('app.gotoAnything.servicesUnavailableMessage')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className='text-sm font-medium'>
|
||||
{isCommandSearch
|
||||
? (() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
app: 'app.gotoAnything.emptyState.noAppsFound',
|
||||
plugin: 'app.gotoAnything.emptyState.noPluginsFound',
|
||||
knowledge: 'app.gotoAnything.emptyState.noKnowledgeBasesFound',
|
||||
node: 'app.gotoAnything.emptyState.noWorkflowNodesFound',
|
||||
}
|
||||
return t(keyMap[commandType] || 'app.gotoAnything.noResults')
|
||||
})()
|
||||
: t('app.gotoAnything.noResults')
|
||||
}
|
||||
</div>
|
||||
<div className='mt-1 text-xs text-text-quaternary'>
|
||||
{isCommandSearch
|
||||
? t('app.gotoAnything.emptyState.tryDifferentTerm', { mode: searchMode })
|
||||
: t('app.gotoAnything.emptyState.trySpecificSearch', { shortcuts: Object.values(Actions).map(action => action.shortcut).join(', ') })
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}, [searchResults, searchQueryDebouncedValue, Actions, searchMode, isLoading, isError])
|
||||
|
||||
const defaultUI = useMemo(() => {
|
||||
if (searchQueryDebouncedValue.trim())
|
||||
return null
|
||||
|
||||
return (<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className='text-sm font-medium'>{t('app.gotoAnything.searchTitle')}</div>
|
||||
<div className='mt-3 space-y-2 text-xs text-text-quaternary'>
|
||||
{Object.values(Actions).map(action => (
|
||||
<div key={action.key} className='flex items-center gap-2'>
|
||||
<span className='inline-flex items-center rounded bg-gray-200 px-2 py-1 font-mono text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-200'>{action.shortcut}</span>
|
||||
<span>{(() => {
|
||||
const keyMap: Record<string, string> = {
|
||||
'@app': 'app.gotoAnything.actions.searchApplicationsDesc',
|
||||
'@plugin': 'app.gotoAnything.actions.searchPluginsDesc',
|
||||
'@knowledge': 'app.gotoAnything.actions.searchKnowledgeBasesDesc',
|
||||
'@node': 'app.gotoAnything.actions.searchWorkflowNodesDesc',
|
||||
}
|
||||
return t(keyMap[action.key])
|
||||
})()}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>)
|
||||
}, [searchQueryDebouncedValue, Actions])
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
return () => {
|
||||
setCmdVal('')
|
||||
}
|
||||
}, [show])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
isShow={show}
|
||||
onClose={() => {
|
||||
setShow(false)
|
||||
setSearchQuery('')
|
||||
onHide?.()
|
||||
}}
|
||||
closable={false}
|
||||
className='!w-[480px] !p-0'
|
||||
>
|
||||
<div className='flex flex-col rounded-2xl border border-components-panel-border bg-components-panel-bg shadow-xl'>
|
||||
<Command
|
||||
className='outline-none'
|
||||
value={cmdVal}
|
||||
onValueChange={setCmdVal}
|
||||
>
|
||||
<div className='flex items-center gap-3 border-b border-divider-subtle bg-components-panel-bg-blur px-4 py-3'>
|
||||
<RiSearchLine className='h-4 w-4 text-text-quaternary' />
|
||||
<div className='flex flex-1 items-center gap-2'>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={searchQuery}
|
||||
placeholder={t('app.gotoAnything.searchPlaceholder')}
|
||||
onChange={(e) => {
|
||||
setCmdVal('')
|
||||
setSearchQuery(e.target.value)
|
||||
}}
|
||||
className='flex-1 !border-0 !bg-transparent !shadow-none'
|
||||
wrapperClassName='flex-1 !border-0 !bg-transparent'
|
||||
autoFocus
|
||||
/>
|
||||
{searchMode !== 'general' && (
|
||||
<div className='flex items-center gap-1 rounded bg-blue-50 px-2 py-[2px] text-xs font-medium text-blue-600 dark:bg-blue-900/40 dark:text-blue-300'>
|
||||
<span>{searchMode.replace('@', '').toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs text-text-quaternary'>
|
||||
<span className='system-kbd rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
|
||||
{isMac() ? '⌘' : 'Ctrl'}
|
||||
</span>
|
||||
<span className='system-kbd ml-1 rounded bg-gray-200 px-1 py-[2px] font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-100'>
|
||||
K
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Command.List className='max-h-[275px] min-h-[240px] overflow-y-auto'>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-gray-300 border-t-gray-600"></div>
|
||||
<span className="text-sm">{t('app.gotoAnything.searching')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div className="flex items-center justify-center py-8 text-center text-text-tertiary">
|
||||
<div>
|
||||
<div className="text-sm font-medium text-red-500">{t('app.gotoAnything.searchFailed')}</div>
|
||||
<div className="mt-1 text-xs text-text-quaternary">
|
||||
{error.message}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !isError && (
|
||||
<>
|
||||
{Object.entries(groupedResults).map(([type, results], groupIndex) => (
|
||||
<Command.Group key={groupIndex} heading={(() => {
|
||||
const typeMap: Record<string, string> = {
|
||||
'app': 'app.gotoAnything.groups.apps',
|
||||
'plugin': 'app.gotoAnything.groups.plugins',
|
||||
'knowledge': 'app.gotoAnything.groups.knowledgeBases',
|
||||
'workflow-node': 'app.gotoAnything.groups.workflowNodes',
|
||||
}
|
||||
return t(typeMap[type] || `${type}s`)
|
||||
})()} className='p-2 capitalize text-text-secondary'>
|
||||
{results.map(result => (
|
||||
<Command.Item
|
||||
key={`${result.type}-${result.id}`}
|
||||
value={result.title}
|
||||
className='flex cursor-pointer items-center gap-3 rounded-md p-3 will-change-[background-color] aria-[selected=true]:bg-state-base-hover data-[selected=true]:bg-state-base-hover'
|
||||
onSelect={() => handleNavigate(result)}
|
||||
>
|
||||
{result.icon}
|
||||
<div className='min-w-0 flex-1'>
|
||||
<div className='truncate font-medium text-text-secondary'>
|
||||
{result.title}
|
||||
</div>
|
||||
{result.description && (
|
||||
<div className='mt-0.5 truncate text-xs text-text-quaternary'>
|
||||
{result.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className='text-xs capitalize text-text-quaternary'>
|
||||
{result.type}
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</Command.Group>
|
||||
))}
|
||||
{emptyResult}
|
||||
{defaultUI}
|
||||
</>
|
||||
)}
|
||||
</Command.List>
|
||||
|
||||
{(!!searchResults.length || isError) && (
|
||||
<div className='border-t border-divider-subtle bg-components-panel-bg-blur px-4 py-2 text-xs text-text-tertiary'>
|
||||
<div className='flex items-center justify-between'>
|
||||
<span>
|
||||
{isError ? (
|
||||
<span className='text-red-500'>{t('app.gotoAnything.someServicesUnavailable')}</span>
|
||||
) : (
|
||||
<>
|
||||
{t('app.gotoAnything.resultCount', { count: searchResults.length })}
|
||||
{searchMode !== 'general' && (
|
||||
<span className='ml-2 opacity-60'>
|
||||
{t('app.gotoAnything.inScope', { scope: searchMode.replace('@', '') })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
<span className='opacity-60'>
|
||||
{searchMode !== 'general'
|
||||
? t('app.gotoAnything.clearToSearchAll')
|
||||
: t('app.gotoAnything.useAtForSpecific')
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Command>
|
||||
</div>
|
||||
|
||||
</Modal>
|
||||
{
|
||||
activePlugin && (
|
||||
<InstallFromMarketplace
|
||||
manifest={activePlugin}
|
||||
uniqueIdentifier={activePlugin.latest_package_identifier}
|
||||
onClose={() => setActivePlugin(undefined)}
|
||||
onSuccess={() => setActivePlugin(undefined)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GotoAnything component with context provider
|
||||
*/
|
||||
const GotoAnythingWithContext: FC<Props> = (props) => {
|
||||
return (
|
||||
<GotoAnythingProvider>
|
||||
<GotoAnything {...props} />
|
||||
</GotoAnythingProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default GotoAnythingWithContext
|
||||
@ -35,7 +35,7 @@ export default function IntegrationsPage() {
|
||||
{
|
||||
integrates.map(integrate => (
|
||||
<div key={integrate.provider} className='mb-2 flex items-center rounded-lg border-[0.5px] border-gray-200 bg-gray-50 px-3 py-2'>
|
||||
<div className={classNames('w-8 h-8 mr-3 bg-white rounded-lg border border-gray-100', s[`${integrate.provider}-icon`])} />
|
||||
<div className={classNames('mr-3 h-8 w-8 rounded-lg border border-gray-100 bg-white', s[`${integrate.provider}-icon`])} />
|
||||
<div className='grow'>
|
||||
<div className='text-sm font-medium leading-[21px] text-gray-800'>{integrateMap[integrate.provider].name}</div>
|
||||
<div className='text-xs font-normal leading-[18px] text-gray-500'>{integrateMap[integrate.provider].description}</div>
|
||||
|
||||
@ -25,7 +25,7 @@ const Collapse = ({
|
||||
const toggle = () => setOpen(!open)
|
||||
|
||||
return (
|
||||
<div className={classNames('bg-background-section-burn rounded-xl overflow-hidden', wrapperClassName)}>
|
||||
<div className={classNames('overflow-hidden rounded-xl bg-background-section-burn', wrapperClassName)}>
|
||||
<div className='flex cursor-pointer items-center justify-between px-3 py-2 text-xs font-medium leading-[18px] text-text-secondary' onClick={toggle}>
|
||||
{title}
|
||||
{
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useState } from 'react'
|
||||
import { t } from 'i18next'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import s from './index.module.css'
|
||||
import type { SuccessInvitationResult } from '.'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import { randomString } from '@/utils'
|
||||
|
||||
type IInvitationLinkProps = {
|
||||
value: SuccessInvitationResult
|
||||
@ -15,7 +14,6 @@ const InvitationLink = ({
|
||||
value,
|
||||
}: IInvitationLinkProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false)
|
||||
const selector = useRef(`invite-link-${randomString(4)}`)
|
||||
|
||||
const copyHandle = useCallback(() => {
|
||||
// No prefix is needed here because the backend has already processed it
|
||||
|
||||
@ -11,7 +11,7 @@ const ModelBadge: FC<ModelBadgeProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex items-center px-1 h-[18px] rounded-[5px] border border-divider-deep system-2xs-medium-uppercase text-text-tertiary cursor-default',
|
||||
'system-2xs-medium-uppercase flex h-[18px] cursor-default items-center rounded-[5px] border border-divider-deep px-1 text-text-tertiary',
|
||||
className,
|
||||
)}>
|
||||
{children}
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ModelParameterRule } from '../declarations'
|
||||
import { useLanguage } from '../hooks'
|
||||
import { isNullOrUndefined } from '../utils'
|
||||
import cn from '@/utils/classnames'
|
||||
import Switch from '@/app/components/base/switch'
|
||||
@ -10,7 +9,6 @@ import Slider from '@/app/components/base/slider'
|
||||
import Radio from '@/app/components/base/radio'
|
||||
import { SimpleSelect } from '@/app/components/base/select'
|
||||
import TagInput from '@/app/components/base/tag-input'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export type ParameterValue = number | string | string[] | boolean | undefined
|
||||
|
||||
@ -28,8 +26,6 @@ const ParameterItem: FC<ParameterItemProps> = ({
|
||||
onSwitch,
|
||||
isInWorkflow,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const language = useLanguage()
|
||||
const [localValue, setLocalValue] = useState(value)
|
||||
const numberInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
||||
@ -145,7 +145,7 @@ const ModelLoadBalancingConfigs = ({
|
||||
<>
|
||||
<div
|
||||
className={classNames(
|
||||
'min-h-16 bg-components-panel-bg border rounded-xl transition-colors',
|
||||
'min-h-16 rounded-xl border bg-components-panel-bg transition-colors',
|
||||
(withSwitch || !draftConfig.enabled) ? 'border-components-panel-border' : 'border-util-colors-blue-blue-600',
|
||||
(withSwitch || draftConfig.enabled) ? 'cursor-default' : 'cursor-pointer',
|
||||
className,
|
||||
@ -259,7 +259,7 @@ const ModelLoadBalancingConfigs = ({
|
||||
<GridMask canvasClassName='!rounded-xl'>
|
||||
<div className='mt-2 flex h-14 items-center justify-between rounded-xl border-[0.5px] border-components-panel-border px-4 shadow-md'>
|
||||
<div
|
||||
className={classNames('text-sm font-semibold leading-tight text-gradient', s.textGradient)}
|
||||
className={classNames('text-gradient text-sm font-semibold leading-tight', s.textGradient)}
|
||||
>
|
||||
{t('common.modelProvider.upgradeForLoadBalancing')}
|
||||
</div>
|
||||
|
||||
@ -137,8 +137,8 @@ const ModelLoadBalancingModal = ({ provider, model, open = false, onClose, onSav
|
||||
<div className='py-2'>
|
||||
<div
|
||||
className={classNames(
|
||||
'min-h-16 bg-components-panel-bg border rounded-xl transition-colors',
|
||||
draftConfig.enabled ? 'border-components-panel-border cursor-pointer' : 'border-util-colors-blue-blue-600 cursor-default',
|
||||
'min-h-16 rounded-xl border bg-components-panel-bg transition-colors',
|
||||
draftConfig.enabled ? 'cursor-pointer border-components-panel-border' : 'cursor-default border-util-colors-blue-blue-600',
|
||||
)}
|
||||
onClick={draftConfig.enabled ? () => toggleModalBalancing(false) : undefined}
|
||||
>
|
||||
|
||||
@ -17,9 +17,9 @@ export default function AppBack({ curApp }: IAppBackProps) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(`
|
||||
flex items-center h-7 pl-2.5 pr-2
|
||||
text-[#1C64F2] font-semibold cursor-pointer
|
||||
rounded-[10px]
|
||||
flex h-7 cursor-pointer items-center rounded-[10px]
|
||||
pl-2.5 pr-2 font-semibold
|
||||
text-[#1C64F2]
|
||||
${curApp && 'hover:bg-[#EBF5FF]'}
|
||||
`)}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
|
||||
@ -13,7 +13,7 @@ const HeaderWrapper = ({
|
||||
children,
|
||||
}: HeaderWrapperProps) => {
|
||||
const pathname = usePathname()
|
||||
const isBordered = ['/apps', '/datasets', '/datasets/create', '/tools'].includes(pathname)
|
||||
const isBordered = ['/apps', '/datasets/create', '/tools'].includes(pathname)
|
||||
// Check if the current path is a workflow canvas & fullscreen
|
||||
const inWorkflowCanvas = pathname.endsWith('/workflow')
|
||||
const isPipelineCanvas = pathname.endsWith('/pipeline')
|
||||
|
||||
@ -47,7 +47,7 @@ export default function Indicator({
|
||||
}: IndicatorProps) {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'w-2 h-2 border border-solid rounded-[3px]',
|
||||
'h-2 w-2 rounded-[3px] border border-solid',
|
||||
BACKGROUND_MAP[color],
|
||||
BORDER_MAP[color],
|
||||
SHADOW_MAP[color],
|
||||
|
||||
@ -39,7 +39,7 @@ const TagsFilter = ({
|
||||
const { t } = useMixedTranslation(locale)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const { tags: options, tagsMap } = useTags(t)
|
||||
const { tags: options } = useTags(t)
|
||||
const filteredOptions = options.filter(option => option.label.toLowerCase().includes(searchText.toLowerCase()))
|
||||
const handleCheck = (id: string) => {
|
||||
if (tags.includes(id))
|
||||
@ -47,7 +47,6 @@ const TagsFilter = ({
|
||||
else
|
||||
onTagsChange([...tags, id])
|
||||
}
|
||||
const selectedTagsLength = tags.length
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
|
||||
@ -45,7 +45,7 @@ export const getPluginDetailLinkInMarketplace = (plugin: Plugin) => {
|
||||
}
|
||||
|
||||
export const getMarketplacePluginsByCollectionId = async (collectionId: string, query?: CollectionsAndPluginsSearchParams) => {
|
||||
let plugins = [] as Plugin[]
|
||||
let plugins: Plugin[]
|
||||
|
||||
try {
|
||||
const url = `${MARKETPLACE_API_PREFIX}/collections/${collectionId}/plugins`
|
||||
|
||||
@ -50,9 +50,9 @@ const PluginSettingModal: FC<Props> = ({
|
||||
isShow
|
||||
onClose={onHide}
|
||||
closable
|
||||
className='w-[480px] !p-0'
|
||||
className='w-[620px] max-w-[620px] !p-0'
|
||||
>
|
||||
<div className='shadows-shadow-xl flex w-[480px] flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
|
||||
<div className='shadows-shadow-xl flex w-full flex-col items-start rounded-2xl border border-components-panel-border bg-components-panel-bg'>
|
||||
<div className='flex items-start gap-2 self-stretch pb-3 pl-6 pr-14 pt-6'>
|
||||
<span className='title-2xl-semi-bold self-stretch text-text-primary'>{t(`${i18nPrefix}.title`)}</span>
|
||||
</div>
|
||||
|
||||
@ -151,10 +151,9 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const pendingTaskList = allTaskList.filter(task => task.status === TaskStatus.pending)
|
||||
const noPendingTask = pendingTaskList.length === 0
|
||||
const showTaskList = allTaskList.filter(task => task.status !== TaskStatus.pending)
|
||||
const [currGroupNum, doSetCurrGroupNum] = useState(0)
|
||||
const currGroupNumRef = useRef(0)
|
||||
|
||||
const setCurrGroupNum = (num: number) => {
|
||||
doSetCurrGroupNum(num)
|
||||
currGroupNumRef.current = num
|
||||
}
|
||||
const getCurrGroupNum = () => {
|
||||
@ -164,10 +163,8 @@ const TextGeneration: FC<IMainProps> = ({
|
||||
const allFailedTaskList = allTaskList.filter(task => task.status === TaskStatus.failed)
|
||||
const allTasksFinished = allTaskList.every(task => task.status === TaskStatus.completed)
|
||||
const allTasksRun = allTaskList.every(task => [TaskStatus.completed, TaskStatus.failed].includes(task.status))
|
||||
const [batchCompletionRes, doSetBatchCompletionRes] = useState<Record<string, string>>({})
|
||||
const batchCompletionResRef = useRef<Record<string, string>>({})
|
||||
const setBatchCompletionRes = (res: Record<string, string>) => {
|
||||
doSetBatchCompletionRes(res)
|
||||
batchCompletionResRef.current = res
|
||||
}
|
||||
const getBatchCompletionRes = () => batchCompletionResRef.current
|
||||
|
||||
@ -23,7 +23,7 @@ const useStickyScroll = ({
|
||||
return
|
||||
const { height: wrapHeight, top: wrapTop } = wrapDom.getBoundingClientRect()
|
||||
const { top: nextToStickyTop } = stickyDOM.getBoundingClientRect()
|
||||
let scrollPositionNew = ScrollPosition.belowTheWrap
|
||||
let scrollPositionNew: ScrollPosition
|
||||
|
||||
if (nextToStickyTop - wrapTop >= wrapHeight)
|
||||
scrollPositionNew = ScrollPosition.belowTheWrap
|
||||
|
||||
@ -36,9 +36,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
<div
|
||||
data-tooltip-id='workflow.undo'
|
||||
className={
|
||||
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.undo)
|
||||
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed')}
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled')}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.undo && handleUndo()}
|
||||
>
|
||||
<RiArrowGoBackLine className='h-4 w-4' />
|
||||
@ -48,9 +48,9 @@ const UndoRedo: FC<UndoRedoProps> = ({ handleUndo, handleRedo }) => {
|
||||
<div
|
||||
data-tooltip-id='workflow.redo'
|
||||
className={
|
||||
classNames('flex items-center px-1.5 w-8 h-8 rounded-md system-sm-medium text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer select-none',
|
||||
classNames('system-sm-medium flex h-8 w-8 cursor-pointer select-none items-center rounded-md px-1.5 text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
(nodesReadOnly || buttonsDisabled.redo)
|
||||
&& 'hover:bg-transparent text-text-disabled hover:text-text-disabled cursor-not-allowed',
|
||||
&& 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
onClick={() => !nodesReadOnly && !buttonsDisabled.redo && handleRedo()}
|
||||
>
|
||||
|
||||
@ -127,7 +127,7 @@ const ViewWorkflowHistory = () => {
|
||||
>
|
||||
<div
|
||||
className={
|
||||
classNames('flex items-center justify-center w-8 h-8 rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary cursor-pointer',
|
||||
classNames('flex h-8 w-8 cursor-pointer items-center justify-center rounded-md text-text-tertiary hover:bg-state-base-hover hover:text-text-secondary',
|
||||
open && 'bg-state-accent-active text-text-accent',
|
||||
nodesReadOnly && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled',
|
||||
)}
|
||||
|
||||
@ -21,4 +21,5 @@ export * from './use-tool-icon'
|
||||
export * from './use-DSL'
|
||||
export * from './use-inspect-vars-crud'
|
||||
export * from './use-set-workflow-vars-with-value'
|
||||
export * from './use-workflow-search'
|
||||
export * from './use-format-time-from-now'
|
||||
|
||||
123
web/app/components/workflow/hooks/use-workflow-search.tsx
Normal file
123
web/app/components/workflow/hooks/use-workflow-search.tsx
Normal file
@ -0,0 +1,123 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo } from 'react'
|
||||
import { useNodes } from 'reactflow'
|
||||
import { useNodesInteractions } from './use-nodes-interactions'
|
||||
import type { CommonNodeType } from '../types'
|
||||
import { workflowNodesAction } from '@/app/components/goto-anything/actions/workflow-nodes'
|
||||
import BlockIcon from '@/app/components/workflow/block-icon'
|
||||
import { setupNodeSelectionListener } from '../utils/node-navigation'
|
||||
|
||||
/**
|
||||
* Hook to register workflow nodes search functionality
|
||||
*/
|
||||
export const useWorkflowSearch = () => {
|
||||
const nodes = useNodes()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
|
||||
// Filter and process nodes for search
|
||||
const searchableNodes = useMemo(() => {
|
||||
const filteredNodes = nodes.filter((node) => {
|
||||
if (!node.id || !node.data || node.type === 'sticky') return false
|
||||
|
||||
const nodeData = node.data as CommonNodeType
|
||||
const nodeType = nodeData?.type
|
||||
|
||||
const internalStartNodes = ['iteration-start', 'loop-start']
|
||||
return !internalStartNodes.includes(nodeType)
|
||||
})
|
||||
|
||||
const result = filteredNodes
|
||||
.map((node) => {
|
||||
const nodeData = node.data as CommonNodeType
|
||||
|
||||
return {
|
||||
id: node.id,
|
||||
title: nodeData?.title || nodeData?.type || 'Untitled',
|
||||
type: nodeData?.type || '',
|
||||
desc: nodeData?.desc || '',
|
||||
blockType: nodeData?.type,
|
||||
nodeData,
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}, [nodes])
|
||||
|
||||
// Create search function for workflow nodes
|
||||
const searchWorkflowNodes = useCallback((query: string) => {
|
||||
if (!searchableNodes.length || !query.trim()) return []
|
||||
|
||||
const searchTerm = query.toLowerCase()
|
||||
|
||||
const results = searchableNodes
|
||||
.map((node) => {
|
||||
const titleMatch = node.title.toLowerCase()
|
||||
const typeMatch = node.type.toLowerCase()
|
||||
const descMatch = node.desc?.toLowerCase() || ''
|
||||
|
||||
let score = 0
|
||||
|
||||
if (titleMatch.startsWith(searchTerm)) score += 100
|
||||
else if (titleMatch.includes(searchTerm)) score += 50
|
||||
else if (typeMatch === searchTerm) score += 80
|
||||
else if (typeMatch.includes(searchTerm)) score += 30
|
||||
else if (descMatch.includes(searchTerm)) score += 20
|
||||
|
||||
return score > 0
|
||||
? {
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
description: node.desc || node.type,
|
||||
type: 'workflow-node' as const,
|
||||
path: `#${node.id}`,
|
||||
icon: (
|
||||
<BlockIcon
|
||||
type={node.blockType}
|
||||
className="shrink-0"
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
metadata: {
|
||||
nodeId: node.id,
|
||||
nodeData: node.nodeData,
|
||||
},
|
||||
// Add required data property for SearchResult type
|
||||
data: node.nodeData,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((node): node is NonNullable<typeof node> => node !== null)
|
||||
.sort((a, b) => {
|
||||
const aTitle = a.title.toLowerCase()
|
||||
const bTitle = b.title.toLowerCase()
|
||||
|
||||
if (aTitle.startsWith(searchTerm) && !bTitle.startsWith(searchTerm)) return -1
|
||||
if (!aTitle.startsWith(searchTerm) && bTitle.startsWith(searchTerm)) return 1
|
||||
|
||||
return 0
|
||||
})
|
||||
|
||||
return results
|
||||
}, [searchableNodes])
|
||||
|
||||
// Directly set the search function on the action object
|
||||
useEffect(() => {
|
||||
if (searchableNodes.length > 0) {
|
||||
// Set the search function directly on the action
|
||||
workflowNodesAction.searchFn = searchWorkflowNodes
|
||||
}
|
||||
|
||||
return () => {
|
||||
// Clean up when component unmounts
|
||||
workflowNodesAction.searchFn = undefined
|
||||
}
|
||||
}, [searchableNodes, searchWorkflowNodes])
|
||||
|
||||
// Set up node selection event listener using the utility function
|
||||
useEffect(() => {
|
||||
return setupNodeSelectionListener(handleNodeSelect)
|
||||
}, [handleNodeSelect])
|
||||
|
||||
return null
|
||||
}
|
||||
@ -60,6 +60,7 @@ import { CUSTOM_SIMPLE_NODE } from './simple-node/constants'
|
||||
import CustomDataSourceEmptyNode from './nodes/data-source-empty'
|
||||
import { CUSTOM_DATA_SOURCE_EMPTY_NODE } from './nodes/data-source-empty/constants'
|
||||
import Operator from './operator'
|
||||
import { useWorkflowSearch } from './hooks/use-workflow-search'
|
||||
import Control from './operator/control'
|
||||
import CustomEdge from './custom-edge'
|
||||
import CustomConnectionLine from './custom-connection-line'
|
||||
@ -70,6 +71,7 @@ import NodeContextmenu from './node-contextmenu'
|
||||
import SelectionContextmenu from './selection-contextmenu'
|
||||
import SyncingDataModal from './syncing-data-modal'
|
||||
import LimitTips from './limit-tips'
|
||||
import { setupScrollToNodeListener } from './utils/node-navigation'
|
||||
import {
|
||||
useStore,
|
||||
useWorkflowStore,
|
||||
@ -283,6 +285,14 @@ export const Workflow: FC<WorkflowProps> = memo(({
|
||||
})
|
||||
|
||||
useShortcuts()
|
||||
// Initialize workflow node search functionality
|
||||
useWorkflowSearch()
|
||||
|
||||
// Set up scroll to node event listener using the utility function
|
||||
useEffect(() => {
|
||||
return setupScrollToNodeListener(nodes, reactflow)
|
||||
}, [nodes, reactflow])
|
||||
|
||||
const { fetchInspectVars } = useSetWorkflowVarsWithValue()
|
||||
useEffect(() => {
|
||||
fetchInspectVars()
|
||||
|
||||
@ -182,7 +182,7 @@ const FormItem: FC<Props> = ({
|
||||
value={singleFileValue}
|
||||
onChange={handleSingleFileChange}
|
||||
fileConfig={{
|
||||
allowed_file_types: inStepRun
|
||||
allowed_file_types: inStepRun && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
|
||||
? [
|
||||
SupportUploadFileTypes.image,
|
||||
SupportUploadFileTypes.document,
|
||||
@ -190,7 +190,7 @@ const FormItem: FC<Props> = ({
|
||||
SupportUploadFileTypes.video,
|
||||
]
|
||||
: payload.allowed_file_types,
|
||||
allowed_file_extensions: inStepRun
|
||||
allowed_file_extensions: inStepRun && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
|
||||
? [
|
||||
...FILE_EXTS[SupportUploadFileTypes.image],
|
||||
...FILE_EXTS[SupportUploadFileTypes.document],
|
||||
@ -209,7 +209,7 @@ const FormItem: FC<Props> = ({
|
||||
value={value}
|
||||
onChange={files => onChange(files)}
|
||||
fileConfig={{
|
||||
allowed_file_types: (inStepRun || isIteratorItemFile)
|
||||
allowed_file_types: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_types || payload.allowed_file_types.length === 0)
|
||||
? [
|
||||
SupportUploadFileTypes.image,
|
||||
SupportUploadFileTypes.document,
|
||||
@ -217,7 +217,7 @@ const FormItem: FC<Props> = ({
|
||||
SupportUploadFileTypes.video,
|
||||
]
|
||||
: payload.allowed_file_types,
|
||||
allowed_file_extensions: (inStepRun || isIteratorItemFile)
|
||||
allowed_file_extensions: (inStepRun || isIteratorItemFile) && (!payload.allowed_file_extensions || payload.allowed_file_extensions.length === 0)
|
||||
? [
|
||||
...FILE_EXTS[SupportUploadFileTypes.image],
|
||||
...FILE_EXTS[SupportUploadFileTypes.document],
|
||||
|
||||
@ -5,7 +5,7 @@ export type GroupLabelProps = ComponentProps<'div'>
|
||||
|
||||
export const GroupLabel: FC<GroupLabelProps> = (props) => {
|
||||
const { children, className, ...rest } = props
|
||||
return <div {...rest} className={classNames('mb-1 system-2xs-medium-uppercase text-text-tertiary', className)}>
|
||||
return <div {...rest} className={classNames('system-2xs-medium-uppercase mb-1 text-text-tertiary', className)}>
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
import {
|
||||
useNodeDataUpdate,
|
||||
useNodesInteractions,
|
||||
useNodesSyncDraft,
|
||||
} from '../../../hooks'
|
||||
import { type Node, NodeRunningStatus } from '../../../types'
|
||||
import { canRunBySingle } from '../../../utils'
|
||||
@ -30,7 +29,6 @@ const NodeControl: FC<NodeControlProps> = ({
|
||||
const [open, setOpen] = useState(false)
|
||||
const { handleNodeDataUpdate } = useNodeDataUpdate()
|
||||
const { handleNodeSelect } = useNodesInteractions()
|
||||
const { handleSyncWorkflowDraft } = useNodesSyncDraft()
|
||||
const isSingleRunning = data._singleRunningStatus === NodeRunningStatus.Running
|
||||
const handleOpenChange = useCallback((newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
|
||||
@ -66,7 +66,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center px-2 py-1 gap-0.5 rounded-lg bg-components-input-bg-normal',
|
||||
'flex items-center gap-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1',
|
||||
disabled ? 'cursor-not-allowed !bg-components-input-bg-disabled' : 'cursor-pointer hover:bg-state-base-hover-alt',
|
||||
open && 'bg-state-base-hover-alt',
|
||||
className,
|
||||
@ -99,7 +99,7 @@ const OperationSelector: FC<OperationSelectorProps> = ({
|
||||
<div
|
||||
key={item.value}
|
||||
className={classNames(
|
||||
'flex items-center px-2 py-1 gap-1 self-stretch rounded-lg',
|
||||
'flex items-center gap-1 self-stretch rounded-lg px-2 py-1',
|
||||
'cursor-pointer hover:bg-state-base-hover',
|
||||
)}
|
||||
onClick={() => {
|
||||
|
||||
@ -36,6 +36,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
|
||||
const { inputs, setInputs } = useNodeCrud<ListFilterNodeType>(id, payload)
|
||||
|
||||
const { getCurrentVariableType } = useWorkflowVariables()
|
||||
|
||||
const getType = useCallback((variable?: ValueSelector) => {
|
||||
const varType = getCurrentVariableType({
|
||||
parentNode: isInIteration ? iterationNode : loopNode,
|
||||
@ -44,7 +45,7 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
|
||||
isChatMode,
|
||||
isConstant: false,
|
||||
})
|
||||
let itemVarType = VarType.string
|
||||
let itemVarType = varType
|
||||
switch (varType) {
|
||||
case VarType.arrayNumber:
|
||||
itemVarType = VarType.number
|
||||
@ -58,8 +59,6 @@ const useConfig = (id: string, payload: ListFilterNodeType) => {
|
||||
case VarType.arrayObject:
|
||||
itemVarType = VarType.object
|
||||
break
|
||||
default:
|
||||
itemVarType = varType
|
||||
}
|
||||
return { varType, itemVarType }
|
||||
}, [availableNodes, getCurrentVariableType, inputs.variable, isChatMode, isInIteration, iterationNode, loopNode])
|
||||
|
||||
@ -13,7 +13,7 @@ const ErrorMessage: FC<ErrorMessageProps> = ({
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(
|
||||
'flex gap-x-1 mt-1 p-2 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg',
|
||||
'mt-1 flex gap-x-1 rounded-lg border-[0.5px] border-components-panel-border bg-toast-error-bg p-2',
|
||||
className,
|
||||
)}>
|
||||
<RiErrorWarningFill className='h-4 w-4 shrink-0 text-text-destructive' />
|
||||
|
||||
@ -16,17 +16,22 @@ import {
|
||||
PortalToFollowElemContent,
|
||||
PortalToFollowElemTrigger,
|
||||
} from '@/app/components/base/portal-to-follow-elem'
|
||||
import { getNodesBounds, useReactFlow } from 'reactflow'
|
||||
import ImagePreview from '@/app/components/base/image-uploader/image-preview'
|
||||
import { useStore } from '@/app/components/workflow/store'
|
||||
|
||||
const ExportImage: FC = () => {
|
||||
const { t } = useTranslation()
|
||||
const { getNodesReadOnly } = useNodesReadOnly()
|
||||
const reactFlow = useReactFlow()
|
||||
|
||||
const appDetail = useAppStore(s => s.appDetail)
|
||||
const [open, setOpen] = useState(false)
|
||||
const [previewUrl, setPreviewUrl] = useState('')
|
||||
const [previewTitle, setPreviewTitle] = useState('')
|
||||
const knowledgeName = useStore(s => s.knowledgeName)
|
||||
|
||||
const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg') => {
|
||||
const handleExportImage = useCallback(async (type: 'png' | 'jpeg' | 'svg', currentWorkflow = false) => {
|
||||
if (!appDetail && !knowledgeName)
|
||||
return
|
||||
|
||||
@ -46,31 +51,123 @@ const ExportImage: FC = () => {
|
||||
}
|
||||
|
||||
let dataUrl
|
||||
switch (type) {
|
||||
case 'png':
|
||||
dataUrl = await toPng(flowElement, { filter })
|
||||
break
|
||||
case 'jpeg':
|
||||
dataUrl = await toJpeg(flowElement, { filter })
|
||||
break
|
||||
case 'svg':
|
||||
dataUrl = await toSvg(flowElement, { filter })
|
||||
break
|
||||
default:
|
||||
dataUrl = await toPng(flowElement, { filter })
|
||||
let filename = `${appDetail.name}`
|
||||
|
||||
if (currentWorkflow) {
|
||||
// Get all nodes and their bounds
|
||||
const nodes = reactFlow.getNodes()
|
||||
const nodesBounds = getNodesBounds(nodes)
|
||||
|
||||
// Save current viewport
|
||||
const currentViewport = reactFlow.getViewport()
|
||||
|
||||
// Calculate the required zoom to fit all nodes
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
const zoom = Math.min(
|
||||
viewportWidth / (nodesBounds.width + 100),
|
||||
viewportHeight / (nodesBounds.height + 100),
|
||||
1,
|
||||
)
|
||||
|
||||
// Calculate center position
|
||||
const centerX = nodesBounds.x + nodesBounds.width / 2
|
||||
const centerY = nodesBounds.y + nodesBounds.height / 2
|
||||
|
||||
// Set viewport to show all nodes
|
||||
reactFlow.setViewport({
|
||||
x: viewportWidth / 2 - centerX * zoom,
|
||||
y: viewportHeight / 2 - centerY * zoom,
|
||||
zoom,
|
||||
})
|
||||
|
||||
// Wait for the transition to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 300))
|
||||
|
||||
// Calculate actual content size with padding
|
||||
const padding = 50 // More padding for better visualization
|
||||
const contentWidth = nodesBounds.width + padding * 2
|
||||
const contentHeight = nodesBounds.height + padding * 2
|
||||
|
||||
// Export with higher quality for whole workflow
|
||||
const exportOptions = {
|
||||
filter,
|
||||
backgroundColor: '#1a1a1a', // Dark background to match previous style
|
||||
pixelRatio: 2, // Higher resolution for better zoom
|
||||
width: contentWidth,
|
||||
height: contentHeight,
|
||||
style: {
|
||||
width: `${contentWidth}px`,
|
||||
height: `${contentHeight}px`,
|
||||
transform: `translate(${padding - nodesBounds.x}px, ${padding - nodesBounds.y}px) scale(${zoom})`,
|
||||
},
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'png':
|
||||
dataUrl = await toPng(flowElement, exportOptions)
|
||||
break
|
||||
case 'jpeg':
|
||||
dataUrl = await toJpeg(flowElement, exportOptions)
|
||||
break
|
||||
case 'svg':
|
||||
dataUrl = await toSvg(flowElement, { filter })
|
||||
break
|
||||
default:
|
||||
dataUrl = await toPng(flowElement, exportOptions)
|
||||
}
|
||||
|
||||
filename += '-whole-workflow'
|
||||
|
||||
// Restore original viewport after a delay
|
||||
setTimeout(() => {
|
||||
reactFlow.setViewport(currentViewport)
|
||||
}, 500)
|
||||
}
|
||||
else {
|
||||
// Current viewport export (existing functionality)
|
||||
switch (type) {
|
||||
case 'png':
|
||||
dataUrl = await toPng(flowElement, { filter })
|
||||
break
|
||||
case 'jpeg':
|
||||
dataUrl = await toJpeg(flowElement, { filter })
|
||||
break
|
||||
case 'svg':
|
||||
dataUrl = await toSvg(flowElement, { filter })
|
||||
break
|
||||
default:
|
||||
dataUrl = await toPng(flowElement, { filter })
|
||||
}
|
||||
}
|
||||
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = `${appDetail ? appDetail.name : knowledgeName}.${type}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
if (currentWorkflow) {
|
||||
// For whole workflow, show preview first
|
||||
setPreviewUrl(dataUrl)
|
||||
setPreviewTitle(`${filename}.${type}`)
|
||||
|
||||
// Also auto-download
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = `${filename}.${type}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
else {
|
||||
// For current view, just download
|
||||
const link = document.createElement('a')
|
||||
link.href = dataUrl
|
||||
link.download = `${appDetail ? filename : knowledgeName}.${type}`
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Export image failed:', error)
|
||||
}
|
||||
}, [getNodesReadOnly, appDetail, knowledgeName])
|
||||
}, [getNodesReadOnly, appDetail, reactFlow, knowledgeName])
|
||||
|
||||
const handleTrigger = useCallback(() => {
|
||||
if (getNodesReadOnly())
|
||||
@ -80,53 +177,90 @@ const ExportImage: FC = () => {
|
||||
}, [getNodesReadOnly])
|
||||
|
||||
return (
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<TipPopup title={t('workflow.common.exportImage')}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
<RiExportLine className='h-4 w-4' />
|
||||
</div>
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='min-w-[120px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
|
||||
<div className='p-1'>
|
||||
<>
|
||||
<PortalToFollowElem
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
placement="top-start"
|
||||
offset={{
|
||||
mainAxis: 4,
|
||||
crossAxis: -8,
|
||||
}}
|
||||
>
|
||||
<PortalToFollowElemTrigger>
|
||||
<TipPopup title={t('workflow.common.exportImage')}>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('png')}
|
||||
className={cn(
|
||||
'flex h-8 w-8 cursor-pointer items-center justify-center rounded-lg hover:bg-state-base-hover hover:text-text-secondary',
|
||||
`${getNodesReadOnly() && 'cursor-not-allowed text-text-disabled hover:bg-transparent hover:text-text-disabled'}`,
|
||||
)}
|
||||
onClick={handleTrigger}
|
||||
>
|
||||
{t('workflow.common.exportPNG')}
|
||||
<RiExportLine className='h-4 w-4' />
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('jpeg')}
|
||||
>
|
||||
{t('workflow.common.exportJPEG')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('svg')}
|
||||
>
|
||||
{t('workflow.common.exportSVG')}
|
||||
</TipPopup>
|
||||
</PortalToFollowElemTrigger>
|
||||
<PortalToFollowElemContent className='z-10'>
|
||||
<div className='min-w-[180px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur text-text-secondary shadow-lg'>
|
||||
<div className='p-1'>
|
||||
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
|
||||
{t('workflow.common.currentView')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('png')}
|
||||
>
|
||||
{t('workflow.common.exportPNG')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('jpeg')}
|
||||
>
|
||||
{t('workflow.common.exportJPEG')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('svg')}
|
||||
>
|
||||
{t('workflow.common.exportSVG')}
|
||||
</div>
|
||||
|
||||
<div className='border-border-divider mx-2 my-1 border-t' />
|
||||
|
||||
<div className='px-2 py-1 text-xs font-medium text-text-tertiary'>
|
||||
{t('workflow.common.currentWorkflow')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('png', true)}
|
||||
>
|
||||
{t('workflow.common.exportPNG')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('jpeg', true)}
|
||||
>
|
||||
{t('workflow.common.exportJPEG')}
|
||||
</div>
|
||||
<div
|
||||
className='system-md-regular flex h-8 cursor-pointer items-center rounded-lg px-2 hover:bg-state-base-hover'
|
||||
onClick={() => handleExportImage('svg', true)}
|
||||
>
|
||||
{t('workflow.common.exportSVG')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
</PortalToFollowElemContent>
|
||||
</PortalToFollowElem>
|
||||
|
||||
{previewUrl && (
|
||||
<ImagePreview
|
||||
url={previewUrl}
|
||||
title={previewTitle}
|
||||
onCancel={() => setPreviewUrl('')}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -52,7 +52,9 @@ const Operator = ({ handleUndo, handleRedo }: OperatorProps) => {
|
||||
}
|
||||
>
|
||||
<div className='flex justify-between px-1 pb-2'>
|
||||
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
|
||||
<div className='flex items-center gap-2'>
|
||||
<UndoRedo handleUndo={handleUndo} handleRedo={handleRedo} />
|
||||
</div>
|
||||
<VariableTrigger />
|
||||
<div className='relative'>
|
||||
<MiniMap
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import type { WorkflowDataUpdater } from '../types'
|
||||
import type { WorkflowRunDetailResponse } from '@/models/log'
|
||||
import Run from '../run'
|
||||
import { useStore } from '../store'
|
||||
import { useWorkflowUpdate } from '../hooks'
|
||||
@ -11,12 +11,12 @@ const Record = () => {
|
||||
const { handleUpdateWorkflowCanvas } = useWorkflowUpdate()
|
||||
const getWorkflowRunAndTraceUrl = useHooksStore(s => s.getWorkflowRunAndTraceUrl)
|
||||
|
||||
const handleResultCallback = useCallback((res: any) => {
|
||||
const graph: WorkflowDataUpdater = res.graph
|
||||
const handleResultCallback = useCallback((res: WorkflowRunDetailResponse) => {
|
||||
const graph = res.graph
|
||||
handleUpdateWorkflowCanvas({
|
||||
nodes: graph.nodes,
|
||||
edges: graph.edges,
|
||||
viewport: graph.viewport,
|
||||
viewport: graph.viewport || { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
}, [handleUpdateWorkflowCanvas])
|
||||
|
||||
|
||||
@ -260,7 +260,30 @@ const SelectionContextmenu = () => {
|
||||
|
||||
// Get all selected nodes
|
||||
const selectedNodeIds = selectedNodes.map(node => node.id)
|
||||
const nodesToAlign = nodes.filter(node => selectedNodeIds.includes(node.id))
|
||||
|
||||
// Find container nodes and their children
|
||||
// Container nodes (like Iteration and Loop) have child nodes that should not be aligned independently
|
||||
// when the container is selected. This prevents child nodes from being moved outside their containers.
|
||||
const childNodeIds = new Set<string>()
|
||||
|
||||
nodes.forEach((node) => {
|
||||
// Check if this is a container node (Iteration or Loop)
|
||||
if (node.data._children && node.data._children.length > 0) {
|
||||
// If container node is selected, add its children to the exclusion set
|
||||
if (selectedNodeIds.includes(node.id)) {
|
||||
// Add all its children to the childNodeIds set
|
||||
node.data._children.forEach((child: { nodeId: string; nodeType: string }) => {
|
||||
childNodeIds.add(child.nodeId)
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Filter out child nodes from the alignment operation
|
||||
// Only align nodes that are selected AND are not children of container nodes
|
||||
// This ensures container nodes can be aligned while their children stay in the same relative position
|
||||
const nodesToAlign = nodes.filter(node =>
|
||||
selectedNodeIds.includes(node.id) && !childNodeIds.has(node.id))
|
||||
|
||||
if (nodesToAlign.length <= 1) {
|
||||
handleSelectionContextmenuCancel()
|
||||
|
||||
124
web/app/components/workflow/utils/node-navigation.ts
Normal file
124
web/app/components/workflow/utils/node-navigation.ts
Normal file
@ -0,0 +1,124 @@
|
||||
/**
|
||||
* Node navigation utilities for workflow
|
||||
* This module provides functions for node selection, focusing and scrolling in workflow
|
||||
*/
|
||||
|
||||
/**
|
||||
* Interface for node selection event detail
|
||||
*/
|
||||
export type NodeSelectionDetail = {
|
||||
nodeId: string;
|
||||
focus?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a node in the workflow
|
||||
* @param nodeId - The ID of the node to select
|
||||
* @param focus - Whether to focus/scroll to the node
|
||||
*/
|
||||
export function selectWorkflowNode(nodeId: string, focus = false): void {
|
||||
// Create and dispatch a custom event for node selection
|
||||
const event = new CustomEvent('workflow:select-node', {
|
||||
detail: {
|
||||
nodeId,
|
||||
focus,
|
||||
},
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Scroll to a specific node in the workflow
|
||||
* @param nodeId - The ID of the node to scroll to
|
||||
*/
|
||||
export function scrollToWorkflowNode(nodeId: string): void {
|
||||
// Create and dispatch a custom event for scrolling to node
|
||||
const event = new CustomEvent('workflow:scroll-to-node', {
|
||||
detail: { nodeId },
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup node selection event listener
|
||||
* @param handleNodeSelect - Function to handle node selection
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupNodeSelectionListener(
|
||||
handleNodeSelect: (nodeId: string) => void,
|
||||
): () => void {
|
||||
// Event handler for node selection
|
||||
const handleNodeSelection = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId, focus } = event.detail
|
||||
if (nodeId) {
|
||||
// Select the node
|
||||
handleNodeSelect(nodeId)
|
||||
|
||||
// If focus is requested, scroll to the node
|
||||
if (focus) {
|
||||
// Use a small timeout to ensure node selection happens first
|
||||
setTimeout(() => {
|
||||
scrollToWorkflowNode(nodeId)
|
||||
}, 100)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:select-node',
|
||||
handleNodeSelection as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup scroll to node event listener with ReactFlow
|
||||
* @param nodes - The workflow nodes
|
||||
* @param reactflow - The ReactFlow instance
|
||||
* @returns Cleanup function
|
||||
*/
|
||||
export function setupScrollToNodeListener(
|
||||
nodes: any[],
|
||||
reactflow: any,
|
||||
): () => void {
|
||||
// Event handler for scrolling to node
|
||||
const handleScrollToNode = (event: CustomEvent<NodeSelectionDetail>) => {
|
||||
const { nodeId } = event.detail
|
||||
if (nodeId) {
|
||||
// Find the target node
|
||||
const node = nodes.find(n => n.id === nodeId)
|
||||
if (node) {
|
||||
// Use ReactFlow's fitView API to scroll to the node
|
||||
reactflow.fitView({
|
||||
nodes: [node],
|
||||
padding: 0.2,
|
||||
duration: 800,
|
||||
minZoom: 0.5,
|
||||
maxZoom: 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add event listener
|
||||
document.addEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
'workflow:scroll-to-node',
|
||||
handleScrollToNode as EventListener,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user