diff --git a/packages/service/core/workflow/dispatch/interactive/formInput.ts b/packages/service/core/workflow/dispatch/interactive/formInput.ts index 2d54d7752d99..569a19e65675 100644 --- a/packages/service/core/workflow/dispatch/interactive/formInput.ts +++ b/packages/service/core/workflow/dispatch/interactive/formInput.ts @@ -10,6 +10,7 @@ import type { UserInputFormItemType } from '@fastgpt/global/core/workflow/templa import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { anyValueDecrypt } from '../../../../common/secret/utils'; import { getLogger, LogCategories } from '../../../../common/logger'; +import { getS3ChatSource } from '../../../../common/s3/sources/chat'; const logger = getLogger(LogCategories.MODULE.WORKFLOW.INTERACTIVE); @@ -60,31 +61,43 @@ export const dispatchFormInput = async (props: Props): Promise { - const inputConfig = userInputForms.find((form) => form.key === key); + const userInputVal: Record = {}; + for (const [key, value] of Object.entries(rawUserInputVal)) { + const inputConfig = userInputForms.find((form) => form.key === key); - if (inputConfig?.type === FlowNodeInputTypeEnum.password) { - acc[key] = anyValueDecrypt(value); - } else if (inputConfig?.type === FlowNodeInputTypeEnum.fileSelect) { - if (Array.isArray(value)) { - acc[key] = value.map((file: any) => { - if (typeof file === 'object' && file.url) { + if (inputConfig?.type === FlowNodeInputTypeEnum.password) { + userInputVal[key] = anyValueDecrypt(value); + } else if (inputConfig?.type === FlowNodeInputTypeEnum.fileSelect) { + if (Array.isArray(value)) { + const files = await Promise.all( + value.map(async (file: any) => { + if (typeof file === 'string' && file) { + return file; + } + + if (!file || typeof file !== 'object') return; + + if (typeof file.key === 'string' && file.key) { + const { url } = await getS3ChatSource().createGetChatFileURL({ + key: file.key, + external: true + }); + return url; + } + + if (typeof file.url === 'string' && file.url) { return file.url; } - return file; - }); - } else { - acc[key] = value; - } + }) + ); + userInputVal[key] = files.filter((file) => typeof file === 'string' && file); } else { - acc[key] = value; + userInputVal[key] = typeof value === 'string' && value ? [value] : []; } - - return acc; - }, - {} as Record - ); + } else { + userInputVal[key] = value; + } + } return { data: { diff --git a/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts b/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts new file mode 100644 index 000000000000..6ff722a1143f --- /dev/null +++ b/packages/service/test/core/workflow/dispatch/interactive/formInput.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { dispatchFormInput } from '@fastgpt/service/core/workflow/dispatch/interactive/formInput'; +import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; +import { + NodeInputKeyEnum, + NodeOutputKeyEnum, + WorkflowIOValueTypeEnum +} from '@fastgpt/global/core/workflow/constants'; +import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; + +const mocks = vi.hoisted(() => ({ + createGetChatFileURL: vi.fn() +})); + +vi.mock('@fastgpt/service/common/s3/sources/chat', () => ({ + getS3ChatSource: () => ({ + createGetChatFileURL: mocks.createGetChatFileURL + }) +})); + +const buildProps = (text: string) => + ({ + histories: [{ id: 'h1' }, { id: 'h2' }], + node: { + isEntry: true + }, + params: { + [NodeInputKeyEnum.description]: '', + [NodeInputKeyEnum.userInputForms]: [ + { + key: 'files', + label: 'Files', + type: FlowNodeInputTypeEnum.fileSelect, + valueType: WorkflowIOValueTypeEnum.arrayAny, + value: [], + required: false + } + ] + }, + query: [ + { + text: { + content: text + } + } + ], + lastInteractive: { + type: 'userInput' + } + }) as any; + +describe('dispatchFormInput', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('converts fileSelect keys to urls and filters invalid file objects', async () => { + mocks.createGetChatFileURL.mockImplementation(async ({ key }: { key: string }) => ({ + url: `https://files.example/${key}` + })); + + const result = await dispatchFormInput( + buildProps( + JSON.stringify({ + files: [ + { key: 'chat/app/user/chat/file.png' }, + { url: 'https://example.com/file.pdf' }, + { rawFile: {}, icon: 'data:image/png;base64,large' }, + null, + {}, + '' + ] + }) + ) + ); + + expect(mocks.createGetChatFileURL).toHaveBeenCalledWith({ + key: 'chat/app/user/chat/file.png', + external: true + }); + expect(result.data?.files).toEqual([ + 'https://files.example/chat/app/user/chat/file.png', + 'https://example.com/file.pdf' + ]); + expect(result.data?.[NodeOutputKeyEnum.formInputResult]).toEqual({ + files: ['https://files.example/chat/app/user/chat/file.png', 'https://example.com/file.pdf'] + }); + }); + + it('returns an interactive form when the node is not ready to consume input', async () => { + const props = buildProps('{}'); + props.node.isEntry = false; + + const result = await dispatchFormInput(props); + + expect(result[DispatchNodeResponseKeyEnum.interactive]).toEqual({ + type: 'userInput', + params: { + description: '', + inputForm: props.params[NodeInputKeyEnum.userInputForms] + } + }); + expect(props.node.isEntry).toBe(false); + }); + + it('does not pass non-array fileSelect objects through', async () => { + const result = await dispatchFormInput( + buildProps( + JSON.stringify({ + files: { icon: 'data:image/png;base64,large' } + }) + ) + ); + + expect(result.data?.files).toEqual([]); + }); +}); diff --git a/packages/web/i18n/en/common.json b/packages/web/i18n/en/common.json index abdd2cf493ab..8fc703fc1c71 100644 --- a/packages/web/i18n/en/common.json +++ b/packages/web/i18n/en/common.json @@ -365,6 +365,7 @@ "core.chat.Unpin": "Unpin", "core.chat.error.Chat error": "Chat Error", "core.chat.error.Messages empty": "API Content is Empty, Possibly Due to Text Being Too Long", + "core.chat.error.Request too large": "The debug request is too large. Reduce this input or split the file and try again.", "core.chat.error.Select dataset empty": "You Have Not Selected a Dataset", "core.chat.error.data_error": "Data Retrieval Error", "core.chat.feedback.No Content": "User Did Not Provide Specific Feedback Content", diff --git a/packages/web/i18n/zh-CN/common.json b/packages/web/i18n/zh-CN/common.json index 5da10d595d77..dac07a635908 100644 --- a/packages/web/i18n/zh-CN/common.json +++ b/packages/web/i18n/zh-CN/common.json @@ -365,6 +365,7 @@ "core.chat.Unpin": "取消置顶", "core.chat.error.Chat error": "对话出现异常", "core.chat.error.Messages empty": "接口内容为空,可能文本超长了~", + "core.chat.error.Request too large": "调试请求内容过大,请减少本次输入内容或拆分文件后重试", "core.chat.error.Select dataset empty": "你没有选择知识库", "core.chat.error.data_error": "获取数据异常", "core.chat.feedback.No Content": "用户没有填写具体反馈内容", diff --git a/packages/web/i18n/zh-Hant/common.json b/packages/web/i18n/zh-Hant/common.json index 0b02196e16c1..566286781f59 100644 --- a/packages/web/i18n/zh-Hant/common.json +++ b/packages/web/i18n/zh-Hant/common.json @@ -361,6 +361,7 @@ "core.chat.Unpin": "取消釘選", "core.chat.error.Chat error": "對話發生錯誤", "core.chat.error.Messages empty": "API 內容為空,可能是文字過長", + "core.chat.error.Request too large": "調試請求內容過大,請減少本次輸入內容或拆分檔案後重試", "core.chat.error.Select dataset empty": "您尚未選擇知識庫", "core.chat.error.data_error": "取得資料錯誤", "core.chat.feedback.No Content": "使用者未提供具體回饋內容", diff --git a/pro b/pro index ee1b1d779dfa..46972180ebc8 160000 --- a/pro +++ b/pro @@ -1 +1 @@ -Subproject commit ee1b1d779dfa90b36503d0693c97437fd9ba7b6a +Subproject commit 46972180ebc8de2300a24826cc1b41caae8bfa42 diff --git a/projects/app/src/components/core/app/FileSelector/index.tsx b/projects/app/src/components/core/app/FileSelector/index.tsx index 128ada691364..bd6534b1de7a 100644 --- a/projects/app/src/components/core/app/FileSelector/index.tsx +++ b/projects/app/src/components/core/app/FileSelector/index.tsx @@ -32,6 +32,67 @@ import { WorkflowRuntimeContext } from '@/components/core/chat/ChatContainer/con import { useSafeTranslation } from '@fastgpt/web/hooks/useSafeTranslation'; import { putFileToS3 } from '@fastgpt/web/common/file/utils'; +export type FileSelectorValueItemType = { + key?: string; + url?: string; +}; + +const isFileUploading = (file: UserInputFileItemType) => { + return !!file.rawFile && !file.key && !file.url && !file.error; +}; + +const formatFilesToValue = (files: UserInputFileItemType[]): FileSelectorValueItemType[] => { + return files.reduce((acc, file) => { + if (file.key) { + acc.push({ key: file.key }); + } else if (file.url) { + acc.push({ url: file.url }); + } + return acc; + }, []); +}; + +const normalizeValueToFiles = (value: unknown): UserInputFileItemType[] => { + if (!Array.isArray(value)) return []; + + return value.reduce((acc, file) => { + if (!file) return acc; + + if (typeof file === 'string') { + acc.push({ + id: getNanoid(6), + status: 1, + type: ChatFileTypeEnum.file, + url: file, + name: file, + icon: 'common/link' + }); + return acc; + } + + if (typeof file !== 'object') return acc; + + const item = file as Partial; + const url = typeof item.url === 'string' ? item.url : ''; + const key = typeof item.key === 'string' ? item.key : ''; + + if (!url && !key) return acc; + + acc.push({ + id: item.id || getNanoid(6), + status: item.status ?? 1, + type: item.type || ChatFileTypeEnum.file, + url, + key, + name: item.name || url || key, + icon: item.icon || (item.type === ChatFileTypeEnum.image ? url : getFileIcon(url || key)), + process: item.process, + error: item.error + }); + return acc; + }, []); +}; + const FileSelector = ({ value, onChange, @@ -47,13 +108,14 @@ const FileSelector = ({ isDisabled = false, isInvalid = false }: AppFileSelectConfigType & { - value: UserInputFileItemType[]; - onChange: (e: any[]) => void; + value: FileSelectorValueItemType[]; + onChange: (e: FileSelectorValueItemType[]) => void; canLocalUpload?: boolean; canUrlUpload?: boolean; isDisabled?: boolean; isInvalid?: boolean; }) => { + const [files, setFiles] = useState(() => normalizeValueToFiles(value)); const { feConfigs } = useSystemStore(); const { teamPlanStatus } = useUserStore(); const { toast } = useToast(); @@ -69,7 +131,8 @@ const FileSelector = ({ const handleChangeFiles = useCallback( (files: UserInputFileItemType[]) => { - onChange([...files]); + setFiles([...files]); + onChange(formatFilesToValue(files)); }, [onChange] ); @@ -122,7 +185,7 @@ const FileSelector = ({ (teamPlanStatus?.standard?.maxUploadFileSize || feConfigs?.uploadFileMaxSize || 500) * 1024 * 1024; - const canSelectFileAmount = maxSelectFiles - value.length; + const canSelectFileAmount = maxSelectFiles - files.length; const isMaxSelected = canSelectFileAmount <= 0; const uploadFiles = useCallback( @@ -191,28 +254,37 @@ const FileSelector = ({ } }); handleChangeFiles(files); + } finally { + setFileUploadingCount((state) => state - 1); } - - setFileUploadingCount((state) => state - 1); }) ); }, - [handleChangeFiles, setFileUploadingCount, appId, chatId, fileSelectConfig, outLinkAuthData] + [ + handleChangeFiles, + setFileUploadingCount, + appId, + chatId, + fileSelectConfig, + outLinkAuthData, + t, + maxSize + ] ); // Selector props const [isDragging, setIsDragging] = useState(false); const onSelectFile = useCallback( - async (files: File[]) => { - if (files.length > maxSelectFiles) { - files = files.slice(0, maxSelectFiles); + async (selectFiles: File[]) => { + if (selectFiles.length > canSelectFileAmount) { + selectFiles = selectFiles.slice(0, canSelectFileAmount); toast({ status: 'warning', title: t('chat:file_amount_over', { max: maxSelectFiles }) }); } - const filterFilesByMaxSize = files.filter((file) => file.size <= maxSize); - if (filterFilesByMaxSize.length < files.length) { + const filterFilesByMaxSize = selectFiles.filter((file) => file.size <= maxSize); + if (filterFilesByMaxSize.length < selectFiles.length) { toast({ status: 'warning', title: t('file:some_file_size_exceeds_limit', { maxSize: formatFileSize(maxSize) }) @@ -253,11 +325,11 @@ const FileSelector = ({ }) ) ); - const newFiles = [...loadFiles, ...value]; - handleChangeFiles(newFiles); + const newFiles = [...loadFiles, ...files]; + setFiles(newFiles); uploadFiles(newFiles); }, - [maxSelectFiles, value, handleChangeFiles, uploadFiles, toast, t, maxSize] + [canSelectFileAmount, files, maxSelectFiles, uploadFiles, toast, t, maxSize] ); const handleDragEnter = (e: DragEvent) => { e.preventDefault(); @@ -364,7 +436,7 @@ const FileSelector = ({ const trimmedUrl = url.trim(); if (trimmedUrl) { handleChangeFiles([ - ...value, + ...files, { id: getNanoid(6), status: 1, @@ -378,17 +450,81 @@ const FileSelector = ({ setUrlInput(''); }, - [t, toast, handleChangeFiles, value] + [t, toast, handleChangeFiles, files] ); const handleDeleteFile = useCallback( (id: string) => { - handleChangeFiles(value.filter((file) => file.id !== id)); + handleChangeFiles(files.filter((file) => file.id !== id)); }, - [handleChangeFiles, value] + [handleChangeFiles, files] ); - const isUploading = value.some((file) => !file.url && !file.error); + useEffect(() => { + setFiles((state) => { + if (state.some(isFileUploading)) return state; + + const nextFiles = normalizeValueToFiles(value); + if ( + JSON.stringify(formatFilesToValue(state)) === JSON.stringify(formatFilesToValue(nextFiles)) + ) { + return state; + } + return nextFiles; + }); + }, [value]); + + useEffect(() => { + const refreshFiles = files.filter((file) => file.key && !file.url && !file.error); + if (refreshFiles.length === 0) return; + + let canceled = false; + + Promise.allSettled( + refreshFiles.map(async (file) => { + const url = await getPresignedChatFileGetUrl({ + key: file.key!, + appId, + outLinkAuthData + }); + + return { + id: file.id, + url + }; + }) + ).then((results) => { + if (canceled) return; + + const urlMap = new Map(); + results.forEach((result) => { + if (result.status === 'fulfilled') { + urlMap.set(result.value.id, result.value.url); + } + }); + + if (urlMap.size === 0) return; + + setFiles((state) => + state.map((file) => { + const url = urlMap.get(file.id); + if (!url) return file; + + return { + ...file, + url, + icon: file.type === ChatFileTypeEnum.image ? url : file.icon + }; + }) + ); + }); + + return () => { + canceled = true; + }; + }, [appId, files, outLinkAuthData]); + + const isUploading = files.some(isFileUploading); const disabled = isDisabled || isUploading; return ( @@ -489,11 +625,11 @@ const FileSelector = ({ {/* Preview */} - {value.length > 0 && ( + {files.length > 0 && ( <> - {value.map((file) => { + {files.map((file) => { const fileIcon = file.type === ChatFileTypeEnum.image ? file.url : getFileIcon(file?.name); return ( diff --git a/projects/app/src/components/core/chat/components/AIResponseBox.tsx b/projects/app/src/components/core/chat/components/AIResponseBox.tsx index 65ca71933aae..e69e7f66aa04 100644 --- a/projects/app/src/components/core/chat/components/AIResponseBox.tsx +++ b/projects/app/src/components/core/chat/components/AIResponseBox.tsx @@ -284,19 +284,9 @@ const RenderUserFormInteractive = React.memo(function RenderFormInput({ }, {}); }, [interactive]); - const handleFormSubmit = useCallback( - (data: Record) => { - const finalData: Record = {}; - interactive.params.inputForm?.forEach((item) => { - if (item.key in data) { - finalData[item.key] = data[item.key]; - } - }); - - onSendPrompt(JSON.stringify(finalData)); - }, - [interactive.params.inputForm] - ); + const handleFormSubmit = useCallback((data: Record) => { + onSendPrompt(JSON.stringify(data)); + }, []); return ( diff --git a/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx b/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx index 759d44aa31c8..b9cc1405dffa 100644 --- a/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx +++ b/projects/app/src/components/core/chat/components/Interactive/InteractiveComponents.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React from 'react'; import { Box, Button, Flex, FormControl, FormErrorMessage } from '@chakra-ui/react'; import { Controller, useForm, type UseFormHandleSubmit } from 'react-hook-form'; import Markdown from '@/components/Markdown'; @@ -12,7 +12,6 @@ import InputRender from '@/components/core/app/formRender'; import { nodeInputTypeToInputType } from '@/components/core/app/formRender/utils'; import FormLabel from '@fastgpt/web/components/common/MyBox/FormLabel'; import LeftRadio from '@fastgpt/web/components/common/Radio/LeftRadio'; -import { getPresignedChatFileGetUrl } from '@/web/common/file/api'; import { useContextSelector } from 'use-context-selector'; import { WorkflowRuntimeContext } from '@/components/core/chat/ChatContainer/context/workflowRuntimeContext'; import { useTranslation } from 'next-i18next'; @@ -80,64 +79,11 @@ export const FormInputComponent = React.memo(function FormInputComponent({ }) { const { t } = useTranslation(); - const { handleSubmit, control, watch, setValue } = useForm({ + const { handleSubmit, control } = useForm({ defaultValues }); - const appId = useContextSelector(WorkflowRuntimeContext, (v) => v.appId); - const outLinkAuthData = useContextSelector(WorkflowRuntimeContext, (v) => v.outLinkAuthData); - const formValues = watch(); - - // 刷新文件 URL(处理 TTL 过期) - useEffect(() => { - if (!submitted || !inputForm) return; - - const refreshFileUrls = async () => { - for (const item of inputForm) { - if (item.type === 'fileSelect' && defaultValues[item.key]) { - const files = defaultValues[item.key]; - if (Array.isArray(files) && files.length > 0 && files[0]?.key) { - try { - const refreshedFiles = await Promise.all( - files.map(async (file: any) => { - if (file.key) { - try { - const newUrl = await getPresignedChatFileGetUrl({ - key: file.key, - appId, - outLinkAuthData - }); - return { - ...file, - url: newUrl, - icon: file.type === 'image' ? newUrl : file.icon - }; - } catch (e) {} - } - return file; - }) - ); - setValue(item.key, refreshedFiles); - } catch (e) {} - } - } - } - }; - - refreshFileUrls(); - }, [submitted, inputForm, defaultValues, appId, outLinkAuthData, setValue]); - - const isFileUploading = React.useMemo(() => { - return inputForm.some((input) => { - if (input.type === 'fileSelect') { - const files = formValues[input.key]; - if (Array.isArray(files)) { - return files.some((file: any) => !file.url && !file.error); - } - } - return false; - }); - }, [inputForm, formValues]); + const isFileUploading = useContextSelector(WorkflowRuntimeContext, (v) => v.fileUploading); return ( @@ -163,7 +109,12 @@ export const FormInputComponent = React.memo(function FormInputComponent({ } } if (input.type === 'fileSelect' && input.required) { - if (!value || !Array.isArray(value) || value.length === 0) { + if ( + !value || + !Array.isArray(value) || + value.length === 0 || + !value.some((file) => file?.key || file?.url) + ) { return t('common:required'); } } diff --git a/projects/app/src/web/common/api/fetch.ts b/projects/app/src/web/common/api/fetch.ts index a32afb9f6342..6b9e482330f8 100644 --- a/projects/app/src/web/common/api/fetch.ts +++ b/projects/app/src/web/common/api/fetch.ts @@ -325,6 +325,12 @@ function $ssefetch(params: SSEFetchParams) { return onfailed(await res.clone().text()); } + if (res.status === 413) { + return onfailed({ + message: 'common:core.chat.error.Request too large' + }); + } + if (!res.ok || !contentType?.startsWith(EventStreamContentType) || res.status !== 200) { try { onfailed(await res.clone().json());