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