ソースを参照

Fix: support file download in workflow result (#11338)

KVOJJJin 4 ヶ月 前
コミット
0b25c0b677

+ 1 - 1
web/app/components/app/text-generate/item/index.tsx

@@ -334,7 +334,7 @@ const GenerationItem: FC<IGenerationItemProps> = ({
                     </SimpleBtn>
                   )
                 }
-                {(currentTab === 'RESULT' || !isWorkflow) && (
+                {((currentTab === 'RESULT' && workflowProcessData?.resultText) || !isWorkflow) && (
                   <SimpleBtn
                     isDisabled={isError || !messageId}
                     className={cn(isMobile && '!px-1.5', 'space-x-1')}

+ 17 - 10
web/app/components/app/text-generate/item/result-tab.tsx

@@ -27,15 +27,15 @@ const ResultTab = ({
     onCurrentTabChange(tab)
   }
   useEffect(() => {
-    if (data?.resultText)
+    if (data?.resultText || !!data?.files?.length)
       switchTab('RESULT')
     else
       switchTab('DETAIL')
-  }, [data?.resultText])
+  }, [data?.files?.length, data?.resultText])
 
   return (
     <div className='grow relative flex flex-col'>
-      {data?.resultText && (
+      {(data?.resultText || !!data?.files?.length) && (
         <div className='shrink-0 flex items-center mb-2 border-b-[0.5px] border-[rgba(0,0,0,0.05)]'>
           <div
             className={cn(
@@ -56,14 +56,21 @@ const ResultTab = ({
       <div className={cn('grow bg-white')}>
         {currentTab === 'RESULT' && (
           <>
-            <Markdown content={data?.resultText || ''} />
+            {data?.resultText && <Markdown content={data?.resultText || ''} />}
             {!!data?.files?.length && (
-              <FileList
-                files={data?.files}
-                showDeleteAction={false}
-                showDownloadAction
-                canPreview
-              />
+              <div className='flex flex-col gap-2'>
+                {data?.files.map((item: any) => (
+                  <div key={item.varName} className='flex flex-col gap-1 system-xs-regular'>
+                    <div className='py-1 text-text-tertiary '>{item.varName}</div>
+                    <FileList
+                      files={item.list}
+                      showDeleteAction={false}
+                      showDownloadAction
+                      canPreview
+                    />
+                  </div>
+                ))}
+              </div>
             )}
           </>
         )}

+ 37 - 17
web/app/components/base/file-uploader/file-list-in-log.tsx

@@ -1,4 +1,5 @@
-import React, { useState } from 'react'
+import React, { useMemo, useState } from 'react'
+import { useTranslation } from 'react-i18next'
 import { RiArrowRightSLine } from '@remixicon/react'
 import FileImageRender from './file-image-render'
 import FileTypeIcon from './file-type-icon'
@@ -12,23 +13,36 @@ import { SupportUploadFileTypes } from '@/app/components/workflow/types'
 import cn from '@/utils/classnames'
 
 type Props = {
-  fileList: FileEntity[]
+  fileList: {
+    varName: string
+    list: FileEntity[]
+  }[]
+  isExpanded?: boolean
+  noBorder?: boolean
+  noPadding?: boolean
 }
 
-const FileListInLog = ({ fileList }: Props) => {
-  const [expanded, setExpanded] = useState(false)
+const FileListInLog = ({ fileList, isExpanded = false, noBorder = false, noPadding = false }: Props) => {
+  const { t } = useTranslation()
+  const [expanded, setExpanded] = useState(isExpanded)
+  const fullList = useMemo(() => {
+    return fileList.reduce((acc: FileEntity[], { list }) => {
+      return [...acc, ...list]
+    }, [])
+  }, [fileList])
 
   if (!fileList.length)
     return null
+
   return (
-    <div className={cn('border-t border-divider-subtle px-3 py-2', expanded && 'py-3')}>
+    <div className={cn('px-3 py-2', expanded && 'py-3', !noBorder && 'border-t border-divider-subtle', noPadding && '!p-0')}>
       <div className='flex justify-between gap-1'>
         {expanded && (
-          <div></div>
+          <div className='grow py-1 text-text-secondary system-xs-semibold-uppercase cursor-pointer' onClick={() => setExpanded(!expanded)}>{t('appLog.runDetail.fileListLabel')}</div>
         )}
         {!expanded && (
-          <div className='flex'>
-            {fileList.map((file) => {
+          <div className='flex gap-1'>
+            {fullList.map((file) => {
               const { id, name, type, supportFileType, base64Url, url } = file
               const isImageFile = supportFileType === SupportUploadFileTypes.image
               return (
@@ -63,19 +77,25 @@ const FileListInLog = ({ fileList }: Props) => {
           </div>
         )}
         <div className='flex items-center gap-1 cursor-pointer' onClick={() => setExpanded(!expanded)}>
-          {!expanded && <div className='text-text-tertiary system-xs-medium-uppercase'>DETAIL</div>}
+          {!expanded && <div className='text-text-tertiary system-xs-medium-uppercase'>{t('appLog.runDetail.fileListDetail')}</div>}
           <RiArrowRightSLine className={cn('w-4 h-4 text-text-tertiary', expanded && 'rotate-90')} />
         </div>
       </div>
       {expanded && (
-        <div className='flex flex-col gap-1'>
-          {fileList.map(file => (
-            <FileItem
-              key={file.id}
-              file={file}
-              showDeleteAction={false}
-              showDownloadAction
-            />
+        <div className='flex flex-col gap-3'>
+          {fileList.map(item => (
+            <div key={item.varName} className='flex flex-col gap-1 system-xs-regular'>
+              <div className='py-1 text-text-tertiary '>{item.varName}</div>
+              {item.list.map(file => (
+                <FileItem
+                  key={file.id}
+                  file={file}
+                  showDeleteAction={false}
+                  showDownloadAction
+                  canPreview
+                />
+              ))}
+            </div>
           ))}
         </div>
       )}

+ 98 - 73
web/app/components/base/file-uploader/file-uploader-in-attachment/file-item.tsx

@@ -1,12 +1,15 @@
 import {
   memo,
+  useState,
 } from 'react'
 import {
   RiDeleteBinLine,
   RiDownloadLine,
+  RiEyeLine,
 } from '@remixicon/react'
 import FileTypeIcon from '../file-type-icon'
 import {
+  downloadFile,
   fileIsUploaded,
   getFileAppearanceType,
   getFileExtension,
@@ -19,6 +22,7 @@ import { formatFileSize } from '@/utils/format'
 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'
 
 type FileInAttachmentItemProps = {
   file: FileEntity
@@ -26,6 +30,7 @@ type FileInAttachmentItemProps = {
   showDownloadAction?: boolean
   onRemove?: (fileId: string) => void
   onReUpload?: (fileId: string) => void
+  canPreview?: boolean
 }
 const FileInAttachmentItem = ({
   file,
@@ -33,96 +38,116 @@ const FileInAttachmentItem = ({
   showDownloadAction = true,
   onRemove,
   onReUpload,
+  canPreview,
 }: FileInAttachmentItemProps) => {
   const { id, name, type, progress, supportFileType, base64Url, url, isRemote } = file
   const ext = getFileExtension(name, type, isRemote)
   const isImageFile = supportFileType === SupportUploadFileTypes.image
-
+  const [imagePreviewUrl, setImagePreviewUrl] = useState('')
   return (
-    <div className={cn(
-      'flex items-center pr-3 h-12 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
-      progress === -1 && 'bg-state-destructive-hover border-state-destructive-border',
-    )}>
-      <div className='flex items-center justify-center w-12 h-12'>
-        {
-          isImageFile && (
-            <FileImageRender
-              className='w-8 h-8'
-              imageUrl={base64Url || url || ''}
-            />
-          )
-        }
-        {
-          !isImageFile && (
-            <FileTypeIcon
-              type={getFileAppearanceType(name, type)}
-              size='lg'
-            />
-          )
-        }
-      </div>
-      <div className='grow w-0 mr-1'>
-        <div
-          className='flex items-center mb-0.5 system-xs-medium text-text-secondary truncate'
-          title={file.name}
-        >
-          <div className='truncate'>{name}</div>
+    <>
+      <div className={cn(
+        'flex items-center pr-3 h-12 rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-on-panel-item-bg shadow-xs',
+        progress === -1 && 'bg-state-destructive-hover border-state-destructive-border',
+      )}>
+        <div className='flex items-center justify-center w-12 h-12'>
+          {
+            isImageFile && (
+              <FileImageRender
+                className='w-8 h-8'
+                imageUrl={base64Url || url || ''}
+              />
+            )
+          }
+          {
+            !isImageFile && (
+              <FileTypeIcon
+                type={getFileAppearanceType(name, type)}
+                size='lg'
+              />
+            )
+          }
         </div>
-        <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
+        <div className='grow w-0 mr-1'>
+          <div
+            className='flex items-center mb-0.5 system-xs-medium text-text-secondary truncate'
+            title={file.name}
+          >
+            <div className='truncate'>{name}</div>
+          </div>
+          <div className='flex items-center system-2xs-medium-uppercase text-text-tertiary'>
+            {
+              ext && (
+                <span>{ext.toLowerCase()}</span>
+              )
+            }
+            {
+              ext && (
+                <span className='mx-1 system-2xs-medium'>•</span>
+              )
+            }
+            {
+              !!file.size && (
+                <span>{formatFileSize(file.size)}</span>
+              )
+            }
+          </div>
+        </div>
+        <div className='shrink-0 flex items-center'>
+          {
+            progress >= 0 && !fileIsUploaded(file) && (
+              <ProgressCircle
+                className='mr-2.5'
+                percentage={progress}
+              />
+            )
+          }
           {
-            ext && (
-              <span>{ext.toLowerCase()}</span>
+            progress === -1 && (
+              <ActionButton
+                className='mr-1'
+                onClick={() => onReUpload?.(id)}
+              >
+                <ReplayLine className='w-4 h-4 text-text-tertiary' />
+              </ActionButton>
             )
           }
           {
-            ext && (
-              <span className='mx-1 system-2xs-medium'>•</span>
+            showDeleteAction && (
+              <ActionButton onClick={() => onRemove?.(id)}>
+                <RiDeleteBinLine className='w-4 h-4' />
+              </ActionButton>
             )
           }
           {
-            !!file.size && (
-              <span>{formatFileSize(file.size)}</span>
+            canPreview && isImageFile && (
+              <ActionButton className='mr-1' onClick={() => setImagePreviewUrl(url || '')}>
+                <RiEyeLine className='w-4 h-4' />
+              </ActionButton>
+            )
+          }
+          {
+            showDownloadAction && (
+              <ActionButton onClick={(e) => {
+                e.stopPropagation()
+                downloadFile(url || base64Url || '', name)
+              }}>
+                <RiDownloadLine className='w-4 h-4' />
+              </ActionButton>
             )
           }
         </div>
       </div>
-      <div className='shrink-0 flex items-center'>
-        {
-          progress >= 0 && !fileIsUploaded(file) && (
-            <ProgressCircle
-              className='mr-2.5'
-              percentage={progress}
-            />
-          )
-        }
-        {
-          progress === -1 && (
-            <ActionButton
-              className='mr-1'
-              onClick={() => onReUpload?.(id)}
-            >
-              <ReplayLine className='w-4 h-4 text-text-tertiary' />
-            </ActionButton>
-          )
-        }
-        {
-          showDeleteAction && (
-            <ActionButton onClick={() => onRemove?.(id)}>
-              <RiDeleteBinLine className='w-4 h-4' />
-            </ActionButton>
-          )
-        }
-        {
-          showDownloadAction && (
-            <ActionButton
-              size='xs'
-            >
-              <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />
-            </ActionButton>
-          )
-        }
-      </div>
-    </div>
+      {
+        imagePreviewUrl && canPreview && (
+          <ImagePreview
+            title={name}
+            url={imagePreviewUrl}
+            onCancel={() => setImagePreviewUrl('')}
+          />
+        )
+      }
+    </>
   )
 }
 

+ 2 - 2
web/app/components/base/file-uploader/file-uploader-in-chat-input/file-item.tsx

@@ -31,7 +31,7 @@ const FileItem = ({
   onRemove,
   onReUpload,
 }: FileItemProps) => {
-  const { id, name, type, progress, url, isRemote } = file
+  const { id, name, type, progress, url, base64Url, isRemote } = file
   const ext = getFileExtension(name, type, isRemote)
   const uploadError = progress === -1
 
@@ -86,7 +86,7 @@ const FileItem = ({
               className='hidden group-hover/file-item:flex absolute -right-1 -top-1'
               onClick={(e) => {
                 e.stopPropagation()
-                downloadFile(url || '', name)
+                downloadFile(url || base64Url || '', name)
               }}
             >
               <RiDownloadLine className='w-3.5 h-3.5 text-text-tertiary' />

+ 15 - 6
web/app/components/base/file-uploader/utils.ts

@@ -1,5 +1,4 @@
 import mime from 'mime'
-import { flatten } from 'lodash-es'
 import { FileAppearanceTypeEnum } from './types'
 import type { FileEntity } from './types'
 import { upload } from '@/service/base'
@@ -158,12 +157,22 @@ export const isAllowedFileExtension = (fileName: string, fileMimetype: string, a
 }
 
 export const getFilesInLogs = (rawData: any) => {
-  const originalFiles = flatten(Object.keys(rawData || {}).map((key) => {
-    if (typeof rawData[key] === 'object' || Array.isArray(rawData[key]))
-      return rawData[key]
+  const result = Object.keys(rawData || {}).map((key) => {
+    if (typeof rawData[key] === 'object' && rawData[key].dify_model_identity === '__dify__file__') {
+      return {
+        varName: key,
+        list: getProcessedFilesFromResponse([rawData[key]]),
+      }
+    }
+    if (Array.isArray(rawData[key]) && rawData[key].some(item => item.dify_model_identity === '__dify__file__')) {
+      return {
+        varName: key,
+        list: getProcessedFilesFromResponse(rawData[key]),
+      }
+    }
     return undefined
-  }).filter(Boolean)).filter(item => item?.model_identity === '__dify__file__')
-  return getProcessedFilesFromResponse(originalFiles)
+  }).filter(Boolean)
+  return result
 }
 
 export const fileIsUploaded = (file: FileEntity) => {

+ 2 - 2
web/app/components/share/text-generation/result/index.tsx

@@ -21,7 +21,7 @@ import { sleep } from '@/utils'
 import type { SiteInfo } from '@/models/share'
 import { TEXT_GENERATION_TIMEOUT_MS } from '@/config'
 import {
-  getProcessedFilesFromResponse,
+  getFilesInLogs,
 } from '@/app/components/base/file-uploader/utils'
 
 export type IResultProps = {
@@ -288,7 +288,7 @@ const Result: FC<IResultProps> = ({
             }
             setWorkflowProcessData(produce(getWorkflowProcessData()!, (draft) => {
               draft.status = WorkflowRunningStatus.Succeeded
-              draft.files = getProcessedFilesFromResponse(data.files || [])
+              draft.files = getFilesInLogs(data.outputs || []) as any[]
             }))
             if (!data.outputs) {
               setCompletionRes('')

+ 2 - 2
web/app/components/workflow/hooks/use-workflow-run.ts

@@ -26,7 +26,7 @@ import {
 import { useFeaturesStore } from '@/app/components/base/features/hooks'
 import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
 import {
-  getProcessedFilesFromResponse,
+  getFilesInLogs,
 } from '@/app/components/base/file-uploader/utils'
 
 export const useWorkflowRun = () => {
@@ -213,7 +213,7 @@ export const useWorkflowRun = () => {
             draft.result = {
               ...draft.result,
               ...data,
-              files: getProcessedFilesFromResponse(data.files || []),
+              files: getFilesInLogs(data.outputs),
             } as any
             if (isStringOutput) {
               draft.resultTabActive = true

+ 4 - 1
web/app/components/workflow/nodes/_base/components/editor/base.tsx

@@ -27,7 +27,10 @@ type Props = {
   isInNode?: boolean
   onGenerated?: (prompt: string) => void
   codeLanguages?: CodeLanguage
-  fileList?: FileEntity[]
+  fileList?: {
+    varName: string
+    list: FileEntity[]
+  }[]
   showFileList?: boolean
   showCodeGenerator?: boolean
 }

+ 1 - 1
web/app/components/workflow/nodes/_base/components/editor/code-editor/index.tsx

@@ -208,7 +208,7 @@ const CodeEditor: FC<Props> = ({
             isInNode={isInNode}
             onGenerated={onGenerated}
             codeLanguages={language}
-            fileList={fileList}
+            fileList={fileList as any}
             showFileList={showFileList}
             showCodeGenerator={showCodeGenerator}
           >

+ 1 - 1
web/app/components/workflow/panel/workflow-preview.tsx

@@ -48,7 +48,7 @@ const WorkflowPreview = () => {
   }, [showDebugAndPreviewPanel, showInputsPanel])
 
   useEffect(() => {
-    if ((workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData?.result.status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText)
+    if ((workflowRunningData?.result.status === WorkflowRunningStatus.Succeeded || workflowRunningData?.result.status === WorkflowRunningStatus.Failed) && !workflowRunningData.resultText && !workflowRunningData.result.files?.length)
       switchTab('DETAIL')
   }, [workflowRunningData])
 

+ 41 - 3
web/app/components/workflow/run/output-panel.tsx

@@ -1,10 +1,13 @@
 'use client'
 import type { FC } from 'react'
+import { useMemo } from 'react'
 import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
 import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
 import { Markdown } from '@/app/components/base/markdown'
 import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
+import { FileList } from '@/app/components/base/file-uploader'
 import StatusContainer from '@/app/components/workflow/run/status-container'
+import { getProcessedFilesFromResponse } from '@/app/components/base/file-uploader/utils'
 
 type OutputPanelProps = {
   isRunning?: boolean
@@ -19,6 +22,30 @@ const OutputPanel: FC<OutputPanelProps> = ({
   error,
   height,
 }) => {
+  const isTextOutput = useMemo(() => {
+    return outputs && Object.keys(outputs).length === 1 && typeof outputs[Object.keys(outputs)[0]] === 'string'
+  }, [outputs])
+
+  const fileList = useMemo(() => {
+    const fileList: any[] = []
+    if (!outputs)
+      return fileList
+    if (Object.keys(outputs).length > 1)
+      return fileList
+    for (const key in outputs) {
+      if (Array.isArray(outputs[key])) {
+        outputs[key].map((output: any) => {
+          if (output.dify_model_identity === '__dify__file__')
+            fileList.push(output)
+          return null
+        })
+      }
+      else if (outputs[key].dify_model_identity === '__dify__file__') {
+        fileList.push(outputs[key])
+      }
+    }
+    return getProcessedFilesFromResponse(fileList)
+  }, [outputs])
   return (
     <div className='py-2'>
       {isRunning && (
@@ -36,20 +63,31 @@ const OutputPanel: FC<OutputPanelProps> = ({
           <Markdown content='No Output' />
         </div>
       )}
-      {outputs && Object.keys(outputs).length === 1 && (
+      {isTextOutput && (
         <div className='px-4 py-2'>
           <Markdown content={outputs[Object.keys(outputs)[0]] || ''} />
         </div>
       )}
+      {fileList.length > 0 && (
+        <div className='px-4 py-2'>
+          <FileList
+            files={fileList}
+            showDeleteAction={false}
+            showDownloadAction
+            canPreview
+          />
+        </div>
+      )}
       {outputs && Object.keys(outputs).length > 1 && height! > 0 && (
-        <div className='px-4 py-2 flex flex-col gap-2'>
+        <div className='flex flex-col gap-2'>
           <CodeEditor
+            showFileList
             readOnly
             title={<div></div>}
             language={CodeLanguage.json}
             value={outputs}
             isJSONStringifyBeauty
-            height={height}
+            height={height ? (height - 16) / 2 : undefined}
           />
         </div>
       )}

+ 22 - 16
web/app/components/workflow/run/result-text.tsx

@@ -6,14 +6,13 @@ import { Markdown } from '@/app/components/base/markdown'
 import LoadingAnim from '@/app/components/base/chat/chat/loading-anim'
 import StatusContainer from '@/app/components/workflow/run/status-container'
 import { FileList } from '@/app/components/base/file-uploader'
-import type { FileEntity } from '@/app/components/base/file-uploader/types'
 
 type ResultTextProps = {
   isRunning?: boolean
   outputs?: any
   error?: string
   onClick?: () => void
-  allFiles?: FileEntity[]
+  allFiles?: any[]
 }
 
 const ResultText: FC<ResultTextProps> = ({
@@ -25,20 +24,20 @@ const ResultText: FC<ResultTextProps> = ({
 }) => {
   const { t } = useTranslation()
   return (
-    <div className='bg-background-section-burn py-2'>
+    <div className='bg-background-section-burn'>
       {isRunning && !outputs && (
         <div className='pt-4 pl-[26px]'>
           <LoadingAnim type='text' />
         </div>
       )}
       {!isRunning && error && (
-        <div className='px-4'>
+        <div className='px-4 py-2'>
           <StatusContainer status='failed'>
             {error}
           </StatusContainer>
         </div>
       )}
-      {!isRunning && !outputs && !error && (
+      {!isRunning && !outputs && !error && !allFiles?.length && (
         <div className='mt-[120px] px-4 py-2 flex flex-col items-center text-[13px] leading-[18px] text-gray-500'>
           <ImageIndentLeft className='w-6 h-6 text-gray-400' />
           <div className='mr-2'>{t('runLog.resultEmpty.title')}</div>
@@ -49,18 +48,25 @@ const ResultText: FC<ResultTextProps> = ({
           </div>
         </div>
       )}
-      {outputs && (
-        <div className='px-4 py-2'>
-          <Markdown content={outputs} />
-          {!!allFiles?.length && (
-            <FileList
-              files={allFiles}
-              showDeleteAction={false}
-              showDownloadAction
-              canPreview
-            />
+      {(outputs || !!allFiles?.length) && (
+        <>
+          {outputs && (
+            <div className='px-4 py-2'>
+              <Markdown content={outputs} />
+            </div>
           )}
-        </div>
+          {!!allFiles?.length && allFiles.map(item => (
+            <div key={item.varName} className='px-4 py-2 flex flex-col gap-1 system-xs-regular'>
+              <div className='py-1 text-text-tertiary '>{item.varName}</div>
+              <FileList
+                files={item.list}
+                showDeleteAction={false}
+                showDownloadAction
+                canPreview
+              />
+            </div>
+          ))}
+        </>
       )}
     </div>
   )

+ 2 - 0
web/i18n/en-US/app-log.ts

@@ -79,6 +79,8 @@ const translation = {
   runDetail: {
     title: 'Conversation Log',
     workflowTitle: 'Log Detail',
+    fileListLabel: 'File Details',
+    fileListDetail: 'Detail',
   },
   promptLog: 'Prompt Log',
   agentLog: 'Agent Log',

+ 2 - 0
web/i18n/zh-Hans/app-log.ts

@@ -79,6 +79,8 @@ const translation = {
   runDetail: {
     title: '对话日志',
     workflowTitle: '日志详情',
+    fileListLabel: '文件详情',
+    fileListDetail: '详情',
   },
   promptLog: 'Prompt 日志',
   agentLog: 'Agent 日志',