| @@ -29,6 +29,11 @@ export type SensitiveWordAvoidance = EnabledOrDisabled & { | |||
| config?: any | |||
| } | |||
| export enum PreviewMode { | |||
| NewPage = 'new_page', | |||
| CurrentPage = 'current_page', | |||
| } | |||
| export type FileUpload = { | |||
| image?: EnabledOrDisabled & { | |||
| detail?: Resolution | |||
| @@ -56,6 +61,10 @@ export type FileUpload = { | |||
| allowed_file_upload_methods?: TransferMethod[] | |||
| number_limits?: number | |||
| fileUploadConfig?: FileUploadConfigResponse | |||
| preview_config?: { | |||
| mode?: PreviewMode | |||
| file_type_list?: string[] | |||
| } | |||
| } & EnabledOrDisabled | |||
| export type AnnotationReplyConfig = { | |||
| @@ -23,6 +23,7 @@ import cn from '@/utils/classnames' | |||
| import { ReplayLine } from '@/app/components/base/icons/src/vender/other' | |||
| import { SupportUploadFileTypes } from '@/app/components/workflow/types' | |||
| import ImagePreview from '@/app/components/base/image-uploader/image-preview' | |||
| import { PreviewMode } from '@/app/components/base/features/types' | |||
| type FileInAttachmentItemProps = { | |||
| file: FileEntity | |||
| @@ -31,6 +32,7 @@ type FileInAttachmentItemProps = { | |||
| onRemove?: (fileId: string) => void | |||
| onReUpload?: (fileId: string) => void | |||
| canPreview?: boolean | |||
| previewMode?: PreviewMode | |||
| } | |||
| const FileInAttachmentItem = ({ | |||
| file, | |||
| @@ -39,6 +41,7 @@ const FileInAttachmentItem = ({ | |||
| onRemove, | |||
| onReUpload, | |||
| canPreview, | |||
| previewMode = PreviewMode.CurrentPage, | |||
| }: FileInAttachmentItemProps) => { | |||
| const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file | |||
| const ext = getFileExtension(name, type, isRemote) | |||
| @@ -49,7 +52,13 @@ const FileInAttachmentItem = ({ | |||
| <div className={cn( | |||
| 'flex h-12 items-center rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg pr-3 shadow-xs', | |||
| progress === -1 && 'border-state-destructive-border bg-state-destructive-hover', | |||
| )}> | |||
| canPreview && previewMode === PreviewMode.NewPage && 'cursor-pointer', | |||
| )} | |||
| onClick={() => { | |||
| if (canPreview && previewMode === PreviewMode.NewPage) | |||
| window.open(url || base64Url || '', '_blank') | |||
| }} | |||
| > | |||
| <div className='flex h-12 w-12 items-center justify-center'> | |||
| { | |||
| isImageFile && ( | |||
| @@ -106,6 +106,8 @@ const FileUploaderInAttachment = ({ | |||
| showDownloadAction={false} | |||
| onRemove={() => handleRemoveFile(file.id)} | |||
| onReUpload={() => handleReUploadFile(file.id)} | |||
| canPreview={fileConfig.preview_config?.file_type_list?.includes(file.type)} | |||
| previewMode={fileConfig.preview_config?.mode} | |||
| /> | |||
| )) | |||
| } | |||
| @@ -20,6 +20,7 @@ type SegmentedControlProps<T extends string | number | symbol> = { | |||
| onChange: (value: T) => void | |||
| className?: string | |||
| activeClassName?: string | |||
| btnClassName?: string | |||
| } | |||
| const SegmentedControlVariants = cva( | |||
| @@ -90,6 +91,7 @@ export const SegmentedControl = <T extends string | number | symbol>({ | |||
| padding, | |||
| activeState, | |||
| activeClassName, | |||
| btnClassName, | |||
| }: SegmentedControlProps<T> | |||
| & VariantProps<typeof SegmentedControlVariants> | |||
| & VariantProps<typeof SegmentedControlItemVariants> | |||
| @@ -115,6 +117,7 @@ export const SegmentedControl = <T extends string | number | symbol>({ | |||
| SegmentedControlItemVariants({ size, activeState: isSelected ? activeState : 'default' }), | |||
| isSelected && activeClassName, | |||
| disabled && 'disabled', | |||
| btnClassName, | |||
| )} | |||
| onClick={() => { | |||
| if (!isSelected) | |||
| @@ -24,12 +24,16 @@ export type TextareaProps = { | |||
| disabled?: boolean | |||
| destructive?: boolean | |||
| styleCss?: CSSProperties | |||
| onFocus?: () => void | |||
| onBlur?: () => void | |||
| } & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants> | |||
| const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>( | |||
| ({ className, value, onChange, disabled, size, destructive, styleCss, ...props }, ref) => { | |||
| ({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => { | |||
| return ( | |||
| <textarea | |||
| onFocus={onFocus} | |||
| onBlur={onBlur} | |||
| ref={ref} | |||
| style={styleCss} | |||
| className={cn( | |||
| @@ -0,0 +1,141 @@ | |||
| import { useMemo } from 'react' | |||
| import SegmentIndexTag from '@/app/components/datasets/documents/detail/completed/common/segment-index-tag' | |||
| import Dot from '@/app/components/datasets/documents/detail/completed/common/dot' | |||
| import { PreviewSlice } from '@/app/components/datasets/formatted-text/flavours/preview-slice' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { formatNumber } from '@/utils/format' | |||
| enum QAItemType { | |||
| Question = 'question', | |||
| Answer = 'answer', | |||
| } | |||
| type QAItemProps = { | |||
| type: QAItemType | |||
| text: string | |||
| } | |||
| const QAItem = (props: QAItemProps) => { | |||
| const { type, text } = props | |||
| return <div className="inline-flex items-start justify-start gap-1 self-stretch"> | |||
| <div className="w-4 text-[13px] font-medium leading-5 text-text-tertiary">{type === QAItemType.Question ? 'Q' : 'A'}</div> | |||
| <div className="body-md-regular flex-1 text-text-secondary">{text}</div> | |||
| </div> | |||
| } | |||
| enum ChunkType { | |||
| General = 'genaral', | |||
| Paragraph = 'paragraph', | |||
| FullDoc = 'full-doc', | |||
| QA = 'qa', | |||
| } | |||
| type ChunkCardProps = { | |||
| type: ChunkType | |||
| content: string | string[] | QAChunk | |||
| positionId?: string | number | |||
| wordCount: number | |||
| } | |||
| const ChunkCard = (props: ChunkCardProps) => { | |||
| const { type, content, positionId, wordCount } = props | |||
| const { t } = useTranslation() | |||
| const renderContent = () => { | |||
| // ChunkType.Paragraph && ChunkType.FullDoc | |||
| if (Array.isArray(content)) { | |||
| return content.map((child, index) => { | |||
| const indexForLabel = index + 1 | |||
| return ( | |||
| <PreviewSlice | |||
| key={child} | |||
| label={`C-${indexForLabel}`} | |||
| text={child} | |||
| tooltip={`Child-chunk-${indexForLabel} · ${child.length} Characters`} | |||
| labelInnerClassName='text-[10px] font-semibold align-bottom leading-7' | |||
| dividerClassName='leading-7' | |||
| /> | |||
| ) | |||
| }) | |||
| } | |||
| // ChunkType.QA | |||
| if (typeof content === 'object') { | |||
| return <div className="flex flex-col gap-2"> | |||
| <QAItem type={QAItemType.Question} text={(content as QAChunk).question} /> | |||
| <QAItem type={QAItemType.Answer} text={(content as QAChunk).answer} /> | |||
| </div> | |||
| } | |||
| // ChunkType.General | |||
| return content | |||
| } | |||
| return <div className="inline-flex flex-col gap-1 self-stretch rounded-lg bg-components-panel-bg px-3 py-2.5"> | |||
| {type !== ChunkType.FullDoc && <div className="inline-flex items-center justify-start gap-2"> | |||
| <SegmentIndexTag | |||
| positionId={positionId} | |||
| labelPrefix={type === ChunkType.Paragraph ? 'Parent-Chunk' : 'Chunk'} | |||
| /> | |||
| <Dot /> | |||
| <div className='system-xs-medium text-text-tertiary'>{formatNumber(wordCount)} {t('datasetDocuments.segment.characters', { count: wordCount })}</div> | |||
| </div>} | |||
| <div className="body-md-regular text-text-secondary">{renderContent()}</div> | |||
| </div> | |||
| } | |||
| export type ChunkInfo = { | |||
| general_chunks?: string[] | |||
| parent_child_chunks?: ParentChildChunk[] | |||
| parent_mode?: string | |||
| qa_chunks?: QAChunk[] | |||
| } | |||
| type ParentChildChunk = { | |||
| child_contents: string[] | |||
| parent_content: string | |||
| parent_mode: string | |||
| } | |||
| type QAChunk = { | |||
| question: string | |||
| answer: string | |||
| } | |||
| type ChunkCardListProps = { | |||
| chunkInfo: ChunkInfo | |||
| } | |||
| export const ChunkCardList = (props: ChunkCardListProps) => { | |||
| const { chunkInfo } = props | |||
| const chunkType = useMemo(() => { | |||
| if (chunkInfo?.general_chunks) | |||
| return ChunkType.General | |||
| if (chunkInfo?.parent_child_chunks) | |||
| return chunkInfo.parent_mode as ChunkType | |||
| return ChunkType.QA | |||
| }, [chunkInfo]) | |||
| return <div className='flex grow flex-col gap-1'> | |||
| {(chunkInfo.general_chunks ?? chunkInfo.parent_child_chunks ?? chunkInfo?.qa_chunks ?? []).map((seg: string | ParentChildChunk | QAChunk, index: number) => { | |||
| const isParentChildMode = [ChunkType.Paragraph, ChunkType.FullDoc].includes(chunkType!) | |||
| let wordCount = 0 | |||
| if (isParentChildMode) | |||
| wordCount = (seg as ParentChildChunk)?.parent_content?.length | |||
| else if (typeof seg === 'string') | |||
| wordCount = seg.length | |||
| else | |||
| wordCount = (seg as QAChunk)?.question?.length + (seg as QAChunk)?.answer?.length | |||
| return <ChunkCard | |||
| type={chunkType} | |||
| content={isParentChildMode ? (seg as ParentChildChunk).child_contents : (seg as string | QAChunk)} | |||
| wordCount={wordCount} | |||
| positionId={index + 1} | |||
| /> | |||
| })} | |||
| </div> | |||
| } | |||
| @@ -15,6 +15,8 @@ type CodeEditorProps = { | |||
| editorWrapperClassName?: string | |||
| readOnly?: boolean | |||
| hideTopMenu?: boolean | |||
| onFocus?: () => void | |||
| onBlur?: () => void | |||
| } & React.HTMLAttributes<HTMLDivElement> | |||
| const CodeEditor: FC<CodeEditorProps> = ({ | |||
| @@ -25,6 +27,8 @@ const CodeEditor: FC<CodeEditorProps> = ({ | |||
| readOnly = false, | |||
| hideTopMenu = false, | |||
| className, | |||
| onFocus, | |||
| onBlur, | |||
| }) => { | |||
| const { t } = useTranslation() | |||
| const { theme } = useTheme() | |||
| @@ -45,6 +49,14 @@ const CodeEditor: FC<CodeEditorProps> = ({ | |||
| const handleEditorDidMount = useCallback((editor: any, monaco: any) => { | |||
| editorRef.current = editor | |||
| monacoRef.current = monaco | |||
| editor.onDidFocusEditorText(() => { | |||
| onFocus?.() | |||
| }) | |||
| editor.onDidBlurEditorText(() => { | |||
| onBlur?.() | |||
| }) | |||
| monaco.editor.defineTheme('light-theme', { | |||
| base: 'vs', | |||
| inherit: true, | |||
| @@ -8,6 +8,8 @@ type SchemaEditorProps = { | |||
| hideTopMenu?: boolean | |||
| className?: string | |||
| readonly?: boolean | |||
| onFocus?: () => void | |||
| onBlur?: () => void | |||
| } | |||
| const SchemaEditor: FC<SchemaEditorProps> = ({ | |||
| @@ -16,6 +18,8 @@ const SchemaEditor: FC<SchemaEditorProps> = ({ | |||
| hideTopMenu, | |||
| className, | |||
| readonly = false, | |||
| onFocus, | |||
| onBlur, | |||
| }) => { | |||
| return ( | |||
| <CodeEditor | |||
| @@ -25,6 +29,8 @@ const SchemaEditor: FC<SchemaEditorProps> = ({ | |||
| value={schema} | |||
| onUpdate={onUpdate} | |||
| hideTopMenu={hideTopMenu} | |||
| onFocus={onFocus} | |||
| onBlur={onBlur} | |||
| /> | |||
| ) | |||
| } | |||
| @@ -1,6 +1,9 @@ | |||
| import { useEffect, useRef, useState } from 'react' | |||
| import { useEffect, useMemo, useRef, useState } from 'react' | |||
| import { useTranslation } from 'react-i18next' | |||
| import { useDebounceFn } from 'ahooks' | |||
| import { RiBracesLine, RiEyeLine } from '@remixicon/react' | |||
| import Textarea from '@/app/components/base/textarea' | |||
| import { Markdown } from '@/app/components/base/markdown' | |||
| import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor' | |||
| import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader' | |||
| import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message' | |||
| @@ -13,6 +16,7 @@ import { | |||
| validateJSONSchema, | |||
| } from '@/app/components/workflow/variable-inspect/utils' | |||
| import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils' | |||
| import { SegmentedControl } from '@/app/components/base/segmented-control' | |||
| import { JSON_SCHEMA_MAX_DEPTH } from '@/config' | |||
| import { TransferMethod } from '@/types/app' | |||
| import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' | |||
| @@ -21,6 +25,84 @@ import type { VarInInspect } from '@/types/workflow' | |||
| import { VarInInspectType } from '@/types/workflow' | |||
| import cn from '@/utils/classnames' | |||
| import { useStore } from '@/app/components/workflow/store' | |||
| import { ChunkCardList, type ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list' | |||
| import { PreviewMode } from '../../base/features/types' | |||
| enum ViewMode { | |||
| Code = 'code', | |||
| Preview = 'preview', | |||
| } | |||
| enum ContentType { | |||
| Markdown = 'markdown', | |||
| Chunks = 'chunks', | |||
| } | |||
| type DisplayContentProps = { | |||
| type: ContentType | |||
| mdString?: string | |||
| jsonString?: string | |||
| readonly: boolean | |||
| handleTextChange?: (value: string) => void | |||
| handleEditorChange?: (value: string) => void | |||
| } | |||
| const DisplayContent = (props: DisplayContentProps) => { | |||
| const { type, mdString, jsonString, readonly, handleTextChange, handleEditorChange } = props | |||
| const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code) | |||
| const [isFocused, setIsFocused] = useState(false) | |||
| const { t } = useTranslation() | |||
| return ( | |||
| <div className={cn('flex h-full flex-col rounded-[10px] bg-components-input-bg-normal', isFocused && 'bg-components-input-bg-active outline outline-1 outline-components-input-border-active')}> | |||
| <div className='flex shrink-0 items-center justify-between p-1'> | |||
| <div className='system-xs-semibold-uppercase flex items-center px-2 py-0.5 text-text-secondary'> | |||
| {type.toUpperCase()} | |||
| </div> | |||
| <SegmentedControl | |||
| options={[ | |||
| { value: ViewMode.Code, text: t('workflow.nodes.templateTransform.code'), Icon: RiBracesLine }, | |||
| { value: ViewMode.Preview, text: t('workflow.common.preview'), Icon: RiEyeLine }, | |||
| ]} | |||
| value={viewMode} | |||
| onChange={setViewMode} | |||
| size='small' | |||
| padding='with' | |||
| activeClassName='!text-text-accent-light-mode-only' | |||
| btnClassName='!pl-1.5 !pr-0.5 gap-[3px]' | |||
| /> | |||
| </div> | |||
| <div className='flex flex-1 overflow-auto rounded-b-[10px] pb-1 pl-3 pr-1'> | |||
| {viewMode === ViewMode.Code && ( | |||
| type === ContentType.Markdown | |||
| ? <Textarea | |||
| readOnly={readonly} | |||
| disabled={readonly} | |||
| className='h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none' | |||
| value={mdString as any} | |||
| onChange={e => handleTextChange?.(e.target.value)} | |||
| onFocus={() => setIsFocused(true)} | |||
| onBlur={() => setIsFocused(false)} | |||
| /> | |||
| : <SchemaEditor | |||
| readonly={readonly} | |||
| className='overflow-y-auto bg-transparent' | |||
| hideTopMenu | |||
| schema={jsonString!} | |||
| onUpdate={handleEditorChange!} | |||
| onFocus={() => setIsFocused(true)} | |||
| onBlur={() => setIsFocused(false)} | |||
| /> | |||
| )} | |||
| {viewMode === ViewMode.Preview && ( | |||
| type === ContentType.Markdown | |||
| ? <Markdown className='grow overflow-auto rounded-lg !bg-white px-4 py-3' content={(mdString ?? '') as string} /> | |||
| : <ChunkCardList chunkInfo={JSON.parse(jsonString!) as ChunkInfo} /> | |||
| )} | |||
| </div> | |||
| </div> | |||
| ) | |||
| } | |||
| type Props = { | |||
| currentVar: VarInInspect | |||
| @@ -42,6 +124,13 @@ const ValueContent = ({ | |||
| const JSONEditorDisabled = currentVar.value_type === 'array[any]' | |||
| const fileUploadConfig = useStore(s => s.fileUploadConfig) | |||
| const hasChunks = useMemo(() => { | |||
| return currentVar.value_type === 'object' | |||
| && currentVar.value | |||
| && typeof currentVar.value === 'object' | |||
| && ['parent_child_chunks', 'general_chunks', 'qa_chunks'].some(key => key in currentVar.value) | |||
| }, [currentVar.value_type, currentVar.value]) | |||
| const formatFileValue = (value: VarInInspect) => { | |||
| if (value.value_type === 'file') | |||
| return value.value ? getProcessedFilesFromResponse([value.value]) : [] | |||
| @@ -72,7 +161,6 @@ const ValueContent = ({ | |||
| if (showFileEditor) | |||
| setFileValue(formatFileValue(currentVar)) | |||
| // eslint-disable-next-line react-hooks/exhaustive-deps | |||
| }, [currentVar.id, currentVar.value]) | |||
| const handleTextChange = (value: string) => { | |||
| @@ -170,7 +258,14 @@ const ValueContent = ({ | |||
| > | |||
| <div className={cn('grow')} style={{ height: `${editorHeight}px` }}> | |||
| {showTextEditor && ( | |||
| <Textarea | |||
| currentVar.value_type === 'string' ? ( | |||
| <DisplayContent | |||
| type={ContentType.Markdown} | |||
| mdString={value as any} | |||
| readonly={textEditorDisabled} | |||
| handleTextChange={handleTextChange} | |||
| /> | |||
| ) : <Textarea | |||
| readOnly={textEditorDisabled} | |||
| disabled={textEditorDisabled} | |||
| className='h-full' | |||
| @@ -179,13 +274,20 @@ const ValueContent = ({ | |||
| /> | |||
| )} | |||
| {showJSONEditor && ( | |||
| <SchemaEditor | |||
| readonly={JSONEditorDisabled} | |||
| className='overflow-y-auto' | |||
| hideTopMenu | |||
| schema={json} | |||
| onUpdate={handleEditorChange} | |||
| /> | |||
| hasChunks | |||
| ? <DisplayContent | |||
| type={ContentType.Chunks} | |||
| jsonString={json ?? '{}'} | |||
| readonly={JSONEditorDisabled} | |||
| handleEditorChange={handleEditorChange} | |||
| /> | |||
| : <SchemaEditor | |||
| readonly={JSONEditorDisabled} | |||
| className='overflow-y-auto' | |||
| hideTopMenu | |||
| schema={json} | |||
| onUpdate={handleEditorChange} | |||
| /> | |||
| )} | |||
| {showFileEditor && ( | |||
| <div className='max-w-[460px]'> | |||
| @@ -208,6 +310,10 @@ const ValueContent = ({ | |||
| allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url], | |||
| number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5, | |||
| fileUploadConfig, | |||
| preview_config: { | |||
| mode: PreviewMode.NewPage, | |||
| file_type_list: ['application/pdf'], | |||
| }, | |||
| }} | |||
| isDisabled={textEditorDisabled} | |||
| /> | |||