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
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 : '获取引用详情失败' };
|
|
}
|
|
};
|
|
|