Commit ff6bb82c by YeYuheng Committed by GitHub

fix: file-bug (#6799)

* file-bug

* file-bug

* file-bug

* fix

* fix

* perf: test

* perf: file inject prompt

---------

Co-authored-by: archer <545436317@qq.com>
parent 7e6dff29
...@@ -11,9 +11,9 @@ description: 当用户需要编写一个单元测试时,触发该 skill,编 ...@@ -11,9 +11,9 @@ description: 当用户需要编写一个单元测试时,触发该 skill,编
### packages 测试 ### packages 测试
packages 里的测试,写在 FastGPT/test/cases 目录下,子路径对应 packages 的目录结构。例如: packages 里的测试,写在 FastGPT/packages/xxx/test 目录下,子路径对应 packages 的目录结构。例如:
`packages/global/common/error/s3.ts`文件,对应的测例文件路径为 `test/cases/global/common/error/s3.test.ts` `packages/global/common/error/s3.ts`文件,对应的测例文件路径为 `packages/test/global/common/error/s3.test.ts`
并且,可以通过 @fastgpt 来导入 packages 里的文件。 并且,可以通过 @fastgpt 来导入 packages 里的文件。
例如: 例如:
...@@ -39,7 +39,7 @@ projects 里的测试,写在 FastGPT/projects/app/test 目录下,子路径 ...@@ -39,7 +39,7 @@ projects 里的测试,写在 FastGPT/projects/app/test 目录下,子路径
```ts ```ts
// FastGPT/packages/service/common/geo/index.ts // FastGPT/packages/service/common/geo/index.ts
import type { NextApiRequest } from 'next'; import type { NextApiRequest } from 'next';
// 同时导出一个依赖给 FastGPT/test/cases/service/common/geo/index.test.ts 使用 // 同时导出一个依赖给 FastGPT/packages/service/test/common/geo/index.test.ts 使用
export type { NextApiRequest } from 'next'; export type { NextApiRequest } from 'next';
``` ```
......
...@@ -11,10 +11,13 @@ description: 'FastGPT V4.15.0 更新说明' ...@@ -11,10 +11,13 @@ description: 'FastGPT V4.15.0 更新说明'
## ⚙️ 优化 ## ⚙️ 优化
1. 增加父子节点选中互斥功能,解决:同时选中父子节点时,移动节点会出现抖动。 1. 增加父子节点选中互斥功能,解决:同时选中父子节点时,移动节点会出现抖动。
2. 调整文件注入 messages 位置,从 system 调整至 user,便于命中缓存。
## 🐛 修复 ## 🐛 修复
## 代码优化 ## 代码优化
1. 优化 Agent tool 声明和运行,统一所有 tool 的声明和运行方式。 1. 重新调整代码结构,升级 nextjs 最新版,切换至 turbopack 构建,提高构建速度;升级容器默认 node 至 24。
\ No newline at end of file 2. 优化 Agent tool 声明和运行,统一所有 tool 的声明和运行方式。
3. 文件上传内容从 system prompt 中放到 user message 中,提高 cache 命中率
\ No newline at end of file
...@@ -230,7 +230,7 @@ ...@@ -230,7 +230,7 @@
"content/self-host/upgrading/4-14/41415.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/41415.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/41415.mdx": "2026-04-26T21:28:27+08:00", "content/self-host/upgrading/4-14/41415.mdx": "2026-04-26T21:28:27+08:00",
"content/self-host/upgrading/4-14/41416.en.mdx": "2026-04-26T21:28:27+08:00", "content/self-host/upgrading/4-14/41416.en.mdx": "2026-04-26T21:28:27+08:00",
"content/self-host/upgrading/4-14/41416.mdx": "2026-04-26T21:28:27+08:00", "content/self-host/upgrading/4-14/41416.mdx": "2026-04-26T22:41:57+08:00",
"content/self-host/upgrading/4-14/4142.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4142.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4142.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4142.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4143.en.mdx": "2026-04-26T21:08:47+08:00",
...@@ -251,7 +251,7 @@ ...@@ -251,7 +251,7 @@
"content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/41481.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-14/4149.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/4-15/4150.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/4-15/4150.mdx": "2026-04-24T13:02:20+08:00",
"content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.en.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/40.mdx": "2026-04-26T21:08:47+08:00",
"content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00", "content/self-host/upgrading/outdated/41.en.mdx": "2026-04-26T21:08:47+08:00",
......
...@@ -25,7 +25,7 @@ ...@@ -25,7 +25,7 @@
"fumadocs-ui": "15.6.3", "fumadocs-ui": "15.6.3",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "15.5.9", "next": "^15.5.15",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-responsive": "^10.0.1", "react-responsive": "^10.0.1",
...@@ -37,7 +37,7 @@ ...@@ -37,7 +37,7 @@
"tailwind-merge": "^3.5.0" "tailwind-merge": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@content-collections/core": "^0.10.0", "@content-collections/core": "^0.15.0",
"@content-collections/next": "^0.2.6", "@content-collections/next": "^0.2.6",
"@tailwindcss/postcss": "^4.1.11", "@tailwindcss/postcss": "^4.1.11",
"@types/mdx": "^2.0.13", "@types/mdx": "^2.0.13",
......
...@@ -311,16 +311,3 @@ export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user ...@@ -311,16 +311,3 @@ export const getQuotePrompt = (version?: string, role: 'user' | 'system' = 'user
return getPromptByVersion(version, defaultTemplate); return getPromptByVersion(version, defaultTemplate);
}; };
// Document quote prompt
export const getDocumentQuotePrompt = (version?: string) => {
const promptMap = {
['4.9.7']: `将 <FilesContent></FilesContent> 中的内容作为本次对话的参考:
<FilesContent>
{{quote}}
</FilesContent>
`
};
return getPromptByVersion(version, promptMap);
};
...@@ -4,8 +4,7 @@ import { ...@@ -4,8 +4,7 @@ import {
Prompt_systemQuotePromptList, Prompt_systemQuotePromptList,
Prompt_QuoteTemplateList, Prompt_QuoteTemplateList,
getQuoteTemplate, getQuoteTemplate,
getQuotePrompt, getQuotePrompt
getDocumentQuotePrompt
} from '@fastgpt/global/core/ai/prompt/AIChat'; } from '@fastgpt/global/core/ai/prompt/AIChat';
/** /**
...@@ -244,41 +243,3 @@ describe('getQuotePrompt', () => { ...@@ -244,41 +243,3 @@ describe('getQuotePrompt', () => {
}); });
}); });
}); });
// ===========================================================================
// 6. getDocumentQuotePrompt
// ===========================================================================
describe('getDocumentQuotePrompt', () => {
it('should return a prompt containing <FilesContent> tags', () => {
const result = getDocumentQuotePrompt('4.9.7');
expect(result).toContain('<FilesContent>');
expect(result).toContain('</FilesContent>');
});
it('should return a prompt containing {{quote}} placeholder', () => {
const result = getDocumentQuotePrompt('4.9.7');
expect(result).toContain('{{quote}}');
});
it('should return the 4.9.7 version when that version is requested', () => {
const result = getDocumentQuotePrompt('4.9.7');
expect(typeof result).toBe('string');
expect(result!.length).toBeGreaterThan(0);
});
it('should return highest version when no version is provided', () => {
const result = getDocumentQuotePrompt();
expect(typeof result).toBe('string');
expect(result).toContain('{{quote}}');
expect(result).toContain('<FilesContent>');
});
it('should fall back to highest version for non-existing version', () => {
const result = getDocumentQuotePrompt('99.99.99');
expect(result).toBe(getDocumentQuotePrompt());
});
it('should return the same result for undefined and non-existing version', () => {
expect(getDocumentQuotePrompt(undefined)).toBe(getDocumentQuotePrompt('0.0.1'));
});
});
...@@ -45,19 +45,28 @@ ${list} ...@@ -45,19 +45,28 @@ ${list}
}; };
/* ===== Inject user query ===== */ /* ===== Inject user query ===== */
export const injectUserFilesPrompt = (files: { index: number; name: string }[] = []) => { export const getUserFilesPrompt = (
files: { id: string; name: string; content?: string }[] = []
) => {
if (files.length === 0) return ''; if (files.length === 0) return '';
return `# Input Files return `# Input Files
本次用户上传的文件: 本次用户上传的文件:
${files.map((file) => `- 文件${file.index}: ${file.name}`).join('\n')}`; ${files
.map((file) =>
`<file>
<name>${file.name}</name>
${file.content ? `<content>${file.content}</content>` : ''}
</file>`.trim()
)
.join('\n')}`;
}; };
export const injectUserQueryTimePrompt = (time: string) => { export const injectUserQueryTimePrompt = (time: string) => {
return `# Current time return `# Current time
${time}`; ${time}`;
}; };
export const injectUserQueryPrompt = ({ export const injectUserQueryPrompt = ({
query, query = '',
filePrompt, filePrompt,
timePrompt timePrompt
}: { }: {
......
...@@ -5,7 +5,7 @@ import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/i ...@@ -5,7 +5,7 @@ import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/i
import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { FlowNodeInputTypeEnum } from '@fastgpt/global/core/workflow/node/constant';
import type { VariableItemType } from '@fastgpt/global/core/app/type'; import type { VariableItemType } from '@fastgpt/global/core/app/type';
import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants'; import { VariableInputEnum } from '@fastgpt/global/core/workflow/constants';
import { clone, cloneDeep } from 'lodash'; import { cloneDeep } from 'lodash';
export const addPreviewUrlToChatItems = async ( export const addPreviewUrlToChatItems = async (
histories: ChatItemMiniType[], histories: ChatItemMiniType[],
......
...@@ -4,19 +4,6 @@ import { getNanoid } from '@fastgpt/global/common/string/tools'; ...@@ -4,19 +4,6 @@ import { getNanoid } from '@fastgpt/global/common/string/tools';
import type { ChildResponseItemType } from './type'; import type { ChildResponseItemType } from './type';
import { SANDBOX_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/constants'; import { SANDBOX_TOOL_NAME } from '@fastgpt/global/core/ai/sandbox/constants';
export const getMultiplePrompt = (obj: {
fileCount: number;
imgCount: number;
question: string;
}) => {
const prompt = `Number of session file inputs:
Document:{{fileCount}}
Image:{{imgCount}}
------
{{question}}`;
return replaceVariable(prompt, obj);
};
export const getSandboxToolWorkflowResponse = ({ export const getSandboxToolWorkflowResponse = ({
name, name,
logo, logo,
......
import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum, NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants'; import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
import type { import type { DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
ChatDispatchProps,
DispatchNodeResultType,
RuntimeNodeItemType
} from '@fastgpt/global/core/workflow/runtime/type';
import { getLLMModel } from '../../../../ai/model'; import { getLLMModel } from '../../../../ai/model';
import { filterToolNodeIdByEdges, getNodeErrResponse, getHistories } from '../../utils'; import { filterToolNodeIdByEdges, getNodeErrResponse, getHistories } from '../../utils';
import { runToolCall } from './toolCall'; import { runToolCall } from './toolCall';
import { type DispatchToolModuleProps, type ToolNodeItemType } from './type'; import { type DispatchToolModuleProps, type ToolNodeItemType } from './type';
import type { import type { UserChatItemFileItemType, ChatItemMiniType } from '@fastgpt/global/core/chat/type';
UserChatItemFileItemType,
ChatItemMiniType,
UserChatItemValueItemType
} from '@fastgpt/global/core/chat/type';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { import {
GPTMessages2Chats, GPTMessages2Chats,
chatValue2RuntimePrompt,
chats2GPTMessages, chats2GPTMessages,
getSystemPrompt_ChatItemType, getSystemPrompt_ChatItemType,
runtimePrompt2ChatsValue runtimePrompt2ChatsValue
} from '@fastgpt/global/core/chat/adapt'; } from '@fastgpt/global/core/chat/adapt';
import { getHistoryPreview } from '@fastgpt/global/core/chat/utils'; import { getHistoryPreview } from '@fastgpt/global/core/chat/utils';
import { replaceVariable } from '@fastgpt/global/common/string/tools';
import { getMultiplePrompt } from './constants';
import { filterToolResponseToPreview } from './utils'; import { filterToolResponseToPreview } from './utils';
import { getFileContentFromLinks, getHistoryFileLinks } from '../../tools/readFiles';
import { parseUrlToFileType } from '../../../utils/context'; import { parseUrlToFileType } from '../../../utils/context';
import { FlowNodeTypeEnum } from '@fastgpt/global/core/workflow/node/constant'; import { rewriteUserQueryWithFiles } from '../../../utils/file';
import { getDocumentQuotePrompt } from '@fastgpt/global/core/ai/prompt/AIChat';
import { postTextCensor } from '../../../../chat/postTextCensor'; import { postTextCensor } from '../../../../chat/postTextCensor';
import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io'; import type { FlowNodeInputItemType } from '@fastgpt/global/core/workflow/type/io';
import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type';
import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema';
import { getToolConfigStatus } from '@fastgpt/global/core/app/formEdit/utils'; import { getToolConfigStatus } from '@fastgpt/global/core/app/formEdit/utils';
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
...@@ -42,11 +28,10 @@ type Response = DispatchNodeResultType<{ ...@@ -42,11 +28,10 @@ type Response = DispatchNodeResultType<{
export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<Response> => { export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<Response> => {
let { let {
node: { nodeId, name, isEntry, version, inputs }, node: { nodeId, isEntry, inputs },
runtimeNodes, runtimeNodes,
runtimeEdges, runtimeEdges,
histories, histories,
query,
requestOrigin, requestOrigin,
chatConfig, chatConfig,
lastInteractive, lastInteractive,
...@@ -126,69 +111,52 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -126,69 +111,52 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
// Check interactive entry // Check interactive entry
props.node.isEntry = false; props.node.isEntry = false;
const hasReadFilesTool = toolNodes.some(
(item) => item.flowNodeType === FlowNodeTypeEnum.readFiles
);
const globalFiles = chatValue2RuntimePrompt(query).files; const { userFiles } = await getMultiInput({
const { documentQuoteText, userFiles } = await getMultiInput({ fileLinks
runningUserInfo,
histories: chatHistories,
requestOrigin,
maxFiles: chatConfig?.fileSelectConfig?.maxFiles || 20,
customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse,
fileLinks,
inputFiles: globalFiles,
hasReadFilesTool,
usageId,
appId: props.runningAppInfo.id,
chatId: props.chatId,
uId: props.uid
}); });
const concatenateSystemPrompt = [ const concatenateSystemPrompt = [toolModel.defaultSystemChatPrompt, systemPrompt]
toolModel.defaultSystemChatPrompt,
systemPrompt,
documentQuoteText
? replaceVariable(getDocumentQuotePrompt(version), {
quote: documentQuoteText
})
: ''
]
.filter(Boolean) .filter(Boolean)
.join('\n\n===---===---===\n\n'); .join('\n\n-----\n\n');
const messages: ChatItemMiniType[] = (() => { const messages = await (async () => {
const value: ChatItemMiniType[] = [ const value: ChatItemMiniType[] = [
...getSystemPrompt_ChatItemType(concatenateSystemPrompt), ...getSystemPrompt_ChatItemType(concatenateSystemPrompt),
// Add file input prompt to histories ...chatHistories,
...chatHistories.map((item) => {
if (item.obj === ChatRoleEnum.Human) {
return {
...item,
value: toolCallMessagesAdapt({
userInput: item.value,
skip: !hasReadFilesTool
})
};
}
return item;
}),
{ {
obj: ChatRoleEnum.Human, obj: ChatRoleEnum.Human,
value: toolCallMessagesAdapt({ value: runtimePrompt2ChatsValue({
skip: !hasReadFilesTool,
userInput: runtimePrompt2ChatsValue({
text: userChatInput, text: userChatInput,
files: userFiles files: userFiles
}) })
})
} }
]; ];
if (lastInteractive && isEntry) {
return value.slice(0, -2); const runtimeMessages = lastInteractive && isEntry ? value.slice(0, -2) : value;
const maxFiles = chatConfig?.fileSelectConfig?.maxFiles || 20;
return Promise.all(
runtimeMessages.map(async (message, index): Promise<ChatItemMiniType> => {
if (message.obj !== ChatRoleEnum.Human) {
return message;
} }
return value;
return {
...message,
value: await rewriteUserQueryWithFiles({
queryId: message.dataId || `${index}`,
userQuery: message.value,
requestOrigin,
maxFiles,
customPdfParse: chatConfig?.fileSelectConfig?.customPdfParse,
usageId,
teamId: runningUserInfo.teamId,
tmbId: runningUserInfo.tmbId
})
};
})
);
})(); })();
// censor model and system key // censor model and system key
...@@ -310,115 +278,10 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise< ...@@ -310,115 +278,10 @@ export const dispatchRunTools = async (props: DispatchToolModuleProps): Promise<
} }
}; };
const getMultiInput = async ({ const getMultiInput = async ({ fileLinks = [] }: { fileLinks?: string[] }) => {
runningUserInfo,
histories,
fileLinks,
requestOrigin,
maxFiles,
customPdfParse,
inputFiles,
hasReadFilesTool,
usageId,
appId,
chatId,
uId
}: {
runningUserInfo: ChatDispatchProps['runningUserInfo'];
histories: ChatItemMiniType[];
fileLinks?: string[];
requestOrigin?: string;
maxFiles: number;
customPdfParse?: boolean;
inputFiles: UserChatItemFileItemType[];
hasReadFilesTool: boolean;
usageId?: string;
appId: string;
chatId?: string;
uId: string;
}) => {
// Not file quote
if (!fileLinks || hasReadFilesTool) {
return {
documentQuoteText: '',
userFiles: inputFiles
};
}
const filesFromHistories = getHistoryFileLinks(histories);
const urls = [...fileLinks, ...filesFromHistories];
if (urls.length === 0) {
return { return {
documentQuoteText: '',
userFiles: []
};
}
// Get files from histories
const { text } = await getFileContentFromLinks({
// Concat fileUrlList and filesFromHistories; remove not supported files
urls,
requestOrigin,
maxFiles,
customPdfParse,
usageId,
teamId: runningUserInfo.teamId,
tmbId: runningUserInfo.tmbId
});
return {
documentQuoteText: text,
userFiles: fileLinks userFiles: fileLinks
.map((url) => parseUrlToFileType(url)) .map((url) => parseUrlToFileType(url))
.filter(Boolean) as UserChatItemFileItemType[] .filter(Boolean) as UserChatItemFileItemType[]
}; };
}; };
/*
Tool call, auth add file prompt to question。
Guide the LLM to call tool.
*/
const toolCallMessagesAdapt = ({
userInput,
skip
}: {
userInput: UserChatItemValueItemType[];
skip?: boolean;
}): UserChatItemValueItemType[] => {
if (skip) return userInput;
const files = userInput.filter((item) => 'file' in item);
if (files.length > 0) {
const filesCount = files.filter((file) => file.file?.type === 'file').length;
const imgCount = files.filter((file) => file.file?.type === 'image').length;
if (userInput.some((item) => 'text' in item)) {
return userInput.map((item) => {
if ('text' in item) {
const text = item.text?.content || '';
return {
...item,
text: {
content: getMultiplePrompt({ fileCount: filesCount, imgCount, question: text })
}
};
}
return item;
});
}
// Every input is a file
return [
{
text: {
content: getMultiplePrompt({ fileCount: filesCount, imgCount, question: '' })
}
}
];
}
return userInput;
};
...@@ -12,7 +12,7 @@ import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/ ...@@ -12,7 +12,7 @@ import type { RuntimeNodeItemType } from '@fastgpt/global/core/workflow/runtime/
import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type'; import type { McpToolDataType } from '@fastgpt/global/core/app/tool/mcpTool/type';
import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema'; import type { JSONSchemaInputType } from '@fastgpt/global/core/app/jsonschema';
import type { ToolNodeItemType } from './tool/type'; import type { ToolNodeItemType } from './toolcall/type';
import json5 from 'json5'; import json5 from 'json5';
import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type'; import type { ChatCompletionMessageParam } from '@fastgpt/global/core/ai/llm/type';
import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants'; import { ChatCompletionRequestMessageRoleEnum } from '@fastgpt/global/core/ai/constants';
......
...@@ -3,9 +3,9 @@ import { dispatchAppRequest } from './abandoned/runApp'; ...@@ -3,9 +3,9 @@ import { dispatchAppRequest } from './abandoned/runApp';
import { dispatchLoop } from './abandoned/runLoop'; import { dispatchLoop } from './abandoned/runLoop';
import { dispatchClassifyQuestion } from './ai/classifyQuestion'; import { dispatchClassifyQuestion } from './ai/classifyQuestion';
import { dispatchContentExtract } from './ai/extract'; import { dispatchContentExtract } from './ai/extract';
import { dispatchRunTools } from './ai/tool/index'; import { dispatchRunTools } from './ai/toolcall/index';
import { dispatchStopToolCall } from './ai/tool/stopTool'; import { dispatchStopToolCall } from './ai/toolcall/stopTool';
import { dispatchToolParams } from './ai/tool/toolParams'; import { dispatchToolParams } from './ai/toolcall/toolParams';
import { dispatchChatCompletion } from './ai/chat'; import { dispatchChatCompletion } from './ai/chat';
import { dispatchCodeSandbox } from './tools/codeSandbox'; import { dispatchCodeSandbox } from './tools/codeSandbox';
import { dispatchDatasetConcat } from './dataset/concat'; import { dispatchDatasetConcat } from './dataset/concat';
......
...@@ -3,60 +3,21 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/ ...@@ -3,60 +3,21 @@ import type { ModuleDispatchProps } from '@fastgpt/global/core/workflow/runtime/
import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import type { NodeInputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants'; import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type'; import { type DispatchNodeResultType } from '@fastgpt/global/core/workflow/runtime/type';
import { axios } from '../../../../common/api/axios';
import { serverRequestBaseUrl } from '../../../../common/api/serverRequest';
import { getErrText } from '@fastgpt/global/common/error/utils';
import {
detectFileEncoding,
parseContentDispositionFilename
} from '@fastgpt/global/common/file/tools';
import { parseUrlToFileType } from '../../utils/context';
import { readFileContentByBuffer } from '../../../../common/file/read/utils';
import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants'; import { ChatRoleEnum } from '@fastgpt/global/core/chat/constants';
import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type'; import { type ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import { addDays } from 'date-fns';
import { getNodeErrResponse } from '../utils'; import { getNodeErrResponse } from '../utils';
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../../common/system/utils'; import { getFileContentFromLinks } from '../../utils/file';
import { replaceS3KeyToPreviewUrl } from '../../../dataset/utils'; import { getUserFilesPrompt } from '../../../ai/llm/agentLoop/prompt';
import { getFileS3Key } from '../../../../common/s3/utils'; import { sliceStrStartEnd } from '@fastgpt/global/common/string/tools';
import { S3ChatSource } from '../../../../common/s3/sources/chat';
import path from 'node:path';
import { S3Buckets } from '../../../../common/s3/config/constants';
import { S3Sources } from '../../../../common/s3/contracts/type';
import { getS3RawTextSource } from '../../../../common/s3/sources/rawText';
import { getLogger, LogCategories } from '../../../../common/logger';
const logger = getLogger(LogCategories.MODULE.WORKFLOW.TOOLS);
type Props = ModuleDispatchProps<{ type Props = ModuleDispatchProps<{
[NodeInputKeyEnum.fileUrlList]: string[]; [NodeInputKeyEnum.fileUrlList]: string[];
}>; }>;
type Response = DispatchNodeResultType<{ type Response = DispatchNodeResultType<{
[NodeOutputKeyEnum.text]: string; [NodeOutputKeyEnum.text]: string;
[NodeOutputKeyEnum.rawResponse]: ReturnType<typeof formatResponseObject>[]; [NodeOutputKeyEnum.rawResponse]: { filename: string; url: string; text: string }[];
}>; }>;
const formatResponseObject = ({
filename,
url,
content
}: {
filename: string;
url: string;
content: string;
}) => ({
filename,
url,
text: `File: ${filename}
<Content>
${content}
</Content>`,
nodeResponsePreviewText: `File: ${filename}
<Content>
${content.slice(0, 100)}${content.length > 100 ? '......' : ''}
</Content>`
});
export const dispatchReadFiles = async (props: Props): Promise<Response> => { export const dispatchReadFiles = async (props: Props): Promise<Response> => {
const { const {
requestOrigin, requestOrigin,
...@@ -74,7 +35,7 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => { ...@@ -74,7 +35,7 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => {
const filesFromHistories = version !== '489' ? [] : getHistoryFileLinks(histories); const filesFromHistories = version !== '489' ? [] : getHistoryFileLinks(histories);
try { try {
const { text, readFilesResult } = await getFileContentFromLinks({ const readFilesResult = await getFileContentFromLinks({
// Concat fileUrlList and filesFromHistories; remove not supported files // Concat fileUrlList and filesFromHistories; remove not supported files
urls: [...fileUrlList, ...filesFromHistories], urls: [...fileUrlList, ...filesFromHistories],
requestOrigin, requestOrigin,
...@@ -84,20 +45,33 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => { ...@@ -84,20 +45,33 @@ export const dispatchReadFiles = async (props: Props): Promise<Response> => {
customPdfParse, customPdfParse,
usageId usageId
}); });
const files = readFilesResult.map((item, index) => ({
id: `${index}`,
name: item.filename,
content: item.content
}));
const text = getUserFilesPrompt(files);
const getPreviewResponse = files
.map((item) => `## ${item.name}\n${sliceStrStartEnd(item.content, 1000, 1000)}`)
.join('\n\n');
return { return {
data: { data: {
[NodeOutputKeyEnum.text]: text, [NodeOutputKeyEnum.text]: text,
[NodeOutputKeyEnum.rawResponse]: readFilesResult [NodeOutputKeyEnum.rawResponse]: readFilesResult.map((item) => ({
filename: item.filename,
url: item.url,
text: item.content
}))
}, },
[DispatchNodeResponseKeyEnum.nodeResponse]: { [DispatchNodeResponseKeyEnum.nodeResponse]: {
readFiles: readFilesResult.map((item) => ({ readFiles: readFilesResult.map((item) => ({
name: item?.filename || '', name: item.filename,
url: item?.url || '' url: item.url
})), })),
readFilesResult: readFilesResult readFilesResult: getPreviewResponse
.map((item) => item?.nodeResponsePreviewText ?? '')
.join('\n******\n')
}, },
[DispatchNodeResponseKeyEnum.toolResponses]: { [DispatchNodeResponseKeyEnum.toolResponses]: {
fileContent: text fileContent: text
...@@ -123,165 +97,3 @@ export const getHistoryFileLinks = (histories: ChatItemMiniType[]) => { ...@@ -123,165 +97,3 @@ export const getHistoryFileLinks = (histories: ChatItemMiniType[]) => {
return []; return [];
}); });
}; };
export const getFileContentFromLinks = async ({
urls,
requestOrigin,
maxFiles,
teamId,
tmbId,
customPdfParse,
usageId
}: {
urls: string[];
requestOrigin?: string;
maxFiles: number;
teamId: string;
tmbId: string;
customPdfParse?: boolean;
usageId?: string;
}) => {
const parseUrlList = urls
// Remove invalid urls
.filter((url) => {
if (typeof url !== 'string') return false;
// 检查相对路径
const validPrefixList = ['/', 'http', 'ws'];
if (validPrefixList.some((prefix) => url.startsWith(prefix))) {
return true;
}
return false;
})
// Just get the document type file
.filter((url) => parseUrlToFileType(url)?.type === 'file')
.map((url) => {
try {
// Check is system upload file
const parsedURL = new URL(url, 'http://localhost:3000');
if (requestOrigin && parsedURL.origin === requestOrigin) {
url = url.replace(requestOrigin, '');
}
return url;
} catch (error) {
logger.warn('Failed to parse file URL', { url, error });
return '';
}
})
.filter(Boolean)
.slice(0, maxFiles);
const readFilesResult = await Promise.all(
parseUrlList
.map(async (url) => {
// Get from buffer
const rawTextBuffer = await getS3RawTextSource().getRawTextBuffer({
sourceId: url,
customPdfParse
});
if (rawTextBuffer) {
return formatResponseObject({
filename: rawTextBuffer.filename || url,
url,
content: rawTextBuffer.text
});
}
try {
if (await isInternalAddress(url)) {
return Promise.reject(PRIVATE_URL_TEXT);
}
// Get file buffer data
const response = await axios.get(url, {
baseURL: serverRequestBaseUrl,
responseType: 'arraybuffer'
});
const buffer = Buffer.from(response.data, 'binary');
const urlObj = new URL(url, 'http://localhost:3000');
const isChatExternalUrl = !urlObj.pathname.startsWith(
`/${S3Buckets.private}/${S3Sources.chat}/`
);
// Get file name
const { filename, extension, imageParsePrefix } = (() => {
if (isChatExternalUrl) {
const contentDisposition = response.headers['content-disposition'] || '';
const matchFilename = parseContentDispositionFilename(contentDisposition);
const filename = matchFilename || urlObj.pathname.split('/').pop() || 'file';
const extension = path.extname(filename).replace('.', '');
return {
filename,
extension,
imageParsePrefix: getFileS3Key.temp({ teamId, filename }).fileParsedPrefix
};
}
return S3ChatSource.parseChatUrl(url);
})();
// Get encoding
const encoding = (() => {
const contentType = response.headers['content-type'];
if (contentType) {
const charsetRegex = /charset=([^;]*)/;
const matches = charsetRegex.exec(contentType);
if (matches != null && matches[1]) {
return matches[1];
}
}
return detectFileEncoding(buffer);
})();
const { rawText } = await readFileContentByBuffer({
extension,
teamId,
tmbId,
buffer,
encoding,
customPdfParse,
getFormatText: true,
imageKeyOptions: imageParsePrefix
? {
prefix: imageParsePrefix,
// 聊天对话里面上传的外部链接,解析出来的图片过期时间设置为1天,而且是存储在临时文件夹的
expiredTime: isChatExternalUrl ? addDays(new Date(), 1) : undefined
}
: undefined,
usageId
});
const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90));
// Add to buffer
getS3RawTextSource().addRawTextBuffer({
sourceId: url,
sourceName: filename,
text: replacedText,
customPdfParse
});
return formatResponseObject({ filename, url, content: replacedText });
} catch (error) {
return formatResponseObject({
filename: '',
url,
content: getErrText(error, 'Load file error')
});
}
})
.filter(Boolean)
);
const text = readFilesResult.map((item) => item?.text ?? '').join('\n******\n');
return {
text,
readFilesResult
};
};
import { imageFileType } from '@fastgpt/global/common/file/constants'; import { imageFileType } from '@fastgpt/global/common/file/constants';
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants'; import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import type { UserChatItemFileItemType } from '@fastgpt/global/core/chat/type'; import type { UserChatItemFileItemType } from '@fastgpt/global/core/chat/type';
import { AsyncLocalStorage } from 'async_hooks'; import { AsyncLocalStorage } from 'async_hooks';
import path from 'path'; import path from 'path';
import type { MCPClient } from '../../app/mcp'; import type { MCPClient } from '../../app/mcp';
......
import { ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import type { UserChatItemValueItemType } from '@fastgpt/global/core/chat/type';
import { parseUrlToFileType } from './context';
import { getS3RawTextSource } from '../../../common/s3/sources/rawText';
import { isInternalAddress, PRIVATE_URL_TEXT } from '../../../common/system/utils';
import { axios } from '../../../common/api/axios';
import { serverRequestBaseUrl } from '../../../common/api/serverRequest';
import { S3Buckets } from '../../../common/s3/config/constants';
import { S3Sources } from '../../../common/s3/contracts/type';
import {
detectFileEncoding,
parseContentDispositionFilename
} from '@fastgpt/global/common/file/tools';
import path from 'path';
import { getFileS3Key } from '../../../common/s3/utils';
import { S3ChatSource } from '../../../common/s3/sources/chat';
import { readFileContentByBuffer } from '../../../common/file/read/utils';
import { addDays } from 'date-fns';
import { replaceS3KeyToPreviewUrl } from '../../dataset/utils';
import { getErrText } from '@fastgpt/global/common/error/utils';
import { getUserFilesPrompt, injectUserQueryPrompt } from '../../ai/llm/agentLoop/prompt';
type GetFileProps = {
requestOrigin?: string;
maxFiles: number;
customPdfParse?: boolean;
teamId: string;
tmbId: string;
usageId?: string;
};
export const rewriteUserQueryWithFiles = async ({
queryId,
userQuery,
requestOrigin,
maxFiles,
customPdfParse,
teamId,
tmbId,
usageId
}: GetFileProps & {
queryId: string;
userQuery: UserChatItemValueItemType[];
}) => {
const urls = userQuery
.map((item) => (item.file?.type === ChatFileTypeEnum.file ? item.file.url : ''))
.filter(Boolean);
if (urls.length === 0) {
return userQuery;
}
const readFilesResult = await getFileContentFromLinks({
urls,
requestOrigin,
maxFiles,
teamId,
tmbId,
customPdfParse,
usageId
});
if (readFilesResult.length === 0) {
return userQuery;
}
const files = readFilesResult.map((item, index) => ({
id: `${queryId}-${index}`,
name: item.filename,
content: item.content
}));
// 把 file 和 text 合并成一个 text(实际上应该只会有一个 text+多个 files)
const text = userQuery.find((item) => item.text?.content)?.text?.content;
const fileQuery = getUserFilesPrompt(files);
const finalQuery = injectUserQueryPrompt({
query: text,
filePrompt: fileQuery
});
return [
{
text: {
content: finalQuery
}
}
];
};
/**
* 格式化文件 URL,移除请求头部分,只保留文件 URL
*/
export const normalizeReadableFileUrl = ({
url,
requestOrigin
}: {
url?: string;
requestOrigin?: string;
}) => {
if (typeof url !== 'string') return '';
let normalizedUrl = url.trim();
if (!normalizedUrl) return '';
const validPrefixList = ['/', 'http', 'ws'];
if (!validPrefixList.some((prefix) => normalizedUrl.startsWith(prefix))) {
return '';
}
if (parseUrlToFileType(normalizedUrl)?.type !== ChatFileTypeEnum.file) {
return '';
}
try {
const parsedURL = new URL(normalizedUrl, 'http://localhost:3000');
if (requestOrigin && parsedURL.origin === requestOrigin) {
normalizedUrl = normalizedUrl.replace(requestOrigin, '');
}
return normalizedUrl;
} catch {
return '';
}
};
export const getFileContentFromLinks = async ({
urls,
requestOrigin,
maxFiles,
teamId,
tmbId,
customPdfParse,
usageId
}: GetFileProps & {
urls: string[];
}) => {
const parseUrlList = urls
.map((url) => normalizeReadableFileUrl({ url, requestOrigin }))
.filter(Boolean)
.slice(0, maxFiles);
const readFilesResult = await Promise.all(
parseUrlList
.map(async (url) => {
// Get from buffer
const rawTextBuffer = await getS3RawTextSource().getRawTextBuffer({
sourceId: url,
customPdfParse
});
if (rawTextBuffer) {
return {
success: true,
filename: rawTextBuffer.filename,
url,
content: rawTextBuffer.text
};
}
try {
if (await isInternalAddress(url)) {
return Promise.reject(PRIVATE_URL_TEXT);
}
// Get file buffer data
const response = await axios.get(url, {
baseURL: serverRequestBaseUrl,
responseType: 'arraybuffer'
});
const buffer = Buffer.from(response.data, 'binary');
const urlObj = new URL(url, 'http://localhost:3000');
const isChatExternalUrl = !urlObj.pathname.startsWith(
`/${S3Buckets.private}/${S3Sources.chat}/`
);
// Get file name
const { filename, extension, imageParsePrefix } = (() => {
if (isChatExternalUrl) {
const contentDisposition = response.headers['content-disposition'] || '';
const matchFilename = parseContentDispositionFilename(contentDisposition);
const filename = matchFilename || urlObj.pathname.split('/').pop() || 'file';
const extension = path.extname(filename).replace('.', '');
return {
filename,
extension,
imageParsePrefix: getFileS3Key.temp({ teamId, filename }).fileParsedPrefix
};
}
return S3ChatSource.parseChatUrl(url);
})();
// Get encoding
const encoding = (() => {
const contentType = response.headers['content-type'];
if (contentType) {
const charsetRegex = /charset=([^;]*)/;
const matches = charsetRegex.exec(contentType);
if (matches != null && matches[1]) {
return matches[1];
}
}
return detectFileEncoding(buffer);
})();
const { rawText } = await readFileContentByBuffer({
extension,
teamId,
tmbId,
buffer,
encoding,
customPdfParse,
getFormatText: true,
imageKeyOptions: imageParsePrefix
? {
prefix: imageParsePrefix,
// 聊天对话里面上传的外部链接,解析出来的图片过期时间设置为1天,而且是存储在临时文件夹的
expiredTime: isChatExternalUrl ? addDays(new Date(), 1) : undefined
}
: undefined,
usageId
});
const replacedText = replaceS3KeyToPreviewUrl(rawText, addDays(new Date(), 90));
// Add to buffer
getS3RawTextSource().addRawTextBuffer({
sourceId: url,
sourceName: filename,
text: replacedText,
customPdfParse
});
return { success: true, filename, url, content: replacedText };
} catch (error) {
return {
success: false,
filename: '',
url,
content: getErrText(error, 'Load file error')
};
}
})
.filter(Boolean)
);
return readFilesResult;
};
...@@ -10,9 +10,9 @@ import { S3PrivateBucket } from '@fastgpt/service/common/s3/buckets/private'; ...@@ -10,9 +10,9 @@ import { S3PrivateBucket } from '@fastgpt/service/common/s3/buckets/private';
// Mock the S3 bucket // Mock the S3 bucket
vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({ vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({
S3PrivateBucket: vi.fn().mockImplementation(() => ({ S3PrivateBucket: vi.fn(function (this: any) {
bucketName: 'fastgpt-private', this.bucketName = 'fastgpt-private';
client: { this.client = {
uploadObject: vi.fn().mockResolvedValue(undefined), uploadObject: vi.fn().mockResolvedValue(undefined),
downloadObject: vi.fn().mockResolvedValue({ downloadObject: vi.fn().mockResolvedValue({
// body must be async-iterable; an array satisfies for-await-of // body must be async-iterable; an array satisfies for-await-of
...@@ -20,8 +20,8 @@ vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({ ...@@ -20,8 +20,8 @@ vi.mock('@fastgpt/service/common/s3/buckets/private', () => ({
}), }),
deleteObject: vi.fn().mockResolvedValue(undefined), deleteObject: vi.fn().mockResolvedValue(undefined),
checkObjectExists: vi.fn().mockResolvedValue({ exists: true }) checkObjectExists: vi.fn().mockResolvedValue({ exists: true })
} };
})) })
})); }));
describe('storage', () => { describe('storage', () => {
...@@ -131,12 +131,12 @@ describe('storage', () => { ...@@ -131,12 +131,12 @@ describe('storage', () => {
const { S3PrivateBucket: MockBucket } = await import( const { S3PrivateBucket: MockBucket } = await import(
'@fastgpt/service/common/s3/buckets/private' '@fastgpt/service/common/s3/buckets/private'
); );
(MockBucket as any).mockImplementationOnce(() => ({ (MockBucket as any).mockImplementationOnce(function (this: any) {
bucketName: 'fastgpt-private', this.bucketName = 'fastgpt-private';
client: { this.client = {
downloadObject: vi.fn().mockResolvedValue({ body: null }) downloadObject: vi.fn().mockResolvedValue({ body: null })
} };
})); });
const storageInfo = { const storageInfo = {
bucket: 'fastgpt-private', bucket: 'fastgpt-private',
......
...@@ -500,6 +500,30 @@ describe('loadRequestMessages function tests', () => { ...@@ -500,6 +500,30 @@ describe('loadRequestMessages function tests', () => {
const content = result[0].content as any[]; const content = result[0].content as any[];
expect(content).toHaveLength(2); expect(content).toHaveLength(2);
}); });
it('should keep text content when file_url is filtered out', async () => {
const messages: ChatCompletionMessageParam[] = [
{
role: ChatCompletionRequestMessageRoleEnum.User,
content: [
{
type: 'file_url',
name: 'a.pdf',
url: '/private/chat/a.pdf'
},
{
type: 'text',
text: '<FilesContent>File body</FilesContent>'
}
]
}
];
const result = await loadRequestMessages({ messages, useVision: true });
expect(result).toHaveLength(1);
expect(result[0].content).toBe('<FilesContent>File body</FilesContent>');
});
}); });
describe('Image processing', () => { describe('Image processing', () => {
......
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ChatRoleEnum, ChatFileTypeEnum } from '@fastgpt/global/core/chat/constants';
import type { ChatItemMiniType } from '@fastgpt/global/core/chat/type';
import { NodeOutputKeyEnum } from '@fastgpt/global/core/workflow/constants';
import { DispatchNodeResponseKeyEnum } from '@fastgpt/global/core/workflow/runtime/constants';
const mockGetFileContentFromLinks = vi.hoisted(() => vi.fn());
vi.mock('@fastgpt/service/core/workflow/utils/file', () => ({
getFileContentFromLinks: mockGetFileContentFromLinks
}));
import {
dispatchReadFiles,
getHistoryFileLinks
} from '@fastgpt/service/core/workflow/dispatch/tools/readFiles';
const baseProps = {
requestOrigin: 'http://localhost:3000',
runningUserInfo: { teamId: 'team-1', tmbId: 'tmb-1' },
histories: [] as ChatItemMiniType[],
chatConfig: {} as any,
node: { version: '490' } as any,
params: { fileUrlList: [] as string[] },
usageId: 'usage-1'
} as any;
describe('dispatchReadFiles', () => {
beforeEach(() => {
vi.clearAllMocks();
mockGetFileContentFromLinks.mockResolvedValue([]);
});
it('成功读取并返回文本/原始响应/节点响应/工具响应结构', async () => {
mockGetFileContentFromLinks.mockResolvedValue([
{ success: true, filename: 'a.pdf', url: '/a.pdf', content: 'Alpha' },
{ success: true, filename: 'b.pdf', url: '/b.pdf', content: 'Beta' }
]);
const result = await dispatchReadFiles({
...baseProps,
params: { fileUrlList: ['/a.pdf', '/b.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith({
urls: ['/a.pdf', '/b.pdf'],
requestOrigin: 'http://localhost:3000',
maxFiles: 20,
teamId: 'team-1',
tmbId: 'tmb-1',
customPdfParse: false,
usageId: 'usage-1'
});
const text = result.data?.[NodeOutputKeyEnum.text];
expect(text).toContain('Alpha');
expect(text).toContain('Beta');
expect(text).toContain('a.pdf');
expect(text).toContain('b.pdf');
expect(result.data?.[NodeOutputKeyEnum.rawResponse]).toEqual([
{ filename: 'a.pdf', url: '/a.pdf', text: 'Alpha' },
{ filename: 'b.pdf', url: '/b.pdf', text: 'Beta' }
]);
const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse] as any;
expect(nodeResponse.readFiles).toEqual([
{ name: 'a.pdf', url: '/a.pdf' },
{ name: 'b.pdf', url: '/b.pdf' }
]);
expect(nodeResponse.readFilesResult).toContain('## a.pdf');
expect(nodeResponse.readFilesResult).toContain('Alpha');
expect(nodeResponse.readFilesResult).toContain('## b.pdf');
expect(nodeResponse.readFilesResult).toContain('Beta');
expect(result[DispatchNodeResponseKeyEnum.toolResponses]).toEqual({
fileContent: text
});
});
it('chatConfig 提供 maxFiles 和 customPdfParse 时按其值传入', async () => {
await dispatchReadFiles({
...baseProps,
chatConfig: {
fileSelectConfig: {
maxFiles: 5,
customPdfParse: true
}
},
params: { fileUrlList: ['/a.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(
expect.objectContaining({
maxFiles: 5,
customPdfParse: true
})
);
});
it('chatConfig 缺失时 maxFiles 兜底为 20,customPdfParse 兜底为 false', async () => {
await dispatchReadFiles({
...baseProps,
chatConfig: undefined,
params: { fileUrlList: ['/a.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(
expect.objectContaining({ maxFiles: 20, customPdfParse: false })
);
});
it('fileSelectConfig.maxFiles 为 0/undefined 时仍兜底为 20', async () => {
await dispatchReadFiles({
...baseProps,
chatConfig: { fileSelectConfig: { maxFiles: 0 } },
params: { fileUrlList: ['/a.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(
expect.objectContaining({ maxFiles: 20 })
);
});
it('node.version === "489" 时拼接 histories 中的文件链接', async () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [
{
file: {
type: ChatFileTypeEnum.file,
name: 'history.pdf',
url: '/history.pdf'
}
}
]
}
];
await dispatchReadFiles({
...baseProps,
node: { version: '489' },
histories,
params: { fileUrlList: ['/current.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(
expect.objectContaining({
urls: ['/current.pdf', '/history.pdf']
})
);
});
it('node.version !== "489" 时忽略 histories', async () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [
{
file: {
type: ChatFileTypeEnum.file,
name: 'history.pdf',
url: '/history.pdf'
}
}
]
}
];
await dispatchReadFiles({
...baseProps,
node: { version: '490' },
histories,
params: { fileUrlList: ['/current.pdf'] }
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(
expect.objectContaining({
urls: ['/current.pdf']
})
);
});
it('params.fileUrlList 缺省时按空数组处理', async () => {
await dispatchReadFiles({
...baseProps,
params: {}
});
expect(mockGetFileContentFromLinks).toHaveBeenCalledWith(expect.objectContaining({ urls: [] }));
});
it('空文件结果返回空文本和空数组结构', async () => {
mockGetFileContentFromLinks.mockResolvedValue([]);
const result = await dispatchReadFiles({
...baseProps,
params: { fileUrlList: [] }
});
expect(result.data?.[NodeOutputKeyEnum.text]).toBe('');
expect(result.data?.[NodeOutputKeyEnum.rawResponse]).toEqual([]);
const nodeResponse = result[DispatchNodeResponseKeyEnum.nodeResponse] as any;
expect(nodeResponse.readFiles).toEqual([]);
expect(nodeResponse.readFilesResult).toBe('');
expect(result[DispatchNodeResponseKeyEnum.toolResponses]).toEqual({ fileContent: '' });
});
it('超大内容下预览仍按 sliceStrStartEnd 截断 (start/end 各 1000)', async () => {
const huge = 'x'.repeat(5000);
mockGetFileContentFromLinks.mockResolvedValue([
{ success: true, filename: 'big.txt', url: '/big.txt', content: huge }
]);
const result = await dispatchReadFiles({
...baseProps,
params: { fileUrlList: ['/big.txt'] }
});
const preview = (result[DispatchNodeResponseKeyEnum.nodeResponse] as any)
.readFilesResult as string;
// sliceStrStartEnd 在超长文本上会截断中间,preview 长度远小于原文
expect(preview.length).toBeLessThan(huge.length);
expect(preview).toContain('## big.txt');
});
it('getFileContentFromLinks 抛错时通过 getNodeErrResponse 返回错误结构', async () => {
mockGetFileContentFromLinks.mockRejectedValue(new Error('boom'));
const result = await dispatchReadFiles({
...baseProps,
params: { fileUrlList: ['/a.pdf'] }
});
expect((result as any).error?.[NodeOutputKeyEnum.errorText]).toBe('boom');
expect((result[DispatchNodeResponseKeyEnum.nodeResponse] as any).errorText).toBe('boom');
expect((result[DispatchNodeResponseKeyEnum.toolResponses] as any).error).toBe('boom');
});
});
describe('getHistoryFileLinks', () => {
it('空历史返回空数组', () => {
expect(getHistoryFileLinks([])).toEqual([]);
});
it('仅保留 Human 消息中的文件 URL', () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [
{
file: {
type: ChatFileTypeEnum.file,
name: 'a.pdf',
url: '/a.pdf'
}
}
]
},
{
obj: ChatRoleEnum.AI,
value: [{ text: { content: 'AI 不会贡献文件' } }]
} as any
];
expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf']);
});
it('单条消息内多个文件按顺序展开', () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [
{
file: { type: ChatFileTypeEnum.file, name: 'a.pdf', url: '/a.pdf' }
},
{
file: { type: ChatFileTypeEnum.file, name: 'b.pdf', url: '/b.pdf' }
},
{ text: { content: '附带说明' } }
]
}
];
expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf', '/b.pdf']);
});
it('Human 消息中无文件时被过滤', () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [{ text: { content: '只是文本' } }]
}
];
expect(getHistoryFileLinks(histories)).toEqual([]);
});
it('混合多条消息时按出现顺序汇总 Human 文件', () => {
const histories: ChatItemMiniType[] = [
{
obj: ChatRoleEnum.Human,
value: [{ file: { type: ChatFileTypeEnum.file, name: 'a.pdf', url: '/a.pdf' } }]
},
{
obj: ChatRoleEnum.AI,
value: [{ text: { content: '回答' } }]
} as any,
{
obj: ChatRoleEnum.Human,
value: [
{ text: { content: '继续' } },
{ file: { type: ChatFileTypeEnum.file, name: 'b.pdf', url: '/b.pdf' } }
]
}
];
expect(getHistoryFileLinks(histories)).toEqual(['/a.pdf', '/b.pdf']);
});
});
Subproject commit 20bf396872d2b072018c7d2251cb4631d75500dd Subproject commit e56f30f8abe05dffb68b50778c06b85c51381335
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment