新功能: - 创建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助手体验!
490 lines
14 KiB
JavaScript
Executable File
490 lines
14 KiB
JavaScript
Executable File
"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
|
||
};
|