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.
 
 
 

970 lines
29 KiB

'use strict';
const crypto = require('crypto');
const superagent = require('superagent');
const fsPromises = require('fs/promises');
const { URL } = require('url');
const path = require('path');
const WebSocket = require('ws');
/**
* [生成CheckSum签名]
* @param {string} appSecret - 应用密钥
* @param {string} nonce - 随机字符串
* @param {string} curTime - 当前UTC时间戳
* @returns {string} - SHA256加密后的CheckSum
*/
function generateCheckSum(appSecret, nonce, curTime) {
const content = `${appSecret}${nonce}${curTime}`;
return crypto.createHash('sha256').update(content).digest('hex');
}
function getWidgetTypeByUrl(url) {
const lowerUrl = String(url || '').toLowerCase();
const cleanUrl = lowerUrl.split('?')[0];
if (/\.(jpg|jpeg|png|gif|bmp|webp)$/i.test(cleanUrl)) {
return 'PICTURE';
}
if (/\.(mp4|mov|avi|wmv|flv|mkv|webm|m4v)$/i.test(cleanUrl)) {
return 'VIDEO';
}
if (/^(rtsp|rtmp):\/\//i.test(lowerUrl) || /\.(m3u8|ts)$/i.test(cleanUrl)) {
return 'STREAM_MEDIA';
}
return 'HTML';
}
async function getMediaMetadata(url) {
try {
const res = await superagent
.get(url)
.buffer(true)
.timeout(30000);
const contentBuffer = Buffer.isBuffer(res.body) ? res.body : Buffer.from(res.text || '', 'utf8');
return {
size: contentBuffer.length,
md5: crypto.createHash('md5').update(contentBuffer).digest('hex')
};
} catch (error) {
return {
size: 0,
md5: '00000000000000000000000000000000'
};
}
}
function buildXfyunAuthUrl(hostUrl, apiKey, apiSecret) {
const url = new URL(hostUrl);
const date = new Date().toUTCString();
const signatureOrigin = `host: ${url.host}\ndate: ${date}\nGET ${url.pathname} HTTP/1.1`;
const signature = crypto
.createHmac('sha256', apiSecret)
.update(signatureOrigin)
.digest('base64');
const authorizationOrigin = `api_key="${apiKey}", algorithm="hmac-sha256", headers="host date request-line", signature="${signature}"`;
const authorization = Buffer.from(authorizationOrigin).toString('base64');
url.searchParams.set('authorization', authorization);
url.searchParams.set('date', date);
url.searchParams.set('host', url.host);
return url.toString();
}
function getXfyunText(result) {
return (result?.ws || [])
.map(item => item?.cw?.[0]?.w || '')
.join('');
}
function getSpeechAudioOptions(file, body = {}) {
const ext = path.extname(file?.originalname || '').toLowerCase();
const encoding = body.encoding || (ext === '.mp3' ? 'lame' : 'raw');
const format = body.format || 'audio/L16;rate=16000';
const supportedEncodings = ['raw', 'lame', 'speex', 'speex-wb'];
if (!supportedEncodings.includes(encoding)) {
throw '不支持的音频编码,仅支持 raw、lame、speex、speex-wb';
}
return { encoding, format };
}
async function recognizeSpeechByXfyun(audioBuffer, config, audioOptions) {
const { appId, apiKey, apiSecret, hostUrl } = config;
const authUrl = buildXfyunAuthUrl(hostUrl, apiKey, apiSecret);
const frameSize = 1280;
const finalResults = [];
const { encoding, format } = audioOptions;
return new Promise((resolve, reject) => {
const socket = new WebSocket(authUrl);
let offset = 0;
let closed = false;
const fail = (error) => {
if (!closed) {
closed = true;
try {
socket.close();
} catch (_) {
// ignore close error
}
}
reject(error);
};
const sendNextFrame = () => {
if (offset >= audioBuffer.length) {
socket.send(JSON.stringify({
data: {
status: 2,
format,
encoding,
audio: ''
}
}));
return;
}
const chunk = audioBuffer.subarray(offset, offset + frameSize);
const status = offset === 0 ? 0 : 1;
const payload = {
data: {
status,
format,
encoding,
audio: chunk.toString('base64')
}
};
if (status === 0) {
payload.common = { app_id: appId };
payload.business = {
language: 'zh_cn',
domain: 'iat',
accent: 'mandarin',
vad_eos: 10000
};
}
socket.send(JSON.stringify(payload));
offset += frameSize;
setTimeout(sendNextFrame, 40);
};
socket.addEventListener('open', () => {
sendNextFrame();
});
socket.addEventListener('message', (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (error) {
fail(new Error('讯飞语音识别返回数据格式错误'));
return;
}
if (message.code !== 0) {
fail(new Error(message.message || '讯飞语音识别失败'));
return;
}
if (message.data?.result) {
finalResults[message.data.result.sn] = getXfyunText(message.data.result);
}
if (message.data?.status === 2) {
closed = true;
socket.close();
resolve(finalResults.filter(Boolean).join(''));
}
});
socket.addEventListener('error', () => {
fail(new Error('讯飞语音识别连接失败'));
});
});
}
function callVoiceFastGpt(config, text) {
return superagent
.post(`${config.apiUrl}/api/v1/chat/completions`)
.send({
stream: false,
detail: false,
messages: [{
role: 'user',
content: [{
type: 'text',
text
}]
}]
})
.set({
Authorization: `Bearer ${config.voiceAppKey}`,
'Content-Type': 'application/json'
})
.timeout(60000);
}
module.exports.setupVoiceStream = (app) => {
app.fs.socket.on('connection', (clientSocket, req) => {
if (req.url !== '/voice/stream') {
return;
}
app.fs.logger.info('[voice stream] client connected');
const { xfyunSpeech, fastGpt } = app.fs.config;
if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) {
clientSocket.send(JSON.stringify({ type: 'error', message: '讯飞语音识别配置未正确设置' }));
clientSocket.close();
return;
}
if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) {
clientSocket.send(JSON.stringify({ type: 'error', message: 'FastGPT语音应用配置未正确设置' }));
clientSocket.close();
return;
}
const authUrl = buildXfyunAuthUrl(xfyunSpeech.hostUrl, xfyunSpeech.apiKey, xfyunSpeech.apiSecret);
const xfyunSocket = new WebSocket(authUrl);
const results = [];
const pendingAudioChunks = [];
let started = false;
let ended = false;
const totalStartedAt = Date.now();
let speechEndedAt = null;
const idleTimer = setTimeout(() => {
fail('流式语音识别超时');
}, 30000);
const sendToXfyun = (status, audio = '') => {
const payload = {
data: {
status,
format: 'audio/L16;rate=16000',
encoding: 'raw',
audio
}
};
if (status === 0) {
payload.common = { app_id: xfyunSpeech.appId };
payload.business = {
language: 'zh_cn',
domain: 'iat',
accent: 'mandarin',
vad_eos: 1000
};
}
xfyunSocket.send(JSON.stringify(payload));
};
const fail = (message) => {
clearTimeout(idleTimer);
app.fs.logger.error('[voice stream] failed:', message);
if (clientSocket.readyState === 1) {
clientSocket.send(JSON.stringify({ type: 'error', message }));
}
try { clientSocket.close(); } catch (_) {}
try { xfyunSocket.close(); } catch (_) {}
};
const beginStream = () => {
started = true;
const firstChunk = pendingAudioChunks.shift();
if (!firstChunk) {
return;
}
app.fs.logger.info('[voice stream] begin stream');
sendToXfyun(0, firstChunk.toString('base64'));
pendingAudioChunks.splice(0).forEach(chunk => {
sendToXfyun(1, chunk.toString('base64'));
});
};
xfyunSocket.addEventListener('message', async (event) => {
let message;
try {
message = JSON.parse(event.data);
} catch (_) {
fail('讯飞语音识别返回数据格式错误');
return;
}
if (message.code !== 0) {
fail(message.message || '讯飞语音识别失败');
return;
}
if (message.data?.result) {
results[message.data.result.sn] = getXfyunText(message.data.result);
}
if (message.data?.status === 2) {
clearTimeout(idleTimer);
app.fs.logger.info('[voice stream] speech finished');
speechEndedAt = Date.now();
const text = results.filter(Boolean).join('');
if (!text) {
fail('未识别到有效语音文本');
return;
}
try {
const fastGptStartedAt = Date.now();
const fastGptRes = await callVoiceFastGpt(fastGpt, text);
if (clientSocket.readyState === 1) {
clientSocket.send(JSON.stringify({
type: 'result',
text,
fastGpt: fastGptRes.body,
timings: {
speechMs: speechEndedAt - totalStartedAt,
fastGptMs: Date.now() - fastGptStartedAt,
totalMs: Date.now() - totalStartedAt
}
}));
}
} catch (error) {
fail(error.message || 'FastGPT调用失败');
}
}
});
xfyunSocket.addEventListener('error', () => fail('讯飞语音识别连接失败'));
xfyunSocket.addEventListener('open', () => {
app.fs.logger.info('[voice stream] xfyun connected');
});
clientSocket.on('message', (data, isBinary) => {
if (isBinary) {
if (ended) {
return;
}
const chunk = Buffer.from(data);
if (!started) {
pendingAudioChunks.push(chunk);
if (xfyunSocket.readyState === 1) {
beginStream();
}
return;
}
if (xfyunSocket.readyState !== 1) {
pendingAudioChunks.push(chunk);
return;
}
sendToXfyun(1, chunk.toString('base64'));
return;
}
let message;
try {
message = JSON.parse(data.toString());
} catch (_) {
fail('客户端消息格式错误');
return;
}
if (message.type === 'start') {
app.fs.logger.info('[voice stream] start received');
if (xfyunSocket.readyState !== 1) {
xfyunSocket.addEventListener('open', beginStream, { once: true });
}
}
if (message.type === 'stop' && started && !ended && xfyunSocket.readyState === 1) {
app.fs.logger.info('[voice stream] stop received');
ended = true;
sendToXfyun(2, '');
}
});
clientSocket.on('close', () => {
clearTimeout(idleTimer);
app.fs.logger.info('[voice stream] client closed');
try { xfyunSocket.close(); } catch (_) {}
});
});
};
/**
* [获取播放器列表]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.getPlayerList = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
// [获取查询参数]
const { count = 20, start = 0, name } = ctx.query;
// [验证参数范围]
const validatedCount = Math.min(Math.max(parseInt(count) || 20, 1), 100);
const validatedStart = Math.max(parseInt(start) || 0, 0);
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL和参数]
const url = `${baseUrl}/v2/player/list`;
const queryParams = {
count: validatedCount,
start: validatedStart
};
if (name) {
queryParams.name = name;
}
// [发送请求到诺瓦开放平台]
const res = await superagent
.get(url)
.query(queryParams)
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('获取播放器列表失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '获取播放器列表失败'),
error: error.response?.body || error.message
};
}
};
/**
* [获取播放器详情]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.getPlayerDetail = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
const { playerId } = ctx.params;
if (!playerId) {
throw '缺少播放器ID参数';
}
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/${playerId}`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.get(url)
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('获取播放器详情失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '获取播放器详情失败'),
error: error.response?.body || error.message
};
}
};
/**
* [发送播放器命令]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.sendPlayerCommand = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
const { playerId } = ctx.params;
const { command, params } = ctx.request.body;
if (!playerId) {
throw '缺少播放器ID参数';
}
if (!command) {
throw '缺少命令参数';
}
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/${playerId}/command`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.post(url)
.send({
command,
params: params || {}
})
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('发送播放器命令失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '发送播放器命令失败'),
error: error.response?.body || error.message
};
}
};
/**
* [屏幕开启]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.openScreen = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
// [固定的播放器ID]
const playerIds = ["81ea331f7b0a456b9a57f6617634413f"];
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/real-time-control/screen-status`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.post(url)
.send({
playerIds,
status: 'OPEN'
})
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('屏幕开启失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '屏幕开启失败'),
error: error.response?.body || error.message
};
}
};
/**
* [屏幕关闭]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.closeScreen = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
// [固定的播放器ID]
const playerIds = ["81ea331f7b0a456b9a57f6617634413f"];
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/real-time-control/screen-status`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.post(url)
.send({
playerIds,
status: 'CLOSE'
})
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('屏幕关闭失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '屏幕关闭失败'),
error: error.response?.body || error.message
};
}
};
/**
* [新增播放器]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.addPlayers = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
const { sns } = ctx.request.body;
// [参数校验]
if (!sns || !Array.isArray(sns) || sns.length === 0) {
throw '缺少设备SN码集合参数';
}
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/iot-add`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.post(url)
.send({
sns
})
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('新增播放器失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '新增播放器失败'),
error: error.response?.body || error.message
};
}
};
/**
* [下发常规节目]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.publishNormalProgram = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
const { pages } = ctx.request.body || {};
// [参数校验]
if (!pages || !Array.isArray(pages) || pages.length === 0) {
throw '缺少页面参数,格式应为 pages: [{ name, url }]';
}
const invalidPage = pages.find(item => !item || !item.name || !item.url);
if (invalidPage) {
throw 'pages 中每一项都必须包含 name 和 url';
}
// [固定播放器ID集合,优先使用配置]
const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0
? novaCloud.fixedPlayerIds
: ['81ea331f7b0a456b9a57f6617634413f'];
if (fixedPlayerIds.length > 100) {
throw '固定播放器ID集合最多支持100个';
}
// [根据前端传入的 name + url 自动构造 widgets]
const normalizedPages = await Promise.all(pages.map(async (page) => {
const type = getWidgetTypeByUrl(page.url);
const { size, md5 } = await getMediaMetadata(page.url);
return {
name: page.name,
widgets: [{
type,
md5,
size,
duration: type === 'VIDEO' ? "" : 10000,
url: page.url,
layout: {
x: '0%',
y: '0%',
width: '100%',
height: '100%'
}
}]
};
}));
const requestPayload = {
playerIds: fixedPlayerIds,
pages: normalizedPages
};
// [生成请求头参数]
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
// [构建请求URL]
const url = `${baseUrl}/v2/player/program/normal`;
// [发送请求到诺瓦开放平台]
const res = await superagent
.post(url)
.send(requestPayload)
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
// [返回响应数据]
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('下发常规节目失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '下发常规节目失败'),
error: error.response?.body || error.message
};
}
};
/**
* [下发单页紧急插播节目]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.publishEmergencyProgramPage = async (ctx, next) => {
try {
const { novaCloud } = ctx.app.fs.config;
if (!novaCloud || !novaCloud.appKey || !novaCloud.appSecret) {
throw '诺瓦云平台配置未正确设置';
}
const { appKey, appSecret, baseUrl } = novaCloud;
const { name, url } = ctx.request.body || {};
if (!name || !url) {
throw '缺少页面参数,必须包含 name 和 url';
}
const fixedPlayerIds = Array.isArray(novaCloud.fixedPlayerIds) && novaCloud.fixedPlayerIds.length > 0
? novaCloud.fixedPlayerIds
: ['81ea331f7b0a456b9a57f6617634413f'];
if (fixedPlayerIds.length > 100) {
throw '固定播放器ID集合最多支持100个';
}
const type = getWidgetTypeByUrl(url);
const { size, md5 } = await getMediaMetadata(url);
const fixedDuration = 2 * 60 * 60 * 1000;
const requestPayload = {
playerIds: fixedPlayerIds,
attribute: {
spotsType: 'IMMEDIATELY',
normalProgramStatus: 'NORMAL',
duration: fixedDuration
},
page: {
name,
widgets: [{
name,
type,
md5,
size,
duration: type === 'VIDEO' ? 0 : fixedDuration,
url,
layout: {
x: '0%',
y: '0%',
width: '100%',
height: '100%'
}
}]
}
};
const nonce = crypto.randomBytes(16).toString('hex');
const curTime = Math.floor(Date.now() / 1000).toString();
const checkSum = generateCheckSum(appSecret, nonce, curTime);
const reqUrl = `${baseUrl}/v2/player/emergency-program/page`;
const res = await superagent
.post(reqUrl)
.send(requestPayload)
.set({
'AppKey': appKey,
'Nonce': nonce,
'CurTime': curTime,
'CheckSum': checkSum,
'Content-Type': 'application/json'
})
.timeout(30000);
ctx.body = res.body;
ctx.status = 200;
} catch (error) {
ctx.logger.error('下发单页紧急插播节目失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '下发单页紧急插播节目失败'),
error: error.response?.body || error.message
};
}
};
/**
* [语音识别并调用 FastGPT]
* @param {Object} ctx - Koa上下文
* @param {Object} next - 下一个中间件
*/
module.exports.voiceChat = async (ctx, next) => {
const file = ctx.request.file;
try {
const totalStartedAt = Date.now();
const { xfyunSpeech, fastGpt } = ctx.app.fs.config;
if (!xfyunSpeech?.appId || !xfyunSpeech?.apiKey || !xfyunSpeech?.apiSecret || !xfyunSpeech?.hostUrl) {
throw '讯飞语音识别配置未正确设置';
}
if (!fastGpt?.apiUrl || !fastGpt?.voiceAppKey) {
throw 'FastGPT语音应用配置未正确设置';
}
if (!file) {
throw '缺少音频文件,字段名应为 file';
}
const audioBuffer = await fsPromises.readFile(file.path);
if (!audioBuffer.length) {
throw '音频文件不能为空';
}
const audioOptions = getSpeechAudioOptions(file, ctx.request.body);
const speechStartedAt = Date.now();
const recognizedText = await recognizeSpeechByXfyun(audioBuffer, xfyunSpeech, audioOptions);
const speechElapsedMs = Date.now() - speechStartedAt;
if (!recognizedText) {
throw '未识别到有效语音文本';
}
const fastGptStartedAt = Date.now();
const fastGptRes = await callVoiceFastGpt(fastGpt, recognizedText);
const fastGptElapsedMs = Date.now() - fastGptStartedAt;
ctx.status = 200;
ctx.body = {
text: recognizedText,
fastGpt: fastGptRes.body,
timings: {
speechMs: speechElapsedMs,
fastGptMs: fastGptElapsedMs,
totalMs: Date.now() - totalStartedAt
}
};
} catch (error) {
ctx.logger.error('语音识别并调用FastGPT失败:', error);
ctx.status = error.status || 500;
ctx.body = {
message: typeof error === 'string' ? error : (error.message || '语音识别并调用FastGPT失败'),
error: error.response?.body || error.message
};
} finally {
if (file?.path) {
try {
await fsPromises.unlink(file.path);
} catch (_) {
// ignore temp file cleanup error
}
}
}
};