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.
 
 
 

277 lines
11 KiB

"use strict";
const superagent = require("superagent");
const http = require('http');
const https = require('https');
const crypto = require('crypto');
const { getFastGptToken } = require("../utils/fastgptToken");
const { buildFastgptEvents, reportUsage } = require('../services/dashboardReporter');
const ANONYMOUS_CLIENT_ANALYTICS = Object.freeze({
'nanchangguidao-zay': {
applicationId: 'ext-rail-transit-zay-chat',
apiKeyId: 'fastgpt-nanchang-guidao-zay',
},
});
const getHeader = (ctx, name) => ctx.headers?.[name.toLowerCase()] || '';
const resolveAnalyticsApiKeyId = (config, appKey) => {
const value = String(appKey || '').trim();
if (!value) return '';
try {
const mapping = typeof config?.analytics?.fastgptKeyIds === 'string'
? JSON.parse(config.analytics.fastgptKeyIds)
: config?.analytics?.fastgptKeyIds || {};
const hash = crypto.createHash('sha256').update(value).digest('hex');
return String(mapping[hash] || '').trim();
} catch (error) {
return '';
}
};
const resolveAnalyticsUserId = (ctx) => String(getHeader(ctx, 'x-ai-center-user-id')).trim().slice(0, 64);
const parseSsePayload = (block) => {
const lines = String(block).split(/\r?\n/);
const event = lines.find((line) => line.startsWith('event:'))?.slice(6).trim() || '';
const data = lines.filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trim()).join('\n');
if (!data || data === '[DONE]') return null;
try { return { event, data: JSON.parse(data) }; } catch (error) { return null; }
};
const proxyFastgptStream = (ctx, { url, body, headers, applicationId, userId, apiKeyId, reportAnonymous = false }) => new Promise((resolve, reject) => {
const target = new URL(url);
const requestBody = JSON.stringify(body);
const invocationId = crypto.randomUUID();
const client = target.protocol === 'https:' ? https : http;
const request = client.request({
protocol: target.protocol, hostname: target.hostname, port: target.port,
path: `${target.pathname}${target.search}`, method: 'POST',
headers: { ...headers, 'Content-Length': Buffer.byteLength(requestBody), Accept: 'text/event-stream' },
}, (response) => {
const status = response.statusCode || 500;
if (status < 200 || status >= 300) {
const chunks = []; response.on('data', (chunk) => chunks.push(chunk));
response.on('end', () => reject(new Error(Buffer.concat(chunks).toString('utf8') || 'FastGPT 请求失败')));
return;
}
ctx.respond = false;
ctx.res.writeHead(status, { 'Content-Type': response.headers['content-type'] || 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no' });
let buffer = ''; let responseData = null;
response.on('data', (chunk) => {
const text = chunk.toString('utf8'); ctx.res.write(chunk); buffer += text;
const blocks = buffer.split(/\n\n/); buffer = blocks.pop() || '';
blocks.forEach((block) => {
const payload = parseSsePayload(block);
if (payload?.data?.responseData) responseData = payload.data.responseData;
else if (payload?.event === 'flowResponses') responseData = payload.data;
});
});
response.on('end', () => {
const finalPayload = parseSsePayload(buffer);
if (finalPayload?.data?.responseData) responseData = finalPayload.data.responseData;
else if (finalPayload?.event === 'flowResponses') responseData = finalPayload.data;
ctx.res.end();
if (responseData && applicationId && (userId || reportAnonymous)) {
const events = buildFastgptEvents({
responseData, applicationId, userId, traceId: body.chatId,
invocationId, occurredAt: new Date().toISOString(), apiKeyId,
});
reportUsage(ctx.app.fs.config, events, ctx.logger);
}
resolve();
});
response.on('error', reject);
});
request.on('error', reject);
ctx.req.on('aborted', () => request.destroy());
request.write(requestBody); request.end();
});
module.exports.token = async (ctx, next) => {
try {
const { status, forceRefresh } = ctx.request.query;
const fastgptToken = await getFastGptToken(ctx.app.fs.config, forceRefresh === "true", status);
ctx.status = 200;
ctx.body = fastgptToken;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error == "string" ? error : "获取fastgpt Token失败",
};
}
};
module.exports.listDatasets = async (ctx, next) => {
try {
const config = ctx.app.fs.config.fastGpt;
const apiUrl = config.apiUrl;
// 使用应用 API key
const appKey = config.appKey || config.xiaoshangAppKey;
const parentId = ctx.request.query.parentId || "";
const res = await superagent
.post(`${apiUrl}/api/core/dataset/list?parentId=${encodeURIComponent(parentId)}`)
.set("Authorization", `Bearer ${appKey}`)
.set("Content-Type", "application/json")
.send({})
.timeout(10000);
const body = res.body || {};
if (body.code === 200 && Array.isArray(body.data)) {
ctx.body = body.data.map(d => ({ _id: d._id, name: d.name }));
} else {
ctx.body = [];
}
ctx.status = 200;
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: "获取知识库列表失败" };
}
};
module.exports.uploadToDataset = async (ctx, next) => {
try {
const { url, dataset_id, file_name, file_type } = ctx.request.body || {};
if (!url || !dataset_id) {
ctx.status = 400;
ctx.body = { message: "缺少必要参数" };
return;
}
const config = ctx.app.fs.config.fastGpt;
const apiKey = config.appKey || config.xiaoshangAppKey;
const apiUrl = config.apiUrl;
// 1. 从 Qiniu 下载文件
const fileResp = await superagent
.get(url)
.responseType("buffer")
.timeout(30000);
const fileName = file_name || `file.${file_type || "bin"}`;
// URL 编码文件名,避免中文乱码
const encodedFileName = encodeURIComponent(fileName);
// 2. 上传到 FastGPT 知识库
const uploadResp = await superagent
.post(`${apiUrl}/api/core/dataset/collection/create/localFile`)
.set("Authorization", `Bearer ${apiKey}`)
.attach("file", fileResp.body, { filename: encodedFileName })
.field("data", JSON.stringify({
datasetId: dataset_id,
parentId: null,
trainingType: "chunk",
chunkSize: 512,
}))
.timeout(60000);
const body = uploadResp.body || {};
if (body.code === 200) {
ctx.body = { status: "success", collectionId: body.data?.collectionId };
} else {
ctx.status = 400;
ctx.body = { message: body.message || "上传到知识库失败" };
}
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: "上传到知识库失败" };
}
};
module.exports.getConfig = async (ctx, next) => {
try {
const { clientId } = ctx.request.query;
const { fsAiClient } = ctx.app.fs.config;
if (!clientId || !fsAiClient[clientId]) {
throw "clientId不合法";
}
ctx.status = 200;
ctx.body = fsAiClient[clientId];
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error == "string" ? error : "获取client配置失败",
};
}
};
module.exports.chat = async (ctx, next) => {
try {
const { clientId, appKey, analyticsApplicationId } = ctx.request.query;
const { fsAiClient } = ctx.app.fs.config;
const body = ctx.request.body;
if (!clientId || !fsAiClient[clientId]) {
throw "clientId不合法";
}
if (fsAiClient[clientId].identity && body) {
if (body.variables) {
body.variables.identity = fsAiClient[clientId].identity;
} else {
body.variables = { identity: fsAiClient[clientId].identity };
}
}
body.detail = true;
const anonymousAnalytics = ANONYMOUS_CLIENT_ANALYTICS[clientId];
const userId = anonymousAnalytics ? '' : resolveAnalyticsUserId(ctx);
// Public clients must use their server-configured App Key. Browser query
// parameters must not be able to change the reported API Key ownership.
const effectiveAppKey = String(anonymousAnalytics ? fsAiClient[clientId].appKey : (appKey || fsAiClient[clientId].appKey || '')).trim();
await proxyFastgptStream(ctx, {
url: `${ctx.app.fs.config.fastGpt.apiUrl}/api/v1/chat/completions`, body,
headers: { Authorization: `Bearer ${effectiveAppKey}`, 'Content-Type': 'application/json' },
applicationId: anonymousAnalytics?.applicationId || String(analyticsApplicationId || 'superagent-chat'), userId,
apiKeyId: anonymousAnalytics?.apiKeyId || resolveAnalyticsApiKeyId(ctx.app.fs.config, effectiveAppKey),
reportAnonymous: Boolean(anonymousAnalytics),
});
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = {
message: typeof error == "string" ? error : "chat失败",
};
}
};
// The industry-plan page used to send its FastGPT key from the browser. Keep
// the key server-side and reuse the audited streaming proxy instead.
module.exports.solutionChat = async (ctx, next) => {
try {
const body = ctx.request.body || {};
body.detail = true;
const userId = resolveAnalyticsUserId(ctx);
const appKey = String(ctx.app.fs.config.fastGpt?.solutionAppKey || '').trim();
if (!appKey) throw '未配置 fastGpt.solutionAppKey';
await proxyFastgptStream(ctx, {
url: `${ctx.app.fs.config.fastGpt.apiUrl}/api/v1/chat/completions`, body,
headers: { Authorization: `Bearer ${appKey}`, 'Content-Type': 'application/json' },
applicationId: 'stable-industry', userId,
apiKeyId: resolveAnalyticsApiKeyId(ctx.app.fs.config, appKey),
});
} catch (error) {
ctx.logger.log(error);
ctx.status = 400;
ctx.body = { message: typeof error === 'string' ? error : '行业方案对话失败' };
}
};
module.exports.solutionQuoteData = async (ctx, next) => {
try {
const quoteId = String(ctx.request.body?.id || '').trim();
const appKey = String(ctx.app.fs.config.fastGpt?.solutionAppKey || '').trim();
if (!quoteId) throw '缺少引用 ID';
if (!appKey) throw '未配置 fastGpt.solutionAppKey';
const res = await superagent
.post(`${ctx.app.fs.config.fastGpt.apiUrl}/api/core/dataset/data/getQuoteData`)
.send({ id: quoteId })
.set({ Authorization: `Bearer ${appKey}`, 'Content-Type': 'application/json' })
.timeout(10000);
ctx.status = res.status || 200;
ctx.body = res.body;
} catch (error) {
ctx.logger.log(error);
ctx.status = error?.status || error?.response?.status || 400;
ctx.body = error?.response?.body || { message: typeof error === 'string' ? error : '获取引用详情失败' };
}
};