ai-query对接新版freesun-agent接口的分支
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

480 lines
19 KiB

'use strict';
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const qiniu = require('qiniu');
const superagent = require('superagent');
const PROJECT_NAME = '飞小尚';
const CATEGORY = 'other';
const SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']);
function getCurrentUserId(ctx) {
const value = ctx.fs?.curUser?.userInfo?.id;
if (!/^\d+$/.test(String(value || ''))) {
const error = new Error('当前登录用户缺少 ai-center user id');
error.status = 401;
throw error;
}
return Number(value);
}
function getFileExtension(fileName) {
return path.extname(String(fileName || '')).slice(1).toLowerCase();
}
function toVectorLiteral(vector) {
if (!Array.isArray(vector) || vector.length !== 1024 || vector.some(value => !Number.isFinite(value))) {
throw new Error('embedding 返回的向量不是有效的 1024 维数值数组');
}
return `[${vector.join(',')}]`;
}
function normalizeDescription(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function getDescriptionMode(value) {
return value === 'ai' ? 'ai' : 'manual';
}
function assertConfiguration(ctx, { needsFastGpt = false, needsQiniu = false, needsEmbedding = false } = {}) {
const { qiniu: qiniuConfig, imageAssets, fastGpt } = ctx.config;
if (needsQiniu && (!qiniuConfig?.dmn || !qiniuConfig?.bkt || !qiniuConfig?.ak || !qiniuConfig?.sk)) {
throw new Error('七牛配置不完整');
}
if (needsEmbedding && (!imageAssets?.databaseUrl || !imageAssets?.dashscopeApiKey || imageAssets.embeddingDimensions !== 1024)) {
throw new Error('图片资源库 embedding 配置不完整');
}
if (needsFastGpt && (!fastGpt?.apiUrl || !fastGpt?.v2TenderImageAppKey)) {
throw new Error('图片 AI 描述 FastGPT 配置不完整');
}
}
function buildQiniuKey(fileName, userId) {
const extension = getFileExtension(fileName);
const safeName = path.basename(String(fileName || 'image'))
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 80);
return `ai-query/resource-library/${userId}/${Date.now()}-${crypto.randomUUID()}-${safeName || `image.${extension}`}`;
}
async function uploadFileToQiniu(ctx, file, userId, fileName) {
const { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } = ctx.config.qiniu;
const mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket });
const uploaderConfig = new qiniu.conf.Config();
uploaderConfig.zone = qiniu.zone.Zone_z0;
const uploader = new qiniu.form_up.FormUploader(uploaderConfig);
const key = buildQiniuKey(fileName || file.originalname, userId);
const result = await new Promise((resolve, reject) => {
uploader.putFile(putPolicy.uploadToken(mac), key, file.path, new qiniu.form_up.PutExtra(), (error, body) => {
if (error) reject(error);
else resolve(body);
});
});
return `${String(domain).replace(/\/+$/, '')}/${result.key}`;
}
async function generateAiDescription(ctx, imageUrl) {
const { apiUrl, v2TenderImageAppKey } = ctx.config.fastGpt;
const response = await superagent
.post(`${String(apiUrl).replace(/\/+$/, '')}/api/v1/chat/completions`)
.send({
stream: false,
detail: false,
messages: [{
role: 'user',
content: [{ type: 'image_url', image_url: { url: imageUrl } }],
}],
})
.set({ Authorization: `Bearer ${v2TenderImageAppKey}`, 'Content-Type': 'application/json' });
const description = normalizeDescription(response.body?.choices?.[0]?.message?.content);
if (!description) throw new Error('AI 未生成有效图片描述');
return description;
}
async function generateEmbedding(ctx, summary) {
const { dashscopeBaseUrl, dashscopeApiKey, embeddingModel, requestTimeout } = ctx.config.imageAssets;
const response = await superagent
.post(`${String(dashscopeBaseUrl).replace(/\/+$/, '')}/embeddings`)
.send({ model: embeddingModel, input: summary, dimensions: 1024 })
.set({ Authorization: `Bearer ${dashscopeApiKey}`, 'Content-Type': 'application/json' })
.timeout(requestTimeout);
return toVectorLiteral(response.body?.data?.[0]?.embedding);
}
function ensureImageFile(file, fileName = file?.originalname) {
if (!file) throw new Error('请选择图片文件');
const extension = getFileExtension(fileName);
if (!SUPPORTED_EXTENSIONS.has(extension) || !String(file.mimetype || '').startsWith('image/')) {
throw new Error('仅支持 JPG、JPEG、PNG、WEBP 图片');
}
}
function toAsset(row) {
return {
id: row.id,
imageName: row.image_name,
summary: row.summary,
url: row.url,
fileFormat: row.file_format,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
async function withUploadCleanup(file, callback) {
try {
return await callback();
} finally {
if (file?.path) fs.promises.unlink(file.path).catch(() => {});
}
}
async function resolveAssetPayload(ctx, userId, existing) {
const file = ctx.file || ctx.request.file;
// multipart 的 filename 头可能被服务端按 Latin-1 解码;前端额外提交的
// fileName 字段保持 UTF-8,优先使用它作为资源显示名和扩展名来源。
const uploadedFileName = file
? normalizeFileName(ctx.request.body?.fileName || file.originalname)
: null;
const mode = getDescriptionMode(ctx.request.body?.descriptionMode);
const manualDescription = normalizeDescription(ctx.request.body?.description);
if (file) ensureImageFile(file, uploadedFileName);
if (!existing && !file) throw new Error('请选择图片文件');
if (mode === 'manual' && !manualDescription) throw new Error('请输入图片描述');
assertConfiguration(ctx, { needsFastGpt: mode === 'ai', needsQiniu: Boolean(file), needsEmbedding: true });
const url = file ? await uploadFileToQiniu(ctx, file, userId, uploadedFileName) : existing.url;
const summary = mode === 'ai' ? await generateAiDescription(ctx, url) : manualDescription;
const vector = await generateEmbedding(ctx, summary);
return {
imageName: file ? uploadedFileName : existing.image_name,
url,
fileFormat: file ? getFileFormat(uploadedFileName) : existing.file_format,
summary,
vector,
};
}
module.exports.listImages = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const result = await ctx.app.fs.imageAssetsDb.query(
`SELECT id, image_name, summary, url, file_format, created_at, updated_at
FROM image_assets
WHERE project_name = $1 AND category = $2 AND ai_center_user_id = $3
ORDER BY created_at DESC`,
[PROJECT_NAME, CATEGORY, userId],
);
ctx.body = { list: result.rows.map(toAsset) };
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '获取资源库图片失败');
}
};
module.exports.getImage = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const result = await ctx.app.fs.imageAssetsDb.query(
`SELECT id, image_name, summary, url, file_format, created_at, updated_at
FROM image_assets
WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4`,
[ctx.params.id, PROJECT_NAME, CATEGORY, userId],
);
if (!result.rows[0]) ctx.throw(404, '图片资源不存在');
ctx.body = toAsset(result.rows[0]);
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '获取资源库图片失败');
}
};
module.exports.createImage = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => {
try {
const userId = getCurrentUserId(ctx);
const asset = await resolveAssetPayload(ctx, userId, null);
const result = await ctx.app.fs.imageAssetsDb.query(
`INSERT INTO image_assets (
image_name, summary, summary_vector, embedding_status, url, file_format, category, project_name,
ai_center_user_id, last_synced_at, created_at, updated_at
) VALUES ($1, $2, $3::vector, 'completed', $4, $5, $6, $7, $8, NOW(), NOW(), NOW())
RETURNING id, image_name, summary, url, file_format, created_at, updated_at`,
[asset.imageName, asset.summary, asset.vector, asset.url, asset.fileFormat, CATEGORY, PROJECT_NAME, userId],
);
ctx.status = 201;
ctx.body = toAsset(result.rows[0]);
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '创建资源库图片失败');
}
});
module.exports.updateImage = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => {
try {
const userId = getCurrentUserId(ctx);
const existingResult = await ctx.app.fs.imageAssetsDb.query(
`SELECT id, image_name, summary, url, file_format
FROM image_assets
WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4`,
[ctx.params.id, PROJECT_NAME, CATEGORY, userId],
);
const existing = existingResult.rows[0];
if (!existing) ctx.throw(404, '图片资源不存在');
const asset = await resolveAssetPayload(ctx, userId, existing);
const result = await ctx.app.fs.imageAssetsDb.query(
`UPDATE image_assets
SET image_name = $1, summary = $2, summary_vector = $3::vector, embedding_status = 'completed',
url = $4, file_format = $5, embedding_error = NULL, last_synced_at = NOW(), updated_at = NOW()
WHERE id = $6 AND project_name = $7 AND category = $8 AND ai_center_user_id = $9
RETURNING id, image_name, summary, url, file_format, created_at, updated_at`,
[asset.imageName, asset.summary, asset.vector, asset.url, asset.fileFormat, existing.id, PROJECT_NAME, CATEGORY, userId],
);
ctx.body = toAsset(result.rows[0]);
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '更新资源库图片失败');
}
});
module.exports.deleteImage = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const result = await ctx.app.fs.imageAssetsDb.query(
`DELETE FROM image_assets
WHERE id = $1 AND project_name = $2 AND category = $3 AND ai_center_user_id = $4
RETURNING id`,
[ctx.params.id, PROJECT_NAME, CATEGORY, userId],
);
if (!result.rows[0]) ctx.throw(404, '图片资源不存在');
ctx.status = 204;
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '删除资源库图片失败');
}
};
const FILE_PROJECT_NAME = '飞小尚';
const FILE_SOURCE_TYPES = new Set(['chat_upload', 'tender_export']);
const FILE_MAX_SIZE = 100 * 1024 * 1024;
function getResourceFileModel(ctx) {
const model = ctx.app.fs.dc?.models?.ResourceLibraryFile;
if (!model) throw new Error('资源库文件模型未加载');
return model;
}
function normalizeFileName(value) {
const name = path.basename(String(value || '').trim());
return name || '未命名文件';
}
function normalizeSha256(value) {
const sha256 = String(value || '').trim().toLowerCase();
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error('文件 SHA-256 格式不正确');
return sha256;
}
function getFileFormat(fileName) {
const extension = getFileExtension(fileName);
return extension || null;
}
function toFileAsset(row) {
const value = row?.get ? row.get({ plain: true }) : row;
return {
id: value.id,
fileName: value.fileName || value.file_name,
fileFormat: value.fileFormat || value.file_format,
mimeType: value.mimeType || value.mime_type,
fileSize: value.fileSize ?? value.file_size,
url: value.url,
objectKey: value.objectKey || value.object_key,
storageProvider: value.storageProvider || value.storage_provider,
sourceType: value.sourceType || value.source_type,
createdAt: value.createdAt || value.created_at,
updatedAt: value.updatedAt || value.updated_at,
};
}
function assertFileSourceType(sourceType) {
if (!FILE_SOURCE_TYPES.has(sourceType)) throw new Error('文件来源类型不正确');
return sourceType;
}
async function findOrCreateFileAsset(ctx, userId, payload) {
const ResourceLibraryFile = getResourceFileModel(ctx);
const sourceType = assertFileSourceType(payload.sourceType);
const normalizedPayload = { ...payload, sourceType };
const where = {
aiCenterUserId: userId,
projectName: FILE_PROJECT_NAME,
sha256: normalizedPayload.sha256,
};
const existing = await ResourceLibraryFile.findOne({ where });
if (existing) return { asset: existing, created: false };
try {
const [asset, created] = await ResourceLibraryFile.findOrCreate({
where: { aiCenterUserId: userId, sha256: normalizedPayload.sha256 },
defaults: { ...normalizedPayload, aiCenterUserId: userId, projectName: FILE_PROJECT_NAME },
});
return { asset, created };
} catch (error) {
if (error?.name === 'SequelizeUniqueConstraintError') {
const duplicate = await ResourceLibraryFile.findOne({ where });
if (duplicate) return { asset: duplicate, created: false };
}
throw error;
}
}
function buildResourceFileKey(fileName, userId) {
const safeName = normalizeFileName(fileName)
.replace(/[^a-zA-Z0-9._-]/g, '_')
.slice(0, 100) || 'file';
return `ai-query/resource-library/files/${userId}/${Date.now()}-${crypto.randomUUID()}-${safeName}`;
}
async function uploadResourceFileToQiniu(ctx, file, userId, fileName) {
const { dmn: domain, bkt: bucket, ak: accessKey, sk: secretKey } = ctx.config.qiniu || {};
if (!domain || !bucket || !accessKey || !secretKey) throw new Error('七牛配置不完整');
const mac = new qiniu.auth.digest.Mac(accessKey, secretKey);
const putPolicy = new qiniu.rs.PutPolicy({ scope: bucket });
const uploaderConfig = new qiniu.conf.Config();
uploaderConfig.zone = qiniu.zone.Zone_z0;
const uploader = new qiniu.form_up.FormUploader(uploaderConfig);
const key = buildResourceFileKey(fileName || file.originalname, userId);
const result = await new Promise((resolve, reject) => {
uploader.putFile(
putPolicy.uploadToken(mac),
key,
file.path,
new qiniu.form_up.PutExtra(),
(error, body) => error ? reject(error) : resolve(body),
);
});
return {
url: `${String(domain).replace(/\/+$/, '')}/${result.key}`,
objectKey: result.key,
};
}
async function hashFile(filePath) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (chunk) => hash.update(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(hash.digest('hex')));
});
}
function parseUploadPayload(ctx) {
const body = ctx.request.body || {};
const url = String(body.path || body.url || '').trim();
const fileName = normalizeFileName(body.original_filename || body.originalFilename || body.file_name);
if (!url) throw new Error('上传结果缺少文件链接');
return {
fileName,
fileFormat: getFileFormat(fileName),
mimeType: String(body.content_type || body.contentType || '').trim() || null,
fileSize: body.size_bytes == null ? null : Number(body.size_bytes),
url,
objectKey: String(body.object_key || body.objectKey || '').trim() || null,
storageProvider: String(body.storage_provider || body.storageProvider || 'qiniu').trim() || 'qiniu',
sha256: normalizeSha256(body.sha256),
sourceType: 'chat_upload',
sourceUploadId: String(body.id || '').trim() || null,
sourceTaskId: String(body.task_id || body.taskId || '').trim() || null,
sourceSessionId: String(body.uploaded_in_session_id || body.sessionId || '').trim() || null,
};
}
module.exports.listFiles = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const ResourceLibraryFile = getResourceFileModel(ctx);
const rows = await ResourceLibraryFile.findAll({
where: { aiCenterUserId: userId, projectName: FILE_PROJECT_NAME },
order: [['createdAt', 'DESC']],
});
ctx.body = { list: rows.map(toFileAsset) };
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '获取资源库文件失败');
}
};
module.exports.createFileFromUpload = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const payload = parseUploadPayload(ctx);
const result = await findOrCreateFileAsset(ctx, userId, payload);
ctx.status = result.created ? 201 : 200;
ctx.body = { ...toFileAsset(result.asset), created: result.created };
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '保存资源库文件失败');
}
};
module.exports.uploadFile = async (ctx) => withUploadCleanup(ctx.file || ctx.request.file, async () => {
try {
const userId = getCurrentUserId(ctx);
const file = ctx.file || ctx.request.file;
if (!file?.path) throw new Error('请上传文件');
if (!file.size || file.size > FILE_MAX_SIZE) throw new Error('文件大小必须在 1B 至 100MB 之间');
const fileName = normalizeFileName(ctx.request.body?.fileName || file.originalname);
const sha256 = await hashFile(file.path);
const ResourceLibraryFile = getResourceFileModel(ctx);
const existing = await ResourceLibraryFile.findOne({
where: { aiCenterUserId: userId, projectName: FILE_PROJECT_NAME, sha256 },
});
if (existing) {
ctx.body = { ...toFileAsset(existing), created: false };
return;
}
const uploaded = await uploadResourceFileToQiniu(ctx, file, userId, fileName);
const result = await findOrCreateFileAsset(ctx, userId, {
fileName,
fileFormat: getFileFormat(fileName),
mimeType: String(file.mimetype || '').trim() || null,
fileSize: Number(file.size),
url: uploaded.url,
objectKey: uploaded.objectKey,
storageProvider: 'qiniu',
sha256,
sourceType: 'tender_export',
sourceUploadId: null,
sourceTaskId: String(ctx.request.body?.taskId || '').trim() || null,
sourceSessionId: String(ctx.request.body?.sessionId || '').trim() || null,
});
ctx.status = result.created ? 201 : 200;
ctx.body = { ...toFileAsset(result.asset), created: result.created };
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '上传资源库文件失败');
}
});
module.exports.deleteFile = async (ctx) => {
try {
const userId = getCurrentUserId(ctx);
const ResourceLibraryFile = getResourceFileModel(ctx);
const count = await ResourceLibraryFile.destroy({
where: {
id: ctx.params.id,
aiCenterUserId: userId,
projectName: FILE_PROJECT_NAME,
},
});
if (!count) ctx.throw(404, '文件资源不存在');
ctx.status = 204;
} catch (error) {
ctx.logger.error(error);
ctx.throw(error.status || 400, error.message || '删除资源库文件失败');
}
};