You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

value-content.tsx 15KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. import { useEffect, useMemo, useRef, useState } from 'react'
  2. import { useTranslation } from 'react-i18next'
  3. import { useDebounceFn } from 'ahooks'
  4. import { RiBracesLine, RiEyeLine } from '@remixicon/react'
  5. import Textarea from '@/app/components/base/textarea'
  6. import { Markdown } from '@/app/components/base/markdown'
  7. import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
  8. import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
  9. import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
  10. import {
  11. checkJsonSchemaDepth,
  12. getValidationErrorMessage,
  13. validateSchemaAgainstDraft7,
  14. } from '@/app/components/workflow/nodes/llm/utils'
  15. import {
  16. validateJSONSchema,
  17. } from '@/app/components/workflow/variable-inspect/utils'
  18. import { getProcessedFiles, getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
  19. import { SegmentedControl } from '@/app/components/base/segmented-control'
  20. import { JSON_SCHEMA_MAX_DEPTH } from '@/config'
  21. import { TransferMethod } from '@/types/app'
  22. import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
  23. import { SupportUploadFileTypes } from '@/app/components/workflow/types'
  24. import type { VarInInspect } from '@/types/workflow'
  25. import { VarInInspectType } from '@/types/workflow'
  26. import cn from '@/utils/classnames'
  27. import LargeDataAlert from './large-data-alert'
  28. import BoolValue from '../panel/chat-variable-panel/components/bool-value'
  29. import { useStore } from '@/app/components/workflow/store'
  30. import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
  31. import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
  32. import { PreviewMode } from '../../base/features/types'
  33. import { ChunkingMode } from '@/models/datasets'
  34. enum ViewMode {
  35. Code = 'code',
  36. Preview = 'preview',
  37. }
  38. enum ContentType {
  39. Markdown = 'markdown',
  40. Chunks = 'chunks',
  41. }
  42. type DisplayContentProps = {
  43. type: ContentType
  44. mdString?: string
  45. jsonString?: string
  46. readonly: boolean
  47. handleTextChange?: (value: string) => void
  48. handleEditorChange?: (value: string) => void
  49. }
  50. const DisplayContent = (props: DisplayContentProps) => {
  51. const { type, mdString, jsonString, readonly, handleTextChange, handleEditorChange } = props
  52. const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Code)
  53. const [isFocused, setIsFocused] = useState(false)
  54. const { t } = useTranslation()
  55. return (
  56. <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')}>
  57. <div className='flex shrink-0 items-center justify-between p-1'>
  58. <div className='system-xs-semibold-uppercase flex items-center px-2 py-0.5 text-text-secondary'>
  59. {type.toUpperCase()}
  60. </div>
  61. <SegmentedControl
  62. options={[
  63. { value: ViewMode.Code, text: t('workflow.nodes.templateTransform.code'), Icon: RiBracesLine },
  64. { value: ViewMode.Preview, text: t('workflow.common.preview'), Icon: RiEyeLine },
  65. ]}
  66. value={viewMode}
  67. onChange={setViewMode}
  68. size='small'
  69. padding='with'
  70. activeClassName='!text-text-accent-light-mode-only'
  71. btnClassName='!pl-1.5 !pr-0.5 gap-[3px]'
  72. />
  73. </div>
  74. <div className='flex flex-1 overflow-auto rounded-b-[10px] pl-3 pr-1'>
  75. {viewMode === ViewMode.Code && (
  76. type === ContentType.Markdown
  77. ? <Textarea
  78. readOnly={readonly}
  79. disabled={readonly}
  80. className='h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none'
  81. value={mdString as any}
  82. onChange={e => handleTextChange?.(e.target.value)}
  83. onFocus={() => setIsFocused(true)}
  84. onBlur={() => setIsFocused(false)}
  85. />
  86. : <SchemaEditor
  87. readonly={readonly}
  88. className='overflow-y-auto bg-transparent'
  89. hideTopMenu
  90. schema={jsonString!}
  91. onUpdate={handleEditorChange!}
  92. onFocus={() => setIsFocused(true)}
  93. onBlur={() => setIsFocused(false)}
  94. />
  95. )}
  96. {viewMode === ViewMode.Preview && (
  97. type === ContentType.Markdown
  98. ? <Markdown className='grow overflow-auto rounded-lg !bg-white px-4 py-3' content={(mdString ?? '') as string} />
  99. : <ChunkCardList
  100. chunkType={ChunkingMode.text} // todo: delete mock data
  101. parentMode={'full-doc'} // todo: delete mock data
  102. chunkInfo={JSON.parse(jsonString!) as ChunkInfo}
  103. />
  104. )}
  105. </div>
  106. </div>
  107. )
  108. }
  109. type Props = {
  110. currentVar: VarInInspect
  111. handleValueChange: (varId: string, value: any) => void
  112. isTruncated: boolean
  113. }
  114. const ValueContent = ({
  115. currentVar,
  116. handleValueChange,
  117. isTruncated,
  118. }: Props) => {
  119. const contentContainerRef = useRef<HTMLDivElement>(null)
  120. const errorMessageRef = useRef<HTMLDivElement>(null)
  121. const [editorHeight, setEditorHeight] = useState(0)
  122. const showTextEditor = currentVar.value_type === 'secret' || currentVar.value_type === 'string' || currentVar.value_type === 'number'
  123. const showBoolEditor = typeof currentVar.value === 'boolean'
  124. const showBoolArrayEditor = Array.isArray(currentVar.value) && currentVar.value.every(v => typeof v === 'boolean')
  125. const isSysFiles = currentVar.type === VarInInspectType.system && currentVar.name === 'files'
  126. const showJSONEditor = !isSysFiles && (currentVar.value_type === 'object' || currentVar.value_type === 'array[string]' || currentVar.value_type === 'array[number]' || currentVar.value_type === 'array[object]' || currentVar.value_type === 'array[any]')
  127. const showFileEditor = isSysFiles || currentVar.value_type === 'file' || currentVar.value_type === 'array[file]'
  128. const textEditorDisabled = currentVar.type === VarInInspectType.environment || (currentVar.type === VarInInspectType.system && currentVar.name !== 'query' && currentVar.name !== 'files')
  129. const JSONEditorDisabled = currentVar.value_type === 'array[any]'
  130. const fileUploadConfig = useStore(s => s.fileUploadConfig)
  131. const hasChunks = useMemo(() => {
  132. return currentVar.value_type === 'object'
  133. && currentVar.value
  134. && typeof currentVar.value === 'object'
  135. && ['parent_child_chunks', 'general_chunks', 'qa_chunks'].some(key => key in currentVar.value)
  136. }, [currentVar.value_type, currentVar.value])
  137. const formatFileValue = (value: VarInInspect) => {
  138. if (value.value_type === 'file')
  139. return value.value ? getProcessedFilesFromResponse([value.value]) : []
  140. if (value.value_type === 'array[file]' || (value.type === VarInInspectType.system && currentVar.name === 'files'))
  141. return value.value && value.value.length > 0 ? getProcessedFilesFromResponse(value.value) : []
  142. return []
  143. }
  144. const [value, setValue] = useState<any>()
  145. const [json, setJson] = useState('')
  146. const [parseError, setParseError] = useState<Error | null>(null)
  147. const [validationError, setValidationError] = useState<string>('')
  148. const [fileValue, setFileValue] = useState<any>(formatFileValue(currentVar))
  149. const { run: debounceValueChange } = useDebounceFn(handleValueChange, { wait: 500 })
  150. // update default value when id changed
  151. useEffect(() => {
  152. if (showTextEditor) {
  153. if (currentVar.value_type === 'number')
  154. return setValue(JSON.stringify(currentVar.value))
  155. if (!currentVar.value)
  156. return setValue('')
  157. setValue(currentVar.value)
  158. }
  159. if (showJSONEditor)
  160. setJson(currentVar.value ? JSON.stringify(currentVar.value, null, 2) : '')
  161. if (showFileEditor)
  162. setFileValue(formatFileValue(currentVar))
  163. }, [currentVar.id, currentVar.value])
  164. const handleTextChange = (value: string) => {
  165. if (isTruncated)
  166. return
  167. if (currentVar.value_type === 'string')
  168. setValue(value)
  169. if (currentVar.value_type === 'number') {
  170. if (/^-?\d+(\.)?(\d+)?$/.test(value))
  171. setValue(Number.parseFloat(value))
  172. }
  173. const newValue = currentVar.value_type === 'number' ? Number.parseFloat(value) : value
  174. debounceValueChange(currentVar.id, newValue)
  175. }
  176. const jsonValueValidate = (value: string, type: string) => {
  177. try {
  178. const newJSONSchema = JSON.parse(value)
  179. setParseError(null)
  180. const result = validateJSONSchema(newJSONSchema, type)
  181. if (!result.success) {
  182. setValidationError(result.error.message)
  183. return false
  184. }
  185. if (type === 'object' || type === 'array[object]') {
  186. const schemaDepth = checkJsonSchemaDepth(newJSONSchema)
  187. if (schemaDepth > JSON_SCHEMA_MAX_DEPTH) {
  188. setValidationError(`Schema exceeds maximum depth of ${JSON_SCHEMA_MAX_DEPTH}.`)
  189. return false
  190. }
  191. const validationErrors = validateSchemaAgainstDraft7(newJSONSchema)
  192. if (validationErrors.length > 0) {
  193. setValidationError(getValidationErrorMessage(validationErrors))
  194. return false
  195. }
  196. }
  197. setValidationError('')
  198. return true
  199. }
  200. catch (error) {
  201. setValidationError('')
  202. if (error instanceof Error) {
  203. setParseError(error)
  204. return false
  205. }
  206. else {
  207. setParseError(new Error('Invalid JSON'))
  208. return false
  209. }
  210. }
  211. }
  212. const handleEditorChange = (value: string) => {
  213. if (isTruncated)
  214. return
  215. setJson(value)
  216. if (jsonValueValidate(value, currentVar.value_type)) {
  217. const parsed = JSON.parse(value)
  218. debounceValueChange(currentVar.id, parsed)
  219. }
  220. }
  221. const fileValueValidate = (fileList: any[]) => fileList.every(file => file.upload_file_id)
  222. const handleFileChange = (value: any[]) => {
  223. setFileValue(value)
  224. // check every file upload progress
  225. // invoke update api after every file uploaded
  226. if (!fileValueValidate(value))
  227. return
  228. if (currentVar.value_type === 'file')
  229. debounceValueChange(currentVar.id, value[0])
  230. if (currentVar.value_type === 'array[file]' || isSysFiles)
  231. debounceValueChange(currentVar.id, value)
  232. }
  233. // get editor height
  234. useEffect(() => {
  235. if (contentContainerRef.current && errorMessageRef.current) {
  236. const errorMessageObserver = new ResizeObserver((entries) => {
  237. for (const entry of entries) {
  238. const { inlineSize } = entry.borderBoxSize[0]
  239. const height = (contentContainerRef.current as any).clientHeight - inlineSize
  240. setEditorHeight(height)
  241. }
  242. })
  243. errorMessageObserver.observe(errorMessageRef.current)
  244. return () => {
  245. errorMessageObserver.disconnect()
  246. }
  247. }
  248. }, [setEditorHeight])
  249. return (
  250. <div
  251. ref={contentContainerRef}
  252. className='flex h-full flex-col'
  253. >
  254. <div className={cn('relative grow')} style={{ height: `${editorHeight}px` }}>
  255. {showTextEditor && (
  256. <>
  257. {isTruncated && <LargeDataAlert className='absolute left-3 right-3 top-1' />}
  258. {
  259. currentVar.value_type === 'string' ? (
  260. <DisplayContent
  261. type={ContentType.Markdown}
  262. mdString={value as any}
  263. readonly={textEditorDisabled}
  264. handleTextChange={handleTextChange}
  265. />
  266. ) : <Textarea
  267. readOnly={textEditorDisabled}
  268. disabled={textEditorDisabled || isTruncated}
  269. className={cn('h-full', isTruncated && 'pt-[48px]')}
  270. value={value as any}
  271. onChange={e => handleTextChange(e.target.value)}
  272. />
  273. }
  274. </>
  275. )}
  276. {showBoolEditor && (
  277. <div className='w-[295px]'>
  278. <BoolValue
  279. value={currentVar.value as boolean}
  280. onChange={(newValue) => {
  281. setValue(newValue)
  282. debounceValueChange(currentVar.id, newValue)
  283. }}
  284. />
  285. </div>
  286. )}
  287. {
  288. showBoolArrayEditor && (
  289. <div className='w-[295px] space-y-1'>
  290. {currentVar.value.map((v: boolean, i: number) => (
  291. <BoolValue
  292. key={i}
  293. value={v}
  294. onChange={(newValue) => {
  295. const newArray = [...(currentVar.value as boolean[])]
  296. newArray[i] = newValue
  297. setValue(newArray)
  298. debounceValueChange(currentVar.id, newArray)
  299. }}
  300. />
  301. ))}
  302. </div>
  303. )
  304. }
  305. {showJSONEditor && (
  306. hasChunks
  307. ? <DisplayContent
  308. type={ContentType.Chunks}
  309. jsonString={json ?? '{}'}
  310. readonly={JSONEditorDisabled}
  311. handleEditorChange={handleEditorChange}
  312. />
  313. : <SchemaEditor
  314. readonly={JSONEditorDisabled || isTruncated}
  315. className='overflow-y-auto'
  316. hideTopMenu
  317. schema={json}
  318. onUpdate={handleEditorChange}
  319. isTruncated={isTruncated}
  320. />
  321. )}
  322. {showFileEditor && (
  323. <div className='max-w-[460px]'>
  324. <FileUploaderInAttachmentWrapper
  325. value={fileValue}
  326. onChange={files => handleFileChange(getProcessedFiles(files))}
  327. fileConfig={{
  328. allowed_file_types: [
  329. SupportUploadFileTypes.image,
  330. SupportUploadFileTypes.document,
  331. SupportUploadFileTypes.audio,
  332. SupportUploadFileTypes.video,
  333. ],
  334. allowed_file_extensions: [
  335. ...FILE_EXTS[SupportUploadFileTypes.image],
  336. ...FILE_EXTS[SupportUploadFileTypes.document],
  337. ...FILE_EXTS[SupportUploadFileTypes.audio],
  338. ...FILE_EXTS[SupportUploadFileTypes.video],
  339. ],
  340. allowed_file_upload_methods: [TransferMethod.local_file, TransferMethod.remote_url],
  341. number_limits: currentVar.value_type === 'file' ? 1 : fileUploadConfig?.workflow_file_upload_limit || 5,
  342. fileUploadConfig,
  343. preview_config: {
  344. mode: PreviewMode.NewPage,
  345. file_type_list: ['application/pdf'],
  346. },
  347. }}
  348. isDisabled={textEditorDisabled}
  349. />
  350. </div>
  351. )}
  352. </div>
  353. <div ref={errorMessageRef} className='shrink-0'>
  354. {parseError && <ErrorMessage className='mt-1' message={parseError.message} />}
  355. {validationError && <ErrorMessage className='mt-1' message={validationError} />}
  356. </div>
  357. </div >
  358. )
  359. }
  360. export default ValueContent