mirror of
https://github.com/labring/FastGPT.git
synced 2025-12-25 20:02:47 +00:00
image compatibility for various content-types (#6119)
* image compatibility for various content-types * perf: image type detect * perf: gethistory * update test * update rerank log * perf: login * fix: query extension use --------- Co-authored-by: archer <545436317@qq.com>
This commit is contained in:
parent
ea7c37745a
commit
5231f4281f
|
|
@ -16,6 +16,7 @@ description: 'FastGPT V4.14.5 更新说明'
|
||||||
|
|
||||||
1. MCP 工具创建时,使用自定义鉴权头会报错。
|
1. MCP 工具创建时,使用自定义鉴权头会报错。
|
||||||
2. 获取对话日志列表时,如果用户头像为空,会抛错。
|
2. 获取对话日志列表时,如果用户头像为空,会抛错。
|
||||||
|
3. chatAgent 未开启问题优化时,前端 UI 显示开启。
|
||||||
|
|
||||||
|
|
||||||
## 插件
|
## 插件
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@
|
||||||
"document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-09-29T11:52:39+08:00",
|
"document/content/docs/introduction/development/modelConfig/ppio.mdx": "2025-09-29T11:52:39+08:00",
|
||||||
"document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00",
|
"document/content/docs/introduction/development/modelConfig/siliconCloud.mdx": "2025-08-05T23:20:39+08:00",
|
||||||
"document/content/docs/introduction/development/openapi/app.mdx": "2025-09-26T13:18:51+08:00",
|
"document/content/docs/introduction/development/openapi/app.mdx": "2025-09-26T13:18:51+08:00",
|
||||||
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-11-14T13:21:17+08:00",
|
"document/content/docs/introduction/development/openapi/chat.mdx": "2025-12-18T13:49:45+08:00",
|
||||||
"document/content/docs/introduction/development/openapi/dataset.mdx": "2025-09-29T11:34:11+08:00",
|
"document/content/docs/introduction/development/openapi/dataset.mdx": "2025-09-29T11:34:11+08:00",
|
||||||
"document/content/docs/introduction/development/openapi/intro.mdx": "2025-09-29T11:34:11+08:00",
|
"document/content/docs/introduction/development/openapi/intro.mdx": "2025-09-29T11:34:11+08:00",
|
||||||
"document/content/docs/introduction/development/openapi/share.mdx": "2025-12-09T23:33:32+08:00",
|
"document/content/docs/introduction/development/openapi/share.mdx": "2025-12-09T23:33:32+08:00",
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { PaginationSchema, PaginationResponseSchema } from '../../../api';
|
||||||
export const GetHistoriesBodySchema = PaginationSchema.and(
|
export const GetHistoriesBodySchema = PaginationSchema.and(
|
||||||
OutLinkChatAuthSchema.and(
|
OutLinkChatAuthSchema.and(
|
||||||
z.object({
|
z.object({
|
||||||
appId: ObjectIdSchema.optional().describe('应用ID'),
|
appId: z.string().optional().describe('应用ID'),
|
||||||
source: z.enum(ChatSourceEnum).optional().describe('对话来源'),
|
source: z.enum(ChatSourceEnum).optional().describe('对话来源'),
|
||||||
startCreateTime: z.string().optional().describe('创建时间开始'),
|
startCreateTime: z.string().optional().describe('创建时间开始'),
|
||||||
endCreateTime: z.string().optional().describe('创建时间结束'),
|
endCreateTime: z.string().optional().describe('创建时间结束'),
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,12 @@ import { type preUploadImgProps } from '@fastgpt/global/common/file/api';
|
||||||
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
|
import { imageBaseUrl } from '@fastgpt/global/common/file/image/constants';
|
||||||
import { MongoImage } from './schema';
|
import { MongoImage } from './schema';
|
||||||
import { type ClientSession, Types } from '../../../common/mongo';
|
import { type ClientSession, Types } from '../../../common/mongo';
|
||||||
import { guessBase64ImageType } from '../utils';
|
import { guessBase64ImageType } from './utils';
|
||||||
import { readFromSecondary } from '../../mongo/utils';
|
import { readFromSecondary } from '../../mongo/utils';
|
||||||
import { addHours } from 'date-fns';
|
import { addHours } from 'date-fns';
|
||||||
import { imageFileType } from '@fastgpt/global/common/file/constants';
|
import { imageFileType } from '@fastgpt/global/common/file/constants';
|
||||||
import { retryFn } from '@fastgpt/global/common/system/utils';
|
import { retryFn } from '@fastgpt/global/common/system/utils';
|
||||||
import { UserError } from '@fastgpt/global/common/error/utils';
|
import { UserError } from '@fastgpt/global/common/error/utils';
|
||||||
import { S3Sources } from '../../s3/type';
|
|
||||||
import { getS3AvatarSource } from '../../s3/sources/avatar';
|
import { getS3AvatarSource } from '../../s3/sources/avatar';
|
||||||
import { isS3ObjectKey } from '../../s3/utils';
|
import { isS3ObjectKey } from '../../s3/utils';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,98 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { addLog } from '../../system/log';
|
import { addLog } from '../../system/log';
|
||||||
import { serverRequestBaseUrl } from '../../api/serverRequest';
|
import { serverRequestBaseUrl } from '../../api/serverRequest';
|
||||||
import { getFileContentTypeFromHeader, guessBase64ImageType } from '../utils';
|
|
||||||
import { retryFn } from '@fastgpt/global/common/system/utils';
|
import { retryFn } from '@fastgpt/global/common/system/utils';
|
||||||
|
import { getContentTypeFromHeader } from '../utils';
|
||||||
|
|
||||||
|
// 图片格式魔数映射表
|
||||||
|
const IMAGE_SIGNATURES: { type: string; magic: number[]; check?: (buffer: Buffer) => boolean }[] = [
|
||||||
|
{ type: 'image/jpeg', magic: [0xff, 0xd8, 0xff] },
|
||||||
|
{ type: 'image/png', magic: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
||||||
|
{ type: 'image/gif', magic: [0x47, 0x49, 0x46, 0x38] },
|
||||||
|
{
|
||||||
|
type: 'image/webp',
|
||||||
|
magic: [0x52, 0x49, 0x46, 0x46],
|
||||||
|
check: (buffer) => buffer.length >= 12 && buffer.slice(8, 12).toString('ascii') === 'WEBP'
|
||||||
|
},
|
||||||
|
{ type: 'image/bmp', magic: [0x42, 0x4d] },
|
||||||
|
{ type: 'image/tiff', magic: [0x49, 0x49, 0x2a, 0x00] },
|
||||||
|
{ type: 'image/tiff', magic: [0x4d, 0x4d, 0x00, 0x2a] },
|
||||||
|
{ type: 'image/svg+xml', magic: [0x3c, 0x73, 0x76, 0x67] },
|
||||||
|
{ type: 'image/x-icon', magic: [0x00, 0x00, 0x01, 0x00] }
|
||||||
|
];
|
||||||
|
|
||||||
|
// 有效的图片 MIME 类型
|
||||||
|
const VALID_IMAGE_TYPES = new Set([
|
||||||
|
'image/jpeg',
|
||||||
|
'image/jpg',
|
||||||
|
'image/png',
|
||||||
|
'image/gif',
|
||||||
|
'image/webp',
|
||||||
|
'image/bmp',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/tiff',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
'image/ico',
|
||||||
|
'image/heic',
|
||||||
|
'image/heif',
|
||||||
|
'image/avif'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Base64 首字符到图片类型的映射
|
||||||
|
const BASE64_PREFIX_MAP: Record<string, string> = {
|
||||||
|
'/': 'image/jpeg',
|
||||||
|
i: 'image/png',
|
||||||
|
R: 'image/gif',
|
||||||
|
U: 'image/webp',
|
||||||
|
Q: 'image/bmp',
|
||||||
|
P: 'image/svg+xml',
|
||||||
|
T: 'image/tiff',
|
||||||
|
J: 'image/jp2',
|
||||||
|
S: 'image/x-tga',
|
||||||
|
I: 'image/ief',
|
||||||
|
V: 'image/vnd.microsoft.icon',
|
||||||
|
W: 'image/vnd.wap.wbmp',
|
||||||
|
X: 'image/x-xbitmap',
|
||||||
|
Z: 'image/x-xpixmap',
|
||||||
|
Y: 'image/x-xwindowdump'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_IMAGE_TYPE = 'image/jpeg';
|
||||||
|
|
||||||
|
export const isValidImageContentType = (contentType: string): boolean => {
|
||||||
|
if (!contentType) return false;
|
||||||
|
return VALID_IMAGE_TYPES.has(contentType);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const detectImageTypeFromBuffer = (buffer: Buffer): string | undefined => {
|
||||||
|
if (!buffer || buffer.length === 0) return;
|
||||||
|
|
||||||
|
for (const { type, magic, check } of IMAGE_SIGNATURES) {
|
||||||
|
if (buffer.length < magic.length) continue;
|
||||||
|
|
||||||
|
const matches = magic.every((byte, index) => buffer[index] === byte);
|
||||||
|
if (matches && (!check || check(buffer))) {
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const guessBase64ImageType = (str: string): string => {
|
||||||
|
if (!str || typeof str !== 'string') return DEFAULT_IMAGE_TYPE;
|
||||||
|
|
||||||
|
// 尝试从 base64 解码并检测文件头
|
||||||
|
try {
|
||||||
|
const buffer = Buffer.from(str, 'base64');
|
||||||
|
const detectedType = detectImageTypeFromBuffer(buffer);
|
||||||
|
if (detectedType) return detectedType;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// 回退到首字符映射
|
||||||
|
return BASE64_PREFIX_MAP[str.charAt(0)] || DEFAULT_IMAGE_TYPE;
|
||||||
|
};
|
||||||
|
|
||||||
export const getImageBase64 = async (url: string) => {
|
export const getImageBase64 = async (url: string) => {
|
||||||
addLog.debug(`Load image to base64: ${url}`);
|
addLog.debug(`Load image to base64: ${url}`);
|
||||||
|
|
@ -16,10 +106,26 @@ export const getImageBase64 = async (url: string) => {
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const base64 = Buffer.from(response.data, 'binary').toString('base64');
|
const buffer = Buffer.from(response.data);
|
||||||
const imageType =
|
const base64 = buffer.toString('base64');
|
||||||
getFileContentTypeFromHeader(response.headers['content-type']) ||
|
const headerContentType = getContentTypeFromHeader(response.headers['content-type']);
|
||||||
guessBase64ImageType(base64);
|
|
||||||
|
// 检测图片类型的优先级策略
|
||||||
|
const imageType = (() => {
|
||||||
|
// 1. 如果 Header 是有效的图片类型,直接使用
|
||||||
|
if (headerContentType && isValidImageContentType(headerContentType)) {
|
||||||
|
return headerContentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 使用文件头检测(适用于通用二进制类型或无效类型)
|
||||||
|
const detectedType = detectImageTypeFromBuffer(buffer);
|
||||||
|
if (detectedType) {
|
||||||
|
return detectedType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 回退到 base64 推断
|
||||||
|
return guessBase64ImageType(base64);
|
||||||
|
})();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
completeBase64: `data:${imageType};base64,${base64}`,
|
completeBase64: `data:${imageType};base64,${base64}`,
|
||||||
|
|
|
||||||
|
|
@ -17,37 +17,8 @@ export const removeFilesByPaths = (paths: string[]) => {
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const guessBase64ImageType = (str: string) => {
|
export const getContentTypeFromHeader = (header: string): string | undefined => {
|
||||||
const imageTypeMap: Record<string, string> = {
|
return header?.toLowerCase()?.split(';')?.[0]?.trim();
|
||||||
'/': 'image/jpeg',
|
|
||||||
i: 'image/png',
|
|
||||||
R: 'image/gif',
|
|
||||||
U: 'image/webp',
|
|
||||||
Q: 'image/bmp',
|
|
||||||
P: 'image/svg+xml',
|
|
||||||
T: 'image/tiff',
|
|
||||||
J: 'image/jp2',
|
|
||||||
S: 'image/x-tga',
|
|
||||||
I: 'image/ief',
|
|
||||||
V: 'image/vnd.microsoft.icon',
|
|
||||||
W: 'image/vnd.wap.wbmp',
|
|
||||||
X: 'image/x-xbitmap',
|
|
||||||
Z: 'image/x-xpixmap',
|
|
||||||
Y: 'image/x-xwindowdump'
|
|
||||||
};
|
|
||||||
|
|
||||||
const defaultType = 'image/jpeg';
|
|
||||||
if (typeof str !== 'string' || str.length === 0) {
|
|
||||||
return defaultType;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstChar = str.charAt(0);
|
|
||||||
return imageTypeMap[firstChar] || defaultType;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getFileContentTypeFromHeader = (header: string): string | undefined => {
|
|
||||||
const contentType = header.split(';')[0];
|
|
||||||
return contentType;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const clearDirFiles = (dirPath: string) => {
|
export const clearDirFiles = (dirPath: string) => {
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import { getAIApi } from '../config';
|
||||||
import { countPromptTokens } from '../../../common/string/tiktoken/index';
|
import { countPromptTokens } from '../../../common/string/tiktoken/index';
|
||||||
import { EmbeddingTypeEnm } from '@fastgpt/global/core/ai/constants';
|
import { EmbeddingTypeEnm } from '@fastgpt/global/core/ai/constants';
|
||||||
import { addLog } from '../../../common/system/log';
|
import { addLog } from '../../../common/system/log';
|
||||||
|
import { getErrText } from '@fastgpt/global/common/error/utils';
|
||||||
|
import { retryFn } from '@fastgpt/global/common/system/utils';
|
||||||
|
|
||||||
type GetVectorProps = {
|
type GetVectorProps = {
|
||||||
model: EmbeddingModelItemType;
|
model: EmbeddingModelItemType;
|
||||||
|
|
@ -38,55 +40,70 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
|
||||||
|
|
||||||
for (const chunk of chunks) {
|
for (const chunk of chunks) {
|
||||||
// input text to vector
|
// input text to vector
|
||||||
const result = await ai.embeddings
|
const result = await retryFn(() =>
|
||||||
.create(
|
ai.embeddings
|
||||||
{
|
.create(
|
||||||
...model.defaultConfig,
|
{
|
||||||
...(type === EmbeddingTypeEnm.db && model.dbConfig),
|
...model.defaultConfig,
|
||||||
...(type === EmbeddingTypeEnm.query && model.queryConfig),
|
...(type === EmbeddingTypeEnm.db && model.dbConfig),
|
||||||
model: model.model,
|
...(type === EmbeddingTypeEnm.query && model.queryConfig),
|
||||||
input: chunk
|
model: model.model,
|
||||||
},
|
input: chunk
|
||||||
model.requestUrl
|
},
|
||||||
? {
|
model.requestUrl
|
||||||
path: model.requestUrl,
|
? {
|
||||||
headers: {
|
path: model.requestUrl,
|
||||||
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
|
headers: {
|
||||||
...headers
|
...(model.requestAuth ? { Authorization: `Bearer ${model.requestAuth}` } : {}),
|
||||||
|
...headers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
: { headers }
|
||||||
: { headers }
|
)
|
||||||
)
|
.then(async (res) => {
|
||||||
.then(async (res) => {
|
if (!res.data) {
|
||||||
if (!res.data) {
|
addLog.error('[Embedding Error] not responding', {
|
||||||
addLog.error('[Embedding] API is not responding', res);
|
message: '',
|
||||||
return Promise.reject('Embedding API is not responding');
|
data: {
|
||||||
}
|
response: res,
|
||||||
if (!res?.data?.[0]?.embedding) {
|
model: model.model,
|
||||||
// @ts-ignore
|
inputLength: chunk.length
|
||||||
const msg = res.data?.err?.message || 'Embedding API Error';
|
}
|
||||||
addLog.error('[Embedding] API Error', {
|
});
|
||||||
message: msg,
|
return Promise.reject('Embedding API is not responding');
|
||||||
data: res
|
}
|
||||||
});
|
if (!res?.data?.[0]?.embedding) {
|
||||||
return Promise.reject(msg);
|
// @ts-ignore
|
||||||
}
|
const msg = res.data?.err?.message || '';
|
||||||
|
addLog.error('[Embedding Error]', {
|
||||||
|
message: msg,
|
||||||
|
data: {
|
||||||
|
response: res,
|
||||||
|
model: model.model,
|
||||||
|
inputLength: chunk.length
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return Promise.reject('Embedding API is not responding');
|
||||||
|
}
|
||||||
|
|
||||||
const [tokens, vectors] = await Promise.all([
|
const [tokens, vectors] = await Promise.all([
|
||||||
(async () => {
|
(async () => {
|
||||||
if (res.usage) return res.usage.total_tokens;
|
if (res.usage) return res.usage.total_tokens;
|
||||||
|
|
||||||
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
|
const tokens = await Promise.all(chunk.map((item) => countPromptTokens(item)));
|
||||||
return tokens.reduce((sum, item) => sum + item, 0);
|
return tokens.reduce((sum, item) => sum + item, 0);
|
||||||
})(),
|
})(),
|
||||||
Promise.all(res.data.map((item) => formatVectors(item.embedding, model.normalization)))
|
Promise.all(
|
||||||
]);
|
res.data.map((item) => formatVectors(item.embedding, model.normalization))
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
tokens,
|
tokens,
|
||||||
vectors
|
vectors
|
||||||
};
|
};
|
||||||
});
|
})
|
||||||
|
);
|
||||||
|
|
||||||
totalTokens += result.tokens;
|
totalTokens += result.tokens;
|
||||||
allVectors.push(...result.vectors);
|
allVectors.push(...result.vectors);
|
||||||
|
|
@ -97,7 +114,13 @@ export async function getVectorsByText({ model, input, type, headers }: GetVecto
|
||||||
vectors: allVectors
|
vectors: allVectors
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addLog.error(`[Embedding] request error`, error);
|
addLog.error(`[Embedding Error]`, {
|
||||||
|
message: getErrText(error),
|
||||||
|
data: {
|
||||||
|
model: model.model,
|
||||||
|
inputLengths: formatInput.map((item) => item.length)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -628,12 +628,17 @@ const createChatCompletion = async ({
|
||||||
('iterator' in response || 'controller' in response);
|
('iterator' in response || 'controller' in response);
|
||||||
|
|
||||||
const getEmptyResponseTip = () => {
|
const getEmptyResponseTip = () => {
|
||||||
addLog.warn(`LLM response empty`, {
|
|
||||||
baseUrl: userKey?.baseUrl,
|
|
||||||
requestBody: body
|
|
||||||
});
|
|
||||||
if (userKey?.baseUrl) {
|
if (userKey?.baseUrl) {
|
||||||
|
addLog.warn(`User LLM response empty`, {
|
||||||
|
baseUrl: userKey?.baseUrl,
|
||||||
|
requestBody: body
|
||||||
|
});
|
||||||
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
|
return `您的 OpenAI key 没有响应: ${JSON.stringify(body)}`;
|
||||||
|
} else {
|
||||||
|
addLog.error(`LLM response empty`, {
|
||||||
|
message: '',
|
||||||
|
data: body
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return i18nT('chat:LLM_model_response_empty');
|
return i18nT('chat:LLM_model_response_empty');
|
||||||
};
|
};
|
||||||
|
|
@ -652,13 +657,18 @@ const createChatCompletion = async ({
|
||||||
getEmptyResponseTip
|
getEmptyResponseTip
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
addLog.error(`LLM response error`, error);
|
|
||||||
addLog.warn(`LLM response error`, {
|
|
||||||
baseUrl: userKey?.baseUrl,
|
|
||||||
requestBody: body
|
|
||||||
});
|
|
||||||
if (userKey?.baseUrl) {
|
if (userKey?.baseUrl) {
|
||||||
|
addLog.warn(`User ai api error`, {
|
||||||
|
message: getErrText(error),
|
||||||
|
baseUrl: userKey?.baseUrl,
|
||||||
|
data: body
|
||||||
|
});
|
||||||
return Promise.reject(`您的 OpenAI key 出错了: ${getErrText(error)}`);
|
return Promise.reject(`您的 OpenAI key 出错了: ${getErrText(error)}`);
|
||||||
|
} else {
|
||||||
|
addLog.error(`LLM response error`, {
|
||||||
|
message: getErrText(error),
|
||||||
|
data: body
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ export function reRankRecall({
|
||||||
headers?: Record<string, string>;
|
headers?: Record<string, string>;
|
||||||
}): Promise<ReRankCallResult> {
|
}): Promise<ReRankCallResult> {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return Promise.reject('[Rerank] No rerank model');
|
return Promise.reject('No rerank model');
|
||||||
}
|
}
|
||||||
if (documents.length === 0) {
|
if (documents.length === 0) {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
|
|
@ -67,7 +67,7 @@ export function reRankRecall({
|
||||||
addLog.info('ReRank finish:', { time: Date.now() - start });
|
addLog.info('ReRank finish:', { time: Date.now() - start });
|
||||||
|
|
||||||
if (!data?.results || data?.results?.length === 0) {
|
if (!data?.results || data?.results?.length === 0) {
|
||||||
addLog.error('[Rerank] Empty result', { data });
|
addLog.error('[Rerank Error]', { message: 'Empty result', data });
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -81,7 +81,7 @@ export function reRankRecall({
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
addLog.error('[Rerank] request error', err);
|
addLog.error('[Rerank Error]', err);
|
||||||
|
|
||||||
return Promise.reject(err);
|
return Promise.reject(err);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ const SearchParamsTip = ({
|
||||||
limit = 5000,
|
limit = 5000,
|
||||||
responseEmptyText,
|
responseEmptyText,
|
||||||
usingReRank = false,
|
usingReRank = false,
|
||||||
datasetSearchUsingExtensionQuery,
|
usingExtensionQuery,
|
||||||
queryExtensionModel
|
queryExtensionModel
|
||||||
}: {
|
}: {
|
||||||
searchMode: `${DatasetSearchModeEnum}`;
|
searchMode: `${DatasetSearchModeEnum}`;
|
||||||
|
|
@ -23,7 +23,7 @@ const SearchParamsTip = ({
|
||||||
limit?: number;
|
limit?: number;
|
||||||
responseEmptyText?: string;
|
responseEmptyText?: string;
|
||||||
usingReRank?: boolean;
|
usingReRank?: boolean;
|
||||||
datasetSearchUsingExtensionQuery?: boolean;
|
usingExtensionQuery?: boolean;
|
||||||
queryExtensionModel?: string;
|
queryExtensionModel?: string;
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
@ -34,8 +34,8 @@ const SearchParamsTip = ({
|
||||||
const hasSimilarityMode = usingReRank || searchMode === DatasetSearchModeEnum.embedding;
|
const hasSimilarityMode = usingReRank || searchMode === DatasetSearchModeEnum.embedding;
|
||||||
|
|
||||||
const extensionModelName = useMemo(
|
const extensionModelName = useMemo(
|
||||||
() => getWebLLMModel(queryExtensionModel)?.name,
|
() => (usingExtensionQuery ? getWebLLMModel(queryExtensionModel)?.name : ''),
|
||||||
[queryExtensionModel]
|
[usingExtensionQuery, queryExtensionModel]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -94,7 +94,7 @@ const SearchParamsTip = ({
|
||||||
</Td>
|
</Td>
|
||||||
)}
|
)}
|
||||||
<Td pt={0} pb={2} fontSize={'mini'}>
|
<Td pt={0} pb={2} fontSize={'mini'}>
|
||||||
{extensionModelName ? extensionModelName : '❌'}
|
{extensionModelName || '❌'}
|
||||||
</Td>
|
</Td>
|
||||||
{hasEmptyResponseMode && <Th>{responseEmptyText !== '' ? '✅' : '❌'}</Th>}
|
{hasEmptyResponseMode && <Th>{responseEmptyText !== '' ? '✅' : '❌'}</Th>}
|
||||||
</Tr>
|
</Tr>
|
||||||
|
|
|
||||||
|
|
@ -282,7 +282,7 @@ const EditForm = ({
|
||||||
similarity={appForm.dataset.similarity}
|
similarity={appForm.dataset.similarity}
|
||||||
limit={appForm.dataset.limit}
|
limit={appForm.dataset.limit}
|
||||||
usingReRank={appForm.dataset.usingReRank}
|
usingReRank={appForm.dataset.usingReRank}
|
||||||
datasetSearchUsingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
|
usingExtensionQuery={appForm.dataset.datasetSearchUsingExtensionQuery}
|
||||||
queryExtensionModel={appForm.dataset.datasetSearchExtensionModel}
|
queryExtensionModel={appForm.dataset.datasetSearchExtensionModel}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ const SelectDatasetParam = ({ inputs = [], nodeId }: RenderInputProps) => {
|
||||||
similarity={data.similarity}
|
similarity={data.similarity}
|
||||||
limit={data.limit}
|
limit={data.limit}
|
||||||
usingReRank={data.usingReRank}
|
usingReRank={data.usingReRank}
|
||||||
datasetSearchUsingExtensionQuery={data.datasetSearchUsingExtensionQuery}
|
usingExtensionQuery={data.datasetSearchUsingExtensionQuery}
|
||||||
queryExtensionModel={data.datasetSearchExtensionModel}
|
queryExtensionModel={data.datasetSearchExtensionModel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -433,7 +433,7 @@ const TestResults = React.memo(function TestResults({
|
||||||
similarity={datasetTestItem.similarity}
|
similarity={datasetTestItem.similarity}
|
||||||
limit={datasetTestItem.limit}
|
limit={datasetTestItem.limit}
|
||||||
usingReRank={datasetTestItem.usingReRank}
|
usingReRank={datasetTestItem.usingReRank}
|
||||||
datasetSearchUsingExtensionQuery={!!datasetTestItem.queryExtensionModel}
|
usingExtensionQuery={!!datasetTestItem.queryExtensionModel}
|
||||||
queryExtensionModel={datasetTestItem.queryExtensionModel}
|
queryExtensionModel={datasetTestItem.queryExtensionModel}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import {
|
||||||
} from '@fastgpt/global/openapi/core/chat/history/api';
|
} from '@fastgpt/global/openapi/core/chat/history/api';
|
||||||
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
|
import { parsePaginationRequest } from '@fastgpt/service/common/api/pagination';
|
||||||
import { addMonths } from 'date-fns';
|
import { addMonths } from 'date-fns';
|
||||||
|
import { ObjectIdSchema } from '@fastgpt/global/common/type/mongo';
|
||||||
|
|
||||||
/* get chat histories list */
|
/* get chat histories list */
|
||||||
export async function handler(
|
export async function handler(
|
||||||
|
|
@ -70,6 +71,13 @@ export async function handler(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (match.appId && !ObjectIdSchema.safeParse(match.appId).success) {
|
||||||
|
return {
|
||||||
|
list: [],
|
||||||
|
total: 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const timeMatch: Record<string, any> = {};
|
const timeMatch: Record<string, any> = {};
|
||||||
if (startCreateTime || endCreateTime) {
|
if (startCreateTime || endCreateTime) {
|
||||||
timeMatch.createTime = {
|
timeMatch.createTime = {
|
||||||
|
|
|
||||||
|
|
@ -30,21 +30,6 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
type: UserAuthTypeEnum.login
|
type: UserAuthTypeEnum.login
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检测用户是否存在
|
|
||||||
const authCert = await MongoUser.findOne(
|
|
||||||
{
|
|
||||||
username
|
|
||||||
},
|
|
||||||
'status'
|
|
||||||
);
|
|
||||||
if (!authCert) {
|
|
||||||
return Promise.reject(UserErrEnum.account_psw_error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authCert.status === UserStatusEnum.forbidden) {
|
|
||||||
return Promise.reject('Invalid account!');
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await MongoUser.findOne({
|
const user = await MongoUser.findOne({
|
||||||
username,
|
username,
|
||||||
password
|
password
|
||||||
|
|
@ -53,16 +38,18 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return Promise.reject(UserErrEnum.account_psw_error);
|
return Promise.reject(UserErrEnum.account_psw_error);
|
||||||
}
|
}
|
||||||
|
if (user.status === UserStatusEnum.forbidden) {
|
||||||
|
return Promise.reject('Invalid account!');
|
||||||
|
}
|
||||||
|
|
||||||
const userDetail = await getUserDetail({
|
const userDetail = await getUserDetail({
|
||||||
tmbId: user?.lastLoginTmbId,
|
tmbId: user?.lastLoginTmbId,
|
||||||
userId: user._id
|
userId: user._id
|
||||||
});
|
});
|
||||||
|
|
||||||
MongoUser.findByIdAndUpdate(user._id, {
|
user.lastLoginTmbId = userDetail.team.tmbId;
|
||||||
lastLoginTmbId: userDetail.team.tmbId,
|
user.language = language;
|
||||||
language
|
await user.save();
|
||||||
});
|
|
||||||
|
|
||||||
const token = await createUserSession({
|
const token = await createUserSession({
|
||||||
userId: user._id,
|
userId: user._id,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ describe('closeCustom api test', () => {
|
||||||
let dataId: string;
|
let dataId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testUser = await getUser('test-user-close-custom');
|
testUser = await getUser(`test-user-close-custom-${Math.random()}`);
|
||||||
|
|
||||||
// Create test app
|
// Create test app
|
||||||
const app = await MongoApp.create({
|
const app = await MongoApp.create({
|
||||||
|
|
@ -94,7 +94,7 @@ describe('closeCustom api test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when user does not have permission', async () => {
|
it('should fail when user does not have permission', async () => {
|
||||||
const unauthorizedUser = await getUser('unauthorized-user-close');
|
const unauthorizedUser = await getUser(`unauthorized-user-close-${Math.random()}`);
|
||||||
|
|
||||||
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
|
const res = await Call<CloseCustomFeedbackBodyType, {}, CloseCustomFeedbackResponseType>(
|
||||||
handler,
|
handler,
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ describe('getFeedbackRecordIds api test', () => {
|
||||||
let chatId: string;
|
let chatId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testUser = await getUser('test-user-get-feedback-ids');
|
testUser = await getUser(`test-user-get-feedback-ids-${Math.random()}`);
|
||||||
|
|
||||||
// Create test app
|
// Create test app
|
||||||
const app = await MongoApp.create({
|
const app = await MongoApp.create({
|
||||||
|
|
@ -270,7 +270,7 @@ describe('getFeedbackRecordIds api test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when user does not have permission', async () => {
|
it('should fail when user does not have permission', async () => {
|
||||||
const unauthorizedUser = await getUser('unauthorized-user-get-ids');
|
const unauthorizedUser = await getUser(`unauthorized-user-get-ids-${Math.random()}`);
|
||||||
|
|
||||||
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
|
const res = await Call<GetFeedbackRecordIdsBodyType, {}, GetFeedbackRecordIdsResponseType>(
|
||||||
handler,
|
handler,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ describe('updateFeedbackReadStatus api test', () => {
|
||||||
let dataId: string;
|
let dataId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testUser = await getUser('test-user-update-read-status');
|
// Use unique username for each test to avoid concurrency issues
|
||||||
|
testUser = await getUser(`test-user-update-read-status-${Math.random()}`);
|
||||||
|
|
||||||
// Create test app
|
// Create test app
|
||||||
const app = await MongoApp.create({
|
const app = await MongoApp.create({
|
||||||
|
|
@ -127,7 +128,7 @@ describe('updateFeedbackReadStatus api test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when user does not have permission', async () => {
|
it('should fail when user does not have permission', async () => {
|
||||||
const unauthorizedUser = await getUser('unauthorized-user-read-status');
|
const unauthorizedUser = await getUser(`unauthorized-user-read-status-${Math.random()}`);
|
||||||
|
|
||||||
const res = await Call<
|
const res = await Call<
|
||||||
UpdateFeedbackReadStatusBodyType,
|
UpdateFeedbackReadStatusBodyType,
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ describe('updateUserFeedback api test', () => {
|
||||||
let dataId: string;
|
let dataId: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
testUser = await getUser('test-user-update-feedback');
|
testUser = await getUser(`test-user-update-feedback-${Math.random()}`);
|
||||||
|
|
||||||
// Create test app
|
// Create test app
|
||||||
const app = await MongoApp.create({
|
const app = await MongoApp.create({
|
||||||
|
|
@ -383,7 +383,7 @@ describe('updateUserFeedback api test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when user does not have permission', async () => {
|
it('should fail when user does not have permission', async () => {
|
||||||
const unauthorizedUser = await getUser('unauthorized-user-feedback');
|
const unauthorizedUser = await getUser(`unauthorized-user-feedback-${Math.random()}`);
|
||||||
|
|
||||||
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
|
const res = await Call<UpdateUserFeedbackBodyType, {}, UpdateUserFeedbackResponseType>(
|
||||||
handler,
|
handler,
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ describe('getHistories api test', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return empty list when appId does not exist', async () => {
|
it('should return empty list when appId does not exist', async () => {
|
||||||
const nonExistentAppId = getNanoid(24);
|
const nonExistentAppId = '507f1f77bcf86cd799439011'; // Valid ObjectId format but non-existent
|
||||||
|
|
||||||
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
|
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
|
||||||
auth: testUser,
|
auth: testUser,
|
||||||
|
|
@ -276,9 +276,9 @@ describe('getHistories api test', () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(res.code).toBe(500);
|
expect(res.code).toBe(200);
|
||||||
expect(res.error).toBeDefined();
|
expect(res.data.list).toHaveLength(0);
|
||||||
expect(res.error?.name).toBe('ZodError');
|
expect(res.data.total).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should fail when appId is missing', async () => {
|
it('should fail when appId is missing', async () => {
|
||||||
|
|
@ -341,4 +341,40 @@ describe('getHistories api test', () => {
|
||||||
// Second should be the most recently updated non-top chat
|
// Second should be the most recently updated non-top chat
|
||||||
expect(res.data.list[1].chatId).toBe(chatIds[2]);
|
expect(res.data.list[1].chatId).toBe(chatIds[2]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return empty list when appId format is invalid', async () => {
|
||||||
|
const invalidAppId = 'invalid-app-id'; // Not a valid ObjectId format
|
||||||
|
|
||||||
|
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
|
||||||
|
auth: testUser,
|
||||||
|
body: {
|
||||||
|
appId: invalidAppId
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
expect(res.data.list).toHaveLength(0);
|
||||||
|
expect(res.data.total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should accept empty string for appId and return empty list', async () => {
|
||||||
|
const res = await Call<GetHistoriesBodyType, any, GetHistoriesResponseType>(handler, {
|
||||||
|
auth: testUser,
|
||||||
|
body: {
|
||||||
|
appId: ''
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
offset: 0,
|
||||||
|
pageSize: 10
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
expect(res.data.list).toHaveLength(0);
|
||||||
|
expect(res.data.total).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,6 @@ describe('loginByPassword API', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept language parameter on successful login', async () => {
|
it('should accept language parameter on successful login', async () => {
|
||||||
// Spy on findByIdAndUpdate to verify it's called with the language
|
|
||||||
const findByIdAndUpdateSpy = vi.spyOn(MongoUser, 'findByIdAndUpdate');
|
|
||||||
|
|
||||||
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
||||||
body: {
|
body: {
|
||||||
username: 'testuser',
|
username: 'testuser',
|
||||||
|
|
@ -208,15 +205,10 @@ describe('loginByPassword API', () => {
|
||||||
expect(res.code).toBe(200);
|
expect(res.code).toBe(200);
|
||||||
expect(res.error).toBeUndefined();
|
expect(res.error).toBeUndefined();
|
||||||
|
|
||||||
// Verify findByIdAndUpdate was called with the language
|
// Verify user was updated with the language
|
||||||
expect(findByIdAndUpdateSpy).toHaveBeenCalledWith(
|
const updatedUser = await MongoUser.findById(testUser._id);
|
||||||
testUser._id,
|
expect(updatedUser?.language).toBe('en');
|
||||||
expect.objectContaining({
|
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
|
||||||
language: 'en'
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
findByIdAndUpdateSpy.mockRestore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle root user login correctly', async () => {
|
it('should handle root user login correctly', async () => {
|
||||||
|
|
@ -261,4 +253,74 @@ describe('loginByPassword API', () => {
|
||||||
expect(res.data.token).toBeDefined();
|
expect(res.data.token).toBeDefined();
|
||||||
expect(typeof res.data.token).toBe('string');
|
expect(typeof res.data.token).toBe('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use default language when language is not provided', async () => {
|
||||||
|
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
||||||
|
body: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpassword',
|
||||||
|
code: '123456'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
expect(res.error).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify user was updated with the default language 'zh-CN'
|
||||||
|
const updatedUser = await MongoUser.findById(testUser._id);
|
||||||
|
expect(updatedUser?.language).toBe('zh-CN');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update lastLoginTmbId on successful login', async () => {
|
||||||
|
const updateOneSpy = vi.spyOn(MongoUser, 'updateOne');
|
||||||
|
|
||||||
|
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
||||||
|
body: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpassword',
|
||||||
|
code: '123456'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
expect(res.error).toBeUndefined();
|
||||||
|
|
||||||
|
// Verify user was updated with lastLoginTmbId
|
||||||
|
const updatedUser = await MongoUser.findById(testUser._id);
|
||||||
|
expect(updatedUser?.lastLoginTmbId).toEqual(testTmb._id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should verify user authentication flow', async () => {
|
||||||
|
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
||||||
|
body: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpassword',
|
||||||
|
code: '123456'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
|
||||||
|
// Verify the full authentication flow
|
||||||
|
expect(authCode).toHaveBeenCalled();
|
||||||
|
expect(setCookie).toHaveBeenCalled();
|
||||||
|
expect(pushTrack.login).toHaveBeenCalled();
|
||||||
|
expect(addAuditLog).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return user details with correct structure', async () => {
|
||||||
|
const res = await Call<PostLoginProps, {}, any>(loginApi.default, {
|
||||||
|
body: {
|
||||||
|
username: 'testuser',
|
||||||
|
password: 'testpassword',
|
||||||
|
code: '123456'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.code).toBe(200);
|
||||||
|
expect(res.data.user).toBeDefined();
|
||||||
|
expect(res.data.user.team).toBeDefined();
|
||||||
|
expect(res.data.user.team.teamId).toBe(String(testTeam._id));
|
||||||
|
expect(res.data.user.team.tmbId).toBe(String(testTmb._id));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,189 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import {
|
||||||
|
isValidImageContentType,
|
||||||
|
detectImageTypeFromBuffer,
|
||||||
|
guessBase64ImageType
|
||||||
|
} from '@fastgpt/service/common/file/image/utils';
|
||||||
|
|
||||||
|
describe('isValidImageContentType', () => {
|
||||||
|
it('should return true for valid image MIME types', () => {
|
||||||
|
expect(isValidImageContentType('image/jpeg')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/jpg')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/png')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/gif')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/webp')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/bmp')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/svg+xml')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/tiff')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/x-icon')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/vnd.microsoft.icon')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/ico')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/heic')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/heif')).toBe(true);
|
||||||
|
expect(isValidImageContentType('image/avif')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for invalid image MIME types', () => {
|
||||||
|
expect(isValidImageContentType('text/plain')).toBe(false);
|
||||||
|
expect(isValidImageContentType('application/json')).toBe(false);
|
||||||
|
expect(isValidImageContentType('video/mp4')).toBe(false);
|
||||||
|
expect(isValidImageContentType('audio/mpeg')).toBe(false);
|
||||||
|
expect(isValidImageContentType('application/pdf')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for empty or undefined input', () => {
|
||||||
|
expect(isValidImageContentType('')).toBe(false);
|
||||||
|
expect(isValidImageContentType(undefined as any)).toBe(false);
|
||||||
|
expect(isValidImageContentType(null as any)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should be case-sensitive (requires lowercase input)', () => {
|
||||||
|
// Note: This function expects lowercase input
|
||||||
|
// The getContentTypeFromHeader function should normalize it first
|
||||||
|
expect(isValidImageContentType('IMAGE/JPEG')).toBe(false);
|
||||||
|
expect(isValidImageContentType('Image/Png')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectImageTypeFromBuffer', () => {
|
||||||
|
it('should detect JPEG images', () => {
|
||||||
|
const jpegBuffer = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46]);
|
||||||
|
expect(detectImageTypeFromBuffer(jpegBuffer)).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect PNG images', () => {
|
||||||
|
const pngBuffer = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(pngBuffer)).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF images', () => {
|
||||||
|
const gifBuffer = Buffer.from([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(gifBuffer)).toBe('image/gif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP images', () => {
|
||||||
|
// RIFF....WEBP
|
||||||
|
const webpBuffer = Buffer.from([
|
||||||
|
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50
|
||||||
|
]);
|
||||||
|
expect(detectImageTypeFromBuffer(webpBuffer)).toBe('image/webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect BMP images', () => {
|
||||||
|
const bmpBuffer = Buffer.from([0x42, 0x4d, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(bmpBuffer)).toBe('image/bmp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF images (little-endian)', () => {
|
||||||
|
const tiffBuffer = Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(tiffBuffer)).toBe('image/tiff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect TIFF images (big-endian)', () => {
|
||||||
|
const tiffBuffer = Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(tiffBuffer)).toBe('image/tiff');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect SVG images', () => {
|
||||||
|
const svgBuffer = Buffer.from([0x3c, 0x73, 0x76, 0x67, 0x20, 0x78, 0x6d, 0x6c]);
|
||||||
|
expect(detectImageTypeFromBuffer(svgBuffer)).toBe('image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect ICO images', () => {
|
||||||
|
const icoBuffer = Buffer.from([0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]);
|
||||||
|
expect(detectImageTypeFromBuffer(icoBuffer)).toBe('image/x-icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for invalid or too short buffers', () => {
|
||||||
|
expect(detectImageTypeFromBuffer(Buffer.from([]))).toBe(undefined);
|
||||||
|
expect(detectImageTypeFromBuffer(Buffer.from([0x00]))).toBe(undefined);
|
||||||
|
expect(detectImageTypeFromBuffer(Buffer.from([0x00, 0x00]))).toBe(undefined);
|
||||||
|
expect(detectImageTypeFromBuffer(null as any)).toBe(undefined);
|
||||||
|
expect(detectImageTypeFromBuffer(undefined as any)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return undefined for unknown image formats', () => {
|
||||||
|
const unknownBuffer = Buffer.from([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11]);
|
||||||
|
expect(detectImageTypeFromBuffer(unknownBuffer)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not detect WebP without proper WEBP marker', () => {
|
||||||
|
// RIFF but no WEBP marker
|
||||||
|
const fakeWebpBuffer = Buffer.from([
|
||||||
|
0x52, 0x49, 0x46, 0x46, 0x00, 0x00, 0x00, 0x00, 0x41, 0x42, 0x43, 0x44
|
||||||
|
]);
|
||||||
|
expect(detectImageTypeFromBuffer(fakeWebpBuffer)).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('guessBase64ImageType', () => {
|
||||||
|
it('should detect JPEG from base64 string', () => {
|
||||||
|
// /9j/ is the base64 encoded start of a JPEG file (0xFF 0xD8 0xFF)
|
||||||
|
const jpegBase64 = '/9j/4AAQSkZJRgABAQAAAQABAAD/';
|
||||||
|
expect(guessBase64ImageType(jpegBase64)).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect PNG from base64 string', () => {
|
||||||
|
// iVBORw== is the base64 encoded start of a PNG file
|
||||||
|
const pngBase64 =
|
||||||
|
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
|
||||||
|
expect(guessBase64ImageType(pngBase64)).toBe('image/png');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect GIF from base64 string', () => {
|
||||||
|
// R0lGOD is the base64 encoded start of a GIF file
|
||||||
|
const gifBase64 = 'R0lGODlhAQABAAAAACw=';
|
||||||
|
expect(guessBase64ImageType(gifBase64)).toBe('image/gif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect WebP from base64 string', () => {
|
||||||
|
// UklGR is RIFF in base64
|
||||||
|
const webpBase64 = 'UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAwA0JaQAA3AA/vuUAAA=';
|
||||||
|
expect(guessBase64ImageType(webpBase64)).toBe('image/webp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should detect BMP from base64 string', () => {
|
||||||
|
// Qk0= is the base64 encoded start of a BMP file (0x42 0x4D)
|
||||||
|
const bmpBase64 = 'Qk02AgAAAAAAADYAAAAoAAAA';
|
||||||
|
expect(guessBase64ImageType(bmpBase64)).toBe('image/bmp');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should fallback to first character mapping for unknown formats', () => {
|
||||||
|
// Test various first characters from BASE64_PREFIX_MAP
|
||||||
|
expect(guessBase64ImageType('/')).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType('i')).toBe('image/png');
|
||||||
|
expect(guessBase64ImageType('R')).toBe('image/gif');
|
||||||
|
expect(guessBase64ImageType('U')).toBe('image/webp');
|
||||||
|
expect(guessBase64ImageType('Q')).toBe('image/bmp');
|
||||||
|
expect(guessBase64ImageType('P')).toBe('image/svg+xml');
|
||||||
|
expect(guessBase64ImageType('T')).toBe('image/tiff');
|
||||||
|
expect(guessBase64ImageType('V')).toBe('image/vnd.microsoft.icon');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return default type for unknown first characters', () => {
|
||||||
|
expect(guessBase64ImageType('xyz')).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType('123')).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType('ABC')).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty or invalid input', () => {
|
||||||
|
expect(guessBase64ImageType('')).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType(null as any)).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType(undefined as any)).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType(123 as any)).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle invalid base64 strings', () => {
|
||||||
|
// Should fallback to first character mapping
|
||||||
|
expect(guessBase64ImageType('!!!invalid!!!')).toBe('image/jpeg');
|
||||||
|
expect(guessBase64ImageType('not-base64')).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should prefer buffer detection over first character mapping', () => {
|
||||||
|
// Create a valid JPEG base64 that starts with 'i' (which would map to PNG)
|
||||||
|
// But the actual content is JPEG
|
||||||
|
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0]);
|
||||||
|
const base64 = jpegBytes.toString('base64'); // This is "/9j/4A=="
|
||||||
|
expect(guessBase64ImageType(base64)).toBe('image/jpeg');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getContentTypeFromHeader } from '@fastgpt/service/common/file/utils';
|
||||||
|
|
||||||
|
describe('getContentTypeFromHeader', () => {
|
||||||
|
it('should extract and normalize content type from header', () => {
|
||||||
|
expect(getContentTypeFromHeader('image/jpeg')).toBe('image/jpeg');
|
||||||
|
expect(getContentTypeFromHeader('image/png; charset=utf-8')).toBe('image/png');
|
||||||
|
expect(getContentTypeFromHeader('text/html; charset=UTF-8')).toBe('text/html');
|
||||||
|
expect(getContentTypeFromHeader('application/json;charset=utf-8')).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle uppercase content types and convert to lowercase', () => {
|
||||||
|
expect(getContentTypeFromHeader('Image/JPEG')).toBe('image/jpeg');
|
||||||
|
expect(getContentTypeFromHeader('IMAGE/PNG')).toBe('image/png');
|
||||||
|
expect(getContentTypeFromHeader('Application/JSON')).toBe('application/json');
|
||||||
|
expect(getContentTypeFromHeader('TEXT/HTML')).toBe('text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed case content types', () => {
|
||||||
|
expect(getContentTypeFromHeader('Image/Jpeg')).toBe('image/jpeg');
|
||||||
|
expect(getContentTypeFromHeader('IMAGE/png; charset=UTF-8')).toBe('image/png');
|
||||||
|
expect(getContentTypeFromHeader('Application/Json')).toBe('application/json');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should trim whitespace', () => {
|
||||||
|
expect(getContentTypeFromHeader(' image/jpeg ')).toBe('image/jpeg');
|
||||||
|
expect(getContentTypeFromHeader(' image/png ; charset=utf-8 ')).toBe('image/png');
|
||||||
|
expect(getContentTypeFromHeader('text/html ;charset=UTF-8')).toBe('text/html');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty or undefined input', () => {
|
||||||
|
// Empty string after processing results in empty string, not undefined
|
||||||
|
expect(getContentTypeFromHeader('')).toBe('');
|
||||||
|
expect(getContentTypeFromHeader(undefined as any)).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle content types with multiple parameters', () => {
|
||||||
|
expect(getContentTypeFromHeader('image/jpeg; charset=utf-8; boundary=something')).toBe(
|
||||||
|
'image/jpeg'
|
||||||
|
);
|
||||||
|
expect(getContentTypeFromHeader('multipart/form-data; boundary=----WebKit')).toBe(
|
||||||
|
'multipart/form-data'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle content types without parameters', () => {
|
||||||
|
expect(getContentTypeFromHeader('image/webp')).toBe('image/webp');
|
||||||
|
expect(getContentTypeFromHeader('image/gif')).toBe('image/gif');
|
||||||
|
expect(getContentTypeFromHeader('image/svg+xml')).toBe('image/svg+xml');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special image formats', () => {
|
||||||
|
expect(getContentTypeFromHeader('image/x-icon')).toBe('image/x-icon');
|
||||||
|
expect(getContentTypeFromHeader('image/vnd.microsoft.icon')).toBe('image/vnd.microsoft.icon');
|
||||||
|
expect(getContentTypeFromHeader('image/heic')).toBe('image/heic');
|
||||||
|
expect(getContentTypeFromHeader('image/avif')).toBe('image/avif');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge cases with semicolons', () => {
|
||||||
|
expect(getContentTypeFromHeader('image/jpeg;')).toBe('image/jpeg');
|
||||||
|
expect(getContentTypeFromHeader('image/png;;')).toBe('image/png');
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue