gongxiangp2/yedir/server/utils/volc-util.js
chenpei d0dc94e36d feat: 将弹幕功能替换为AI聊天助手
新功能:
- 创建AI聊天助手组件(AIChatWidget)
- 支持文字+图片多模态对话
- 集成Gemini AI API
- 支持图片编辑和内容生成
- 实时对话历史管理
- 优雅的UI设计,支持黑夜模式

技术实现:
- 后端API代理Gemini API(/api/ai-chat)
- 支持最多5张图片上传
- 流式响应处理
- 对话历史管理
- Base64图片处理

配置:
- 在.env中添加Gemini API配置
- GEMINI_API_KEY
- GEMINI_API_BASE_URL
- GEMINI_MODEL

替换原弹幕功能,提供更强大的AI助手体验!
2025-10-27 17:45:37 +08:00

490 lines
14 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use strict";
/*
Copyright (year) Beijing Volcano Engine Technology Ltd.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const crypto = require("crypto");
const util = require("util");
const url = require("url");
const axios = require("axios");
const querystring = require("querystring");
const debuglog = util.debuglog('signer');
// 配置
const config = {
// 使用API文档提供的凭证
accessKeyId: process.env.VOLC_AI_IMAGE_EDIT_ACCESS_KEY_ID || 'AKLTYjFlYmRiOThhMGFmNDJhZWI2Y2YyYmVkNGE1ZDEwMWE',
secretAccessKey: process.env.VOLC_AI_IMAGE_EDIT_SECRET_ACCESS_KEY || 'T0daaE1qY3lOMk16WWpoa05EWXdOMkk4Tm1WaE9UYzBaakEwWXpKa01HTQ==',
region: 'cn-north-1', // 文档中指定Region为cn-north-1
host: 'visual.volcengineapi.com',
service: 'cv', // 文档中指定Service为cv
apiVersion: '2022-08-31' // 文档中指定Version为2022-08-31
};
/**
* 不参与加签过程的 header key
*/
const HEADER_KEYS_TO_IGNORE = new Set([
"authorization",
"content-type",
"content-length",
"user-agent",
"presigned-expires",
"expect",
]);
/**
* 生成当前时间的标准格式字符串(YYYYMMDDTHHMMSSZ)
* @returns {string} 格式化的时间字符串
*/
function getDateTimeNow() {
// 使用当前实际系统时间
const now = new Date();
console.log(`[INFO] 当前系统时间UTC: ${now.toISOString()}`);
// 生成北京时间日志
const beijingTime = new Date(now.getTime() + 8 * 60 * 60 * 1000);
console.log(`[INFO] 当前北京时间: ${beijingTime.toISOString()}`);
// 返回UTC时间的标准格式字符串 - 确保使用正确格式
return now.toISOString().replace(/[:-]|\.\d{3}/g, '');
}
/**
* 计算HMAC-SHA256
* @param {string|Buffer} secret 密钥
* @param {string} s 要计算的字符串
* @returns {Buffer} HMAC结果
*/
function hmac(secret, s) {
return crypto.createHmac('sha256', secret).update(s, 'utf8').digest();
}
/**
* 计算SHA256哈希
* @param {string} s 要计算哈希的字符串
* @returns {string} 十六进制哈希字符串
*/
function hash(s) {
return crypto.createHash('sha256').update(s, 'utf8').digest('hex');
}
/**
* 将查询参数转换为字符串
* @param {object} query 查询参数对象
* @returns {string} 排序后的查询参数字符串
*/
function queryParamsToString(query) {
if (!query) return '';
return Object.keys(query)
.sort() // 按字典序排序
.map(key => {
const value = query[key] === undefined || query[key] === null ? '' : query[key];
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
})
.join('&');
}
/**
* 获取待签名的Header
* @param {object} headers 请求头对象
* @param {Array} needSignHeaderKeys 需要签名的header key列表
* @returns {Array} [signedHeaderKeys, canonicalHeaders]
*/
function getSignHeaders(headers, needSignHeaderKeys = []) {
// 合并需要签名的header key
if (!Array.isArray(needSignHeaderKeys)) {
needSignHeaderKeys = [];
}
// 始终签名host和x-date
const mustSignedHeaders = ['host', 'x-date'];
// 确保Host和X-Date始终被包含在需要签名的Header中
mustSignedHeaders.forEach(headerKey => {
if (!needSignHeaderKeys.includes(headerKey.toLowerCase())) {
needSignHeaderKeys.push(headerKey.toLowerCase());
}
});
// 获取待签名的header key
let signedHeaderKeys = [];
let canonicalHeaders = '';
// 获取所有待签名的header
const cloneHeaders = { ...headers };
for (const headerKey in cloneHeaders) {
const lowerKey = headerKey.toLowerCase();
if (
needSignHeaderKeys.includes(lowerKey) ||
mustSignedHeaders.includes(lowerKey)
) {
signedHeaderKeys.push(lowerKey);
canonicalHeaders += `${lowerKey}:${cloneHeaders[headerKey]}\n`;
}
}
// 按照头部名称排序
signedHeaderKeys.sort();
return [signedHeaderKeys.join(';'), canonicalHeaders];
}
/**
* URI编码
* @param {string} str 原始字符串
* @returns {string} 编码后的字符串
*/
function uriEscape(str) {
try {
return encodeURIComponent(str)
.replace(/[^A-Za-z0-9_.~\-%]+/g, escape)
.replace(/[*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`);
} catch (e) {
return '';
}
}
/**
* 获取请求体的SHA256哈希
* @param {string|Buffer|URLSearchParams} body 请求体
* @returns {string} 哈希字符串
*/
function getBodySha(body) {
const hash = crypto.createHash('sha256');
if (typeof body === 'string') {
hash.update(body);
} else if (body instanceof url.URLSearchParams) {
hash.update(body.toString());
} else if (util.isBuffer(body)) {
hash.update(body);
}
return hash.digest('hex');
}
/**
* 签名算法
* @param {object} params 签名参数
* @returns {string} 授权头
*/
function sign(params) {
const {
headers = {},
query = {},
region = '',
serviceName = '',
method = '',
pathName = '/',
accessKeyId = '',
secretAccessKey = '',
needSignHeaderKeys = [],
bodySha,
} = params;
// 1. 获取X-Date标头值
const datetime = headers["X-Date"];
if (!datetime) {
throw new Error('Missing required X-Date header for signing');
}
// 2. 获取日期部分(前8位)
const date = datetime.substring(0, 8); // YYYYMMDD
// 3. 创建签名所需的标头
const [signedHeaders, canonicalHeaders] = getSignHeaders(headers, needSignHeaderKeys);
// 4. 创建规范请求字符串
const canonicalRequest = [
method.toUpperCase(),
pathName,
queryParamsToString(query) || '',
canonicalHeaders + '\n', // 确保结尾有换行符
signedHeaders,
bodySha || hash(''),
].join('\n');
// 5. 创建凭证范围
const credentialScope = [date, region, serviceName, "request"].join('/');
// 6. 创建待签名字符串
const stringToSign = [
"HMAC-SHA256",
datetime,
credentialScope,
hash(canonicalRequest)
].join('\n');
// 7. 计算签名
const kDate = hmac(secretAccessKey, date);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, serviceName);
const kSigning = hmac(kService, "request");
const signature = hmac(kSigning, stringToSign).toString('hex');
// 8. 打印调试信息
console.log('-----CanonicalRequest-----');
console.log(canonicalRequest);
console.log('-----StringToSign-----');
console.log(stringToSign);
console.log('-----Signature-----');
console.log(signature);
// 9. 返回授权头
return `HMAC-SHA256 Credential=${accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
}
/**
* 提交异步任务
* @param {object} params 请求参数
* @returns {Promise<object>} 任务提交结果
*/
async function submitAsyncTask(params) {
try {
console.log('[Node.js API] 开始提交异步任务...');
console.log('[Node.js API] 使用服务:', params.req_key);
// 将params序列化为JSON字符串
const jsonBody = JSON.stringify(params);
console.log('[Node.js API] 请求体:', jsonBody);
// 获取body的SHA256哈希
const bodySha = getBodySha(jsonBody);
// 获取当前时间
const datetime = getDateTimeNow();
console.log('[Node.js API] X-Date:', datetime);
// 准备查询参数 - 使用CVSync2AsyncSubmitTask接口
const queryParams = {
Version: config.apiVersion,
Action: 'CVSync2AsyncSubmitTask'
};
// 准备请求头
const headers = {
"X-Date": datetime,
"Host": config.host
};
// 签名参数
const signParams = {
headers,
method: 'POST',
query: queryParams,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
serviceName: config.service,
region: config.region,
bodySha
};
// 计算签名
const authorization = sign(signParams);
// 构建URL
const url = `https://${config.host}/?${querystring.stringify(queryParams)}`;
console.log('[Node.js API] 请求URL:', url);
// 完整请求头
const requestHeaders = {
"X-Date": datetime,
"Host": config.host,
"Authorization": authorization,
"Content-Type": "application/json"
};
console.log('[Node.js API] 请求头:', JSON.stringify(requestHeaders, null, 2));
// 发送请求
const response = await axios.post(url, jsonBody, {
headers: requestHeaders
});
console.log('[Node.js API] 任务提交响应:', JSON.stringify(response.data, null, 2));
return response.data;
} catch (error) {
console.error('[Node.js API] 提交任务失败:', error.message);
if (error.response) {
console.error('[Node.js API] 错误状态码:', error.response.status);
console.error('[Node.js API] 错误响应内容:', JSON.stringify(error.response.data, null, 2));
}
throw error;
}
}
/**
* 获取异步任务结果
* @param {string} taskId 任务ID
* @param {object} params 附加参数
* @returns {Promise<object>} 任务结果
*/
async function getAsyncTaskResult(taskId, params = {}) {
try {
console.log('[Node.js API] 开始获取任务结果, 任务ID:', taskId);
// 准备查询参数
const queryParams = {
Version: config.apiVersion,
Action: 'CVSync2AsyncGetResult'
};
// 准备请求体 - 按照官方文档格式
let reqJson = {};
// 将logo_info和return_url参数添加到reqJson
if (params.logo_info) {
reqJson.logo_info = params.logo_info;
}
if (params.return_url !== undefined) {
reqJson.return_url = params.return_url;
} else {
reqJson.return_url = true; // 默认返回URL
}
// 构建完整请求体
const body = {
req_key: params.req_key,
task_id: taskId,
req_json: JSON.stringify(reqJson)
};
console.log('[Node.js API] 使用服务:', body.req_key);
// 将请求体转换为JSON字符串
const jsonBody = JSON.stringify(body);
console.log('[Node.js API] 获取结果请求体:', jsonBody);
// 获取body的SHA256哈希
const bodySha = getBodySha(jsonBody);
// 获取当前时间
const datetime = getDateTimeNow();
console.log('[Node.js API] X-Date:', datetime);
// 准备请求头
const headers = {
"X-Date": datetime,
"Host": config.host
};
// 签名参数
const signParams = {
headers,
method: 'POST',
query: queryParams,
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
serviceName: config.service,
region: config.region,
bodySha
};
// 计算签名
const authorization = sign(signParams);
// 构建URL
const url = `https://${config.host}/?${querystring.stringify(queryParams)}`;
console.log('[Node.js API] 请求URL:', url);
// 完整请求头
const requestHeaders = {
"X-Date": datetime,
"Host": config.host,
"Authorization": authorization,
"Content-Type": "application/json"
};
console.log('[Node.js API] 请求头:', JSON.stringify(requestHeaders, null, 2));
// 发送请求
const response = await axios.post(url, jsonBody, {
headers: requestHeaders
});
console.log('[Node.js API] 任务结果响应:', JSON.stringify(response.data, null, 2));
return response.data;
} catch (error) {
console.error('[Node.js API] 获取任务结果失败:', error.message);
if (error.response) {
console.error('[Node.js API] 错误状态码:', error.response.status);
console.error('[Node.js API] 错误响应内容:', JSON.stringify(error.response.data, null, 2));
}
throw error;
}
}
/**
* 等待异步任务完成
* @param {string} taskId 任务ID
* @param {object} params 附加参数
* @param {number} maxAttempts 最大尝试次数
* @param {number} interval 间隔时间(毫秒)
* @returns {Promise<object>} 任务结果
*/
async function waitForTaskCompletion(taskId, params = {}, maxAttempts = 30, interval = 2000) {
console.log(`[Node.js API] 等待任务完成, 任务ID: ${taskId}, 最大尝试次数: ${maxAttempts}, 间隔: ${interval}ms`);
let attempts = 0;
while (attempts < maxAttempts) {
attempts++;
try {
const result = await getAsyncTaskResult(taskId, params);
// 如果请求成功
if (result && result.code === 10000) {
// 检查任务状态
if (result.data && result.data.status === 'done') {
console.log(`[Node.js API] 任务成功完成, 尝试次数: ${attempts}`);
return result;
} else if (result.data && result.data.status === 'not_found') {
throw new Error('任务未找到,可能已过期或不存在');
}
// 任务仍在处理中 (in_queue 或 generating)
console.log(`[Node.js API] 任务正在处理中, 尝试次数: ${attempts}, 状态: ${result.data.status}`);
} else {
// 请求不成功
console.error(`[Node.js API] 查询任务结果异常, 错误码: ${result.code}, 消息: ${result.message}`);
}
// 等待指定的间隔时间
await new Promise(resolve => setTimeout(resolve, interval));
} catch (error) {
// 如果是明确的任务失败或未找到,直接抛出错误
if (error.message.includes('任务未找到') || error.message.includes('任务执行失败')) {
throw error;
}
console.error(`[Node.js API] 查询任务状态出错, 尝试次数: ${attempts}`, error.message);
// 等待指定的间隔时间
await new Promise(resolve => setTimeout(resolve, interval));
}
}
throw new Error('任务处理超时,请稍后检查结果');
}
// 导出函数
module.exports = {
submitAsyncTask,
getAsyncTaskResult,
waitForTaskCompletion,
sign,
getDateTimeNow,
getBodySha
};