mirror of https://github.com/langgenius/dify.git
feat: variable preview
This commit is contained in:
parent
cd760633cb
commit
d416d78b2c
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
Loading…
Reference in New Issue