mirror of
https://github.com/LLM-Red-Team/qwen-free-api.git
synced 2025-04-11 18:57:16 +08:00
971 lines
28 KiB
TypeScript
971 lines
28 KiB
TypeScript
import { URL } from "url";
|
||
import { PassThrough } from "stream";
|
||
import http2 from "http2";
|
||
import path from "path";
|
||
import _ from "lodash";
|
||
import mime from "mime";
|
||
import FormData from "form-data";
|
||
import axios, { AxiosResponse } from "axios";
|
||
|
||
import APIException from "@/lib/exceptions/APIException.ts";
|
||
import EX from "@/api/consts/exceptions.ts";
|
||
import { createParser } from "eventsource-parser";
|
||
import logger from "@/lib/logger.ts";
|
||
import util from "@/lib/util.ts";
|
||
|
||
// 模型名称
|
||
const MODEL_NAME = "qwen";
|
||
// 最大重试次数
|
||
const MAX_RETRY_COUNT = 3;
|
||
// 重试延迟
|
||
const RETRY_DELAY = 5000;
|
||
// 伪装headers
|
||
const FAKE_HEADERS = {
|
||
Accept: "application/json, text/plain, */*",
|
||
"Accept-Encoding": "gzip, deflate, br, zstd",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
"Cache-Control": "no-cache",
|
||
Origin: "https://tongyi.aliyun.com",
|
||
Pragma: "no-cache",
|
||
"Sec-Ch-Ua":
|
||
'"Chromium";v="122", "Not(A:Brand";v="24", "Google Chrome";v="122"',
|
||
"Sec-Ch-Ua-Mobile": "?0",
|
||
"Sec-Ch-Ua-Platform": '"Windows"',
|
||
"Sec-Fetch-Dest": "empty",
|
||
"Sec-Fetch-Mode": "cors",
|
||
"Sec-Fetch-Site": "same-site",
|
||
Referer: "https://tongyi.aliyun.com/",
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36",
|
||
"X-Platform": "pc_tongyi",
|
||
"X-Xsrf-Token": "48b9ee49-a184-45e2-9f67-fa87213edcdc",
|
||
};
|
||
// 文件最大大小
|
||
const FILE_MAX_SIZE = 100 * 1024 * 1024;
|
||
|
||
/**
|
||
* 移除会话
|
||
*
|
||
* 在对话流传输完毕后移除会话,避免创建的会话出现在用户的对话列表中
|
||
*
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
*/
|
||
async function removeConversation(convId: string, ticket: string) {
|
||
const result = await axios.post(
|
||
`https://qianwen.biz.aliyun.com/dialog/session/delete`,
|
||
{
|
||
sessionId: convId,
|
||
},
|
||
{
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
timeout: 15000,
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
checkResult(result);
|
||
}
|
||
|
||
/**
|
||
* 同步对话补全
|
||
*
|
||
* @param model 模型名称
|
||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
* @param refConvId 引用的会话ID
|
||
* @param retryCount 重试次数
|
||
*/
|
||
async function createCompletion(
|
||
model = MODEL_NAME,
|
||
messages: any[],
|
||
searchType: string = '',
|
||
ticket: string,
|
||
refConvId = '',
|
||
retryCount = 0
|
||
) {
|
||
let session: http2.ClientHttp2Session;
|
||
return (async () => {
|
||
logger.info(messages);
|
||
|
||
// 提取引用文件URL并上传qwen获得引用的文件ID列表
|
||
const refFileUrls = extractRefFileUrls(messages);
|
||
const refs = refFileUrls.length
|
||
? await Promise.all(
|
||
refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket))
|
||
)
|
||
: [];
|
||
|
||
// 如果引用对话ID不正确则重置引用
|
||
if (!/[0-9a-z]{32}/.test(refConvId))
|
||
refConvId = '';
|
||
|
||
// 请求流
|
||
const session: http2.ClientHttp2Session = await new Promise(
|
||
(resolve, reject) => {
|
||
const session = http2.connect("https://qianwen.biz.aliyun.com");
|
||
session.on("connect", () => resolve(session));
|
||
session.on("error", reject);
|
||
}
|
||
);
|
||
const [sessionId, parentMsgId = ''] = refConvId.split('-');
|
||
const req = session.request({
|
||
":method": "POST",
|
||
":path": "/dialog/conversation",
|
||
"Content-Type": "application/json",
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
Accept: "text/event-stream",
|
||
});
|
||
req.setTimeout(120000);
|
||
req.write(
|
||
JSON.stringify({
|
||
mode: "chat",
|
||
model: "",
|
||
action: "next",
|
||
userAction: "chat",
|
||
requestId: util.uuid(false),
|
||
sessionId,
|
||
sessionType: "text_chat",
|
||
parentMsgId,
|
||
params: {
|
||
"fileUploadBatchId": util.uuid(),
|
||
"searchType": searchType,
|
||
},
|
||
contents: messagesPrepare(messages, refs, !!refConvId),
|
||
})
|
||
);
|
||
req.setEncoding("utf8");
|
||
const streamStartTime = util.timestamp();
|
||
// 接收流为输出文本
|
||
const answer = await receiveStream(req);
|
||
session.close();
|
||
logger.success(
|
||
`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
|
||
);
|
||
|
||
// 异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略
|
||
removeConversation(answer.id, ticket).catch((err) => console.error(err));
|
||
|
||
return answer;
|
||
})().catch((err) => {
|
||
session && session.close();
|
||
if (retryCount < MAX_RETRY_COUNT) {
|
||
logger.error(`Stream response error: ${err.message}`);
|
||
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
|
||
return (async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
||
return createCompletion(model, messages, ticket, refConvId, retryCount + 1);
|
||
})();
|
||
}
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 流式对话补全
|
||
*
|
||
* @param model 模型名称
|
||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
* @param refConvId 引用的会话ID
|
||
* @param retryCount 重试次数
|
||
*/
|
||
async function createCompletionStream(
|
||
model = MODEL_NAME,
|
||
messages: any[],
|
||
searchType: string = '',
|
||
ticket: string,
|
||
refConvId = '',
|
||
retryCount = 0
|
||
) {
|
||
let session: http2.ClientHttp2Session;
|
||
return (async () => {
|
||
logger.info(messages);
|
||
|
||
// 提取引用文件URL并上传qwen获得引用的文件ID列表
|
||
const refFileUrls = extractRefFileUrls(messages);
|
||
const refs = refFileUrls.length
|
||
? await Promise.all(
|
||
refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket))
|
||
)
|
||
: [];
|
||
|
||
// 如果引用对话ID不正确则重置引用
|
||
if (!/[0-9a-z]{32}/.test(refConvId))
|
||
refConvId = ''
|
||
|
||
// 请求流
|
||
session = await new Promise((resolve, reject) => {
|
||
const session = http2.connect("https://qianwen.biz.aliyun.com");
|
||
session.on("connect", () => resolve(session));
|
||
session.on("error", reject);
|
||
});
|
||
const [sessionId, parentMsgId = ''] = refConvId.split('-');
|
||
const req = session.request({
|
||
":method": "POST",
|
||
":path": "/dialog/conversation",
|
||
"Content-Type": "application/json",
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
Accept: "text/event-stream",
|
||
});
|
||
req.setTimeout(120000);
|
||
req.write(
|
||
JSON.stringify({
|
||
mode: "chat",
|
||
model: "",
|
||
action: "next",
|
||
userAction: "chat",
|
||
requestId: util.uuid(false),
|
||
sessionId,
|
||
sessionType: "text_chat",
|
||
parentMsgId,
|
||
params: {
|
||
"fileUploadBatchId": util.uuid(),
|
||
"searchType": searchType,
|
||
},
|
||
contents: messagesPrepare(messages, refs, !!refConvId),
|
||
})
|
||
);
|
||
req.setEncoding("utf8");
|
||
const streamStartTime = util.timestamp();
|
||
// 创建转换流将消息格式转换为gpt兼容格式
|
||
return createTransStream(req, (convId: string) => {
|
||
// 关闭请求会话
|
||
session.close();
|
||
logger.success(
|
||
`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
|
||
);
|
||
// 流传输结束后异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略
|
||
removeConversation(convId, ticket).catch((err) => console.error(err));
|
||
});
|
||
})().catch((err) => {
|
||
session && session.close();
|
||
if (retryCount < MAX_RETRY_COUNT) {
|
||
logger.error(`Stream response error: ${err.message}`);
|
||
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
|
||
return (async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
||
return createCompletionStream(model, messages, ticket, refConvId, retryCount + 1);
|
||
})();
|
||
}
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
async function generateImages(
|
||
model = MODEL_NAME,
|
||
prompt: string,
|
||
ticket: string,
|
||
retryCount = 0
|
||
) {
|
||
let session: http2.ClientHttp2Session;
|
||
return (async () => {
|
||
const messages = [
|
||
{ role: "user", content: prompt.indexOf('画') == -1 ? `请画:${prompt}` : prompt },
|
||
];
|
||
// 请求流
|
||
const session: http2.ClientHttp2Session = await new Promise(
|
||
(resolve, reject) => {
|
||
const session = http2.connect("https://qianwen.biz.aliyun.com");
|
||
session.on("connect", () => resolve(session));
|
||
session.on("error", reject);
|
||
}
|
||
);
|
||
const req = session.request({
|
||
":method": "POST",
|
||
":path": "/dialog/conversation",
|
||
"Content-Type": "application/json",
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
Accept: "text/event-stream",
|
||
});
|
||
req.setTimeout(120000);
|
||
req.write(
|
||
JSON.stringify({
|
||
mode: "chat",
|
||
model: "",
|
||
action: "next",
|
||
userAction: "chat",
|
||
requestId: util.uuid(false),
|
||
sessionId: "",
|
||
sessionType: "text_chat",
|
||
parentMsgId: "",
|
||
params: {
|
||
"fileUploadBatchId": util.uuid()
|
||
},
|
||
contents: messagesPrepare(messages),
|
||
})
|
||
);
|
||
req.setEncoding("utf8");
|
||
const streamStartTime = util.timestamp();
|
||
// 接收流为输出文本
|
||
const { convId, imageUrls } = await receiveImages(req);
|
||
session.close();
|
||
logger.success(
|
||
`Stream has completed transfer ${util.timestamp() - streamStartTime}ms`
|
||
);
|
||
|
||
// 异步移除会话,如果消息不合规,此操作可能会抛出数据库错误异常,请忽略
|
||
removeConversation(convId, ticket).catch((err) => console.error(err));
|
||
|
||
return imageUrls;
|
||
})().catch((err) => {
|
||
session && session.close();
|
||
if (retryCount < MAX_RETRY_COUNT) {
|
||
logger.error(`Stream response error: ${err.message}`);
|
||
logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`);
|
||
return (async () => {
|
||
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY));
|
||
return generateImages(model, prompt, ticket, retryCount + 1);
|
||
})();
|
||
}
|
||
throw err;
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 提取消息中引用的文件URL
|
||
*
|
||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||
*/
|
||
function extractRefFileUrls(messages: any[]) {
|
||
const urls = [];
|
||
// 如果没有消息,则返回[]
|
||
if (!messages.length) {
|
||
return urls;
|
||
}
|
||
// 只获取最新的消息
|
||
const lastMessage = messages[messages.length - 1];
|
||
if (_.isArray(lastMessage.content)) {
|
||
lastMessage.content.forEach((v) => {
|
||
if (!_.isObject(v) || !["file", "image_url"].includes(v["type"])) return;
|
||
// glm-free-api支持格式
|
||
if (
|
||
v["type"] == "file" &&
|
||
_.isObject(v["file_url"]) &&
|
||
_.isString(v["file_url"]["url"])
|
||
)
|
||
urls.push(v["file_url"]["url"]);
|
||
// 兼容gpt-4-vision-preview API格式
|
||
else if (
|
||
v["type"] == "image_url" &&
|
||
_.isObject(v["image_url"]) &&
|
||
_.isString(v["image_url"]["url"])
|
||
)
|
||
urls.push(v["image_url"]["url"]);
|
||
});
|
||
}
|
||
logger.info("本次请求上传:" + urls.length + "个文件");
|
||
return urls;
|
||
}
|
||
|
||
/**
|
||
* 消息预处理
|
||
*
|
||
* 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果
|
||
* user:旧消息1
|
||
* assistant:旧消息2
|
||
* user:新消息
|
||
*
|
||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||
* @param refs 参考文件列表
|
||
* @param isRefConv 是否为引用会话
|
||
*/
|
||
function messagesPrepare(messages: any[], refs: any[] = [], isRefConv = false) {
|
||
let content;
|
||
if (isRefConv || messages.length < 2) {
|
||
content = messages.reduce((content, message) => {
|
||
if (_.isArray(message.content)) {
|
||
return (
|
||
message.content.reduce((_content, v) => {
|
||
if (!_.isObject(v) || v["type"] != "text") return _content;
|
||
return _content + (v["text"] || "") + "\n";
|
||
}, content)
|
||
);
|
||
}
|
||
return content + `${message.content}\n`;
|
||
}, "");
|
||
logger.info("\n透传内容:\n" + content);
|
||
}
|
||
else {
|
||
content = messages.reduce((content, message) => {
|
||
if (_.isArray(message.content)) {
|
||
return message.content.reduce((_content, v) => {
|
||
if (!_.isObject(v) || v["type"] != "text") return _content;
|
||
return _content + `<|im_start|>${message.role || "user"}\n${v["text"] || ""}<|im_end|>\n`;
|
||
}, content);
|
||
}
|
||
return (content += `<|im_start|>${message.role || "user"}\n${
|
||
message.content
|
||
}<|im_end|>\n`);
|
||
}, "").replace(/\!\[.*\]\(.+\)/g, "");
|
||
logger.info("\n对话合并:\n" + content);
|
||
}
|
||
return [
|
||
{
|
||
content,
|
||
contentType: "text",
|
||
role: "user",
|
||
},
|
||
...refs
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 检查请求结果
|
||
*
|
||
* @param result 结果
|
||
*/
|
||
function checkResult(result: AxiosResponse) {
|
||
if (!result.data) return null;
|
||
const { success, errorCode, errorMsg } = result.data;
|
||
if (!_.isBoolean(success) || success) return result.data;
|
||
throw new APIException(
|
||
EX.API_REQUEST_FAILED,
|
||
`[请求qwen失败]: ${errorCode}-${errorMsg}`
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 从流接收完整的消息内容
|
||
*
|
||
* @param stream 消息流
|
||
*/
|
||
async function receiveStream(stream: any): Promise<any> {
|
||
return new Promise((resolve, reject) => {
|
||
// 消息初始化
|
||
const data = {
|
||
id: "",
|
||
model: MODEL_NAME,
|
||
object: "chat.completion",
|
||
choices: [
|
||
{
|
||
index: 0,
|
||
message: { role: "assistant", content: "" },
|
||
finish_reason: "stop",
|
||
},
|
||
],
|
||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||
created: util.unixTimestamp(),
|
||
};
|
||
const parser = createParser((event) => {
|
||
try {
|
||
if (event.type !== "event") return;
|
||
if (event.data == "[DONE]") return;
|
||
// 解析JSON
|
||
const result = _.attempt(() => JSON.parse(event.data));
|
||
if (_.isError(result))
|
||
throw new Error(`Stream response invalid: ${event.data}`);
|
||
if (!data.id && result.sessionId && result.msgId)
|
||
data.id = `${result.sessionId}-${result.msgId}`;
|
||
const text = (result.contents || []).reduce((str, part) => {
|
||
const { contentType, role, content } = part;
|
||
if (contentType != "text" && contentType != "text2image") return str;
|
||
if (role != "assistant" && !_.isString(content)) return str;
|
||
return str + content;
|
||
}, "");
|
||
const exceptCharIndex = text.indexOf("<22>");
|
||
let chunk = text.substring(
|
||
exceptCharIndex != -1
|
||
? Math.min(data.choices[0].message.content.length, exceptCharIndex)
|
||
: data.choices[0].message.content.length,
|
||
exceptCharIndex == -1 ? text.length : exceptCharIndex
|
||
);
|
||
if (chunk && result.contentType == "text2image") {
|
||
chunk = chunk.replace(
|
||
/https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=\,]*)/gi,
|
||
(url) => {
|
||
const urlObj = new URL(url);
|
||
urlObj.search = "";
|
||
return urlObj.toString();
|
||
}
|
||
);
|
||
}
|
||
if (result.msgStatus != "finished") {
|
||
if (result.contentType == "text")
|
||
data.choices[0].message.content += chunk;
|
||
} else {
|
||
data.choices[0].message.content += chunk;
|
||
if (!result.canShare)
|
||
data.choices[0].message.content +=
|
||
"\n[内容由于不合规被停止生成,我们换个话题吧]";
|
||
if (result.errorCode)
|
||
data.choices[0].message.content += `服务暂时不可用,第三方响应错误:${result.errorCode}`;
|
||
resolve(data);
|
||
}
|
||
} catch (err) {
|
||
logger.error(err);
|
||
reject(err);
|
||
}
|
||
});
|
||
// 将流数据喂给SSE转换器
|
||
stream.on("data", (buffer) => parser.feed(buffer.toString()));
|
||
stream.once("error", (err) => reject(err));
|
||
stream.once("close", () => resolve(data));
|
||
stream.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 创建转换流
|
||
*
|
||
* 将流格式转换为gpt兼容流格式
|
||
*
|
||
* @param stream 消息流
|
||
* @param endCallback 传输结束回调
|
||
*/
|
||
function createTransStream(stream: any, endCallback?: Function) {
|
||
// 消息创建时间
|
||
const created = util.unixTimestamp();
|
||
// 创建转换流
|
||
const transStream = new PassThrough();
|
||
let content = "";
|
||
!transStream.closed &&
|
||
transStream.write(
|
||
`data: ${JSON.stringify({
|
||
id: "",
|
||
model: MODEL_NAME,
|
||
object: "chat.completion.chunk",
|
||
choices: [
|
||
{
|
||
index: 0,
|
||
delta: { role: "assistant", content: "" },
|
||
finish_reason: null,
|
||
},
|
||
],
|
||
created,
|
||
})}\n\n`
|
||
);
|
||
const parser = createParser((event) => {
|
||
try {
|
||
if (event.type !== "event") return;
|
||
if (event.data == "[DONE]") return;
|
||
// 解析JSON
|
||
const result = _.attempt(() => JSON.parse(event.data));
|
||
if (_.isError(result))
|
||
throw new Error(`Stream response invalid: ${event.data}`);
|
||
const text = (result.contents || []).reduce((str, part) => {
|
||
const { contentType, role, content } = part;
|
||
if (contentType != "text" && contentType != "text2image") return str;
|
||
if (role != "assistant" && !_.isString(content)) return str;
|
||
return str + content;
|
||
}, "");
|
||
const exceptCharIndex = text.indexOf("<22>");
|
||
let chunk = text.substring(
|
||
exceptCharIndex != -1
|
||
? Math.min(content.length, exceptCharIndex)
|
||
: content.length,
|
||
exceptCharIndex == -1 ? text.length : exceptCharIndex
|
||
);
|
||
if (chunk && result.contentType == "text2image") {
|
||
chunk = chunk.replace(
|
||
/https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=\,]*)/gi,
|
||
(url) => {
|
||
const urlObj = new URL(url);
|
||
urlObj.search = "";
|
||
return urlObj.toString();
|
||
}
|
||
);
|
||
}
|
||
if (result.msgStatus != "finished") {
|
||
if (chunk && result.contentType == "text") {
|
||
content += chunk;
|
||
const data = `data: ${JSON.stringify({
|
||
id: `${result.sessionId}-${result.msgId}`,
|
||
model: MODEL_NAME,
|
||
object: "chat.completion.chunk",
|
||
choices: [
|
||
{ index: 0, delta: { content: chunk }, finish_reason: null },
|
||
],
|
||
created,
|
||
})}\n\n`;
|
||
!transStream.closed && transStream.write(data);
|
||
}
|
||
} else {
|
||
const delta = { content: chunk || "" };
|
||
if (!result.canShare)
|
||
delta.content += "\n[内容由于不合规被停止生成,我们换个话题吧]";
|
||
if (result.errorCode)
|
||
delta.content += `服务暂时不可用,第三方响应错误:${result.errorCode}`;
|
||
const data = `data: ${JSON.stringify({
|
||
id: `${result.sessionId}-${result.msgId}`,
|
||
model: MODEL_NAME,
|
||
object: "chat.completion.chunk",
|
||
choices: [
|
||
{
|
||
index: 0,
|
||
delta,
|
||
finish_reason: "stop",
|
||
},
|
||
],
|
||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||
created,
|
||
})}\n\n`;
|
||
!transStream.closed && transStream.write(data);
|
||
!transStream.closed && transStream.end("data: [DONE]\n\n");
|
||
content = "";
|
||
endCallback && endCallback(result.sessionId);
|
||
}
|
||
// else
|
||
// logger.warn(result.event, result);
|
||
} catch (err) {
|
||
logger.error(err);
|
||
!transStream.closed && transStream.end("\n\n");
|
||
}
|
||
});
|
||
// 将流数据喂给SSE转换器
|
||
stream.on("data", (buffer) => parser.feed(buffer.toString()));
|
||
stream.once(
|
||
"error",
|
||
() => !transStream.closed && transStream.end("data: [DONE]\n\n")
|
||
);
|
||
stream.once(
|
||
"close",
|
||
() => !transStream.closed && transStream.end("data: [DONE]\n\n")
|
||
);
|
||
stream.end();
|
||
return transStream;
|
||
}
|
||
|
||
/**
|
||
* 从流接收图像
|
||
*
|
||
* @param stream 消息流
|
||
*/
|
||
async function receiveImages(
|
||
stream: any
|
||
): Promise<{ convId: string; imageUrls: string[] }> {
|
||
return new Promise((resolve, reject) => {
|
||
let convId = "";
|
||
const imageUrls = [];
|
||
const parser = createParser((event) => {
|
||
try {
|
||
if (event.type !== "event") return;
|
||
if (event.data == "[DONE]") return;
|
||
// 解析JSON
|
||
const result = _.attempt(() => JSON.parse(event.data));
|
||
if (_.isError(result))
|
||
throw new Error(`Stream response invalid: ${event.data}`);
|
||
if (!convId && result.sessionId) convId = result.sessionId;
|
||
const text = (result.contents || []).reduce((str, part) => {
|
||
const { role, content } = part;
|
||
if (role != "assistant" && !_.isString(content)) return str;
|
||
return str + content;
|
||
}, "");
|
||
if (result.contentFrom == "text2image") {
|
||
const urls =
|
||
text.match(
|
||
/https?:\/\/[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=\,]*)/gi
|
||
) || [];
|
||
urls.forEach((url) => {
|
||
const urlObj = new URL(url);
|
||
urlObj.search = "";
|
||
const imageUrl = urlObj.toString();
|
||
if (imageUrls.indexOf(imageUrl) != -1) return;
|
||
imageUrls.push(imageUrl);
|
||
});
|
||
}
|
||
if (result.msgStatus == "finished") {
|
||
if (!result.canShare || imageUrls.length == 0)
|
||
throw new APIException(EX.API_CONTENT_FILTERED);
|
||
if (result.errorCode)
|
||
throw new APIException(
|
||
EX.API_REQUEST_FAILED,
|
||
`服务暂时不可用,第三方响应错误:${result.errorCode}`
|
||
);
|
||
}
|
||
} catch (err) {
|
||
logger.error(err);
|
||
reject(err);
|
||
}
|
||
});
|
||
// 将流数据喂给SSE转换器
|
||
stream.on("data", (buffer) => parser.feed(buffer.toString()));
|
||
stream.once("error", (err) => reject(err));
|
||
stream.once("close", () => resolve({ convId, imageUrls }));
|
||
stream.end();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取上传参数
|
||
*
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
*/
|
||
async function acquireUploadParams(ticket: string) {
|
||
const result = await axios.post(
|
||
"https://qianwen.biz.aliyun.com/dialog/uploadToken",
|
||
{},
|
||
{
|
||
timeout: 15000,
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
const { data } = checkResult(result);
|
||
return data;
|
||
}
|
||
|
||
/**
|
||
* 预检查文件URL有效性
|
||
*
|
||
* @param fileUrl 文件URL
|
||
*/
|
||
async function checkFileUrl(fileUrl: string) {
|
||
if (util.isBASE64Data(fileUrl)) return;
|
||
const result = await axios.head(fileUrl, {
|
||
timeout: 15000,
|
||
validateStatus: () => true,
|
||
});
|
||
if (result.status >= 400)
|
||
throw new APIException(
|
||
EX.API_FILE_URL_INVALID,
|
||
`File ${fileUrl} is not valid: [${result.status}] ${result.statusText}`
|
||
);
|
||
// 检查文件大小
|
||
if (result.headers && result.headers["content-length"]) {
|
||
const fileSize = parseInt(result.headers["content-length"], 10);
|
||
if (fileSize > FILE_MAX_SIZE)
|
||
throw new APIException(
|
||
EX.API_FILE_EXECEEDS_SIZE,
|
||
`File ${fileUrl} is not valid`
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 上传文件
|
||
*
|
||
* @param fileUrl 文件URL
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
*/
|
||
async function uploadFile(fileUrl: string, ticket: string) {
|
||
// 预检查远程文件URL可用性
|
||
await checkFileUrl(fileUrl);
|
||
|
||
let filename, fileData, mimeType;
|
||
// 如果是BASE64数据则直接转换为Buffer
|
||
if (util.isBASE64Data(fileUrl)) {
|
||
mimeType = util.extractBASE64DataFormat(fileUrl);
|
||
const ext = mime.getExtension(mimeType);
|
||
filename = `${util.uuid()}.${ext}`;
|
||
fileData = Buffer.from(util.removeBASE64DataHeader(fileUrl), "base64");
|
||
}
|
||
// 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存
|
||
else {
|
||
filename = path.basename(fileUrl);
|
||
({ data: fileData } = await axios.get(fileUrl, {
|
||
responseType: "arraybuffer",
|
||
// 100M限制
|
||
maxContentLength: FILE_MAX_SIZE,
|
||
// 60秒超时
|
||
timeout: 60000,
|
||
}));
|
||
}
|
||
|
||
// 获取文件的MIME类型
|
||
mimeType = mimeType || mime.getType(filename);
|
||
|
||
// 获取上传参数
|
||
const { accessId, policy, signature, dir } = await acquireUploadParams(
|
||
ticket
|
||
);
|
||
|
||
const formData = new FormData();
|
||
formData.append("OSSAccessKeyId", accessId);
|
||
formData.append("policy", policy);
|
||
formData.append("signature", signature);
|
||
formData.append("key", `${dir}${filename}`);
|
||
formData.append("dir", dir);
|
||
formData.append("success_action_status", "200");
|
||
formData.append("file", fileData, {
|
||
filename,
|
||
contentType: mimeType,
|
||
});
|
||
|
||
// 上传文件到OSS
|
||
await axios.request({
|
||
method: "POST",
|
||
url: "https://broadscope-dialogue-new.oss-cn-beijing.aliyuncs.com/",
|
||
data: formData,
|
||
// 100M限制
|
||
maxBodyLength: FILE_MAX_SIZE,
|
||
// 60秒超时
|
||
timeout: 120000,
|
||
headers: {
|
||
...FAKE_HEADERS,
|
||
"X-Requested-With": "XMLHttpRequest"
|
||
}
|
||
});
|
||
|
||
const isImage = [
|
||
'image/jpeg',
|
||
'image/jpg',
|
||
'image/tiff',
|
||
'image/png',
|
||
'image/bmp',
|
||
'image/gif',
|
||
'image/svg+xml',
|
||
'image/webp',
|
||
'image/ico',
|
||
'image/heic',
|
||
'image/heif',
|
||
'image/bmp',
|
||
'image/x-icon',
|
||
'image/vnd.microsoft.icon',
|
||
'image/x-png'
|
||
].includes(mimeType);
|
||
|
||
if(isImage) {
|
||
const result = await axios.post(
|
||
"https://qianwen.biz.aliyun.com/dialog/downloadLink",
|
||
{
|
||
fileKey: filename,
|
||
fileType: "image",
|
||
dir
|
||
},
|
||
{
|
||
timeout: 15000,
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
const { data } = checkResult(result);
|
||
return {
|
||
role: "user",
|
||
contentType: "image",
|
||
content: data.url
|
||
};
|
||
}
|
||
else {
|
||
let result = await axios.post(
|
||
"https://qianwen.biz.aliyun.com/dialog/downloadLink/batch",
|
||
{
|
||
fileKeys: [filename],
|
||
fileType: "file",
|
||
dir
|
||
},
|
||
{
|
||
timeout: 15000,
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
const { data } = checkResult(result);
|
||
if(!data.results[0] || !data.results[0].url)
|
||
throw new Error(`文件上传失败:${data.results[0] ? data.results[0].errorMsg : '未知错误'}`);
|
||
const url = data.results[0].url;
|
||
const startTime = util.timestamp();
|
||
while(true) {
|
||
result = await axios.post(
|
||
"https://qianwen.biz.aliyun.com/dialog/secResult/batch",
|
||
{
|
||
urls: [url]
|
||
},
|
||
{
|
||
timeout: 15000,
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
const { data } = checkResult(result);
|
||
if(data.pollEndFlag) {
|
||
if(data.statusList[0] && data.statusList[0].status === 0)
|
||
throw new Error(`文件处理失败:${data.statusList[0].errorMsg || '未知错误'}`);
|
||
break;
|
||
}
|
||
if(util.timestamp() > startTime + 120000)
|
||
throw new Error("文件处理超时:超出120秒");
|
||
}
|
||
return {
|
||
role: "user",
|
||
contentType: "file",
|
||
content: url,
|
||
ext: { fileSize: fileData.byteLength }
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Token切分
|
||
*
|
||
* @param authorization 认证字符串
|
||
*/
|
||
function tokenSplit(authorization: string) {
|
||
return authorization.replace("Bearer ", "").split(",");
|
||
}
|
||
|
||
/**
|
||
* 生成Cookies
|
||
*
|
||
* @param ticket tongyi_sso_ticket或login_aliyunid_ticket
|
||
*/
|
||
function generateCookie(ticket: string) {
|
||
return [
|
||
`${ticket.length > 100 ? 'login_aliyunid_ticket' : 'tongyi_sso_ticket'}=${ticket}`,
|
||
'aliyun_choice=intl',
|
||
"_samesite_flag_=true",
|
||
`t=${util.uuid(false)}`,
|
||
// `login_aliyunid_csrf=_csrf_tk_${util.generateRandomString({ charset: 'numeric', length: 15 })}`,
|
||
// `cookie2=${util.uuid(false)}`,
|
||
// `munb=22${util.generateRandomString({ charset: 'numeric', length: 11 })}`,
|
||
// `csg=`,
|
||
// `_tb_token_=${util.generateRandomString({ length: 10, capitalization: 'lowercase' })}`,
|
||
// `cna=`,
|
||
// `cnaui=`,
|
||
// `atpsida=`,
|
||
// `isg=`,
|
||
// `tfstk=`,
|
||
// `aui=`,
|
||
// `sca=`
|
||
].join("; ");
|
||
}
|
||
|
||
/**
|
||
* 获取Token存活状态
|
||
*/
|
||
async function getTokenLiveStatus(ticket: string) {
|
||
const result = await axios.post(
|
||
"https://qianwen.biz.aliyun.com/dialog/session/list",
|
||
{},
|
||
{
|
||
headers: {
|
||
Cookie: generateCookie(ticket),
|
||
...FAKE_HEADERS,
|
||
},
|
||
timeout: 15000,
|
||
validateStatus: () => true,
|
||
}
|
||
);
|
||
try {
|
||
const { data } = checkResult(result);
|
||
return _.isArray(data);
|
||
}
|
||
catch(err) {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
export default {
|
||
createCompletion,
|
||
createCompletionStream,
|
||
generateImages,
|
||
getTokenLiveStatus,
|
||
tokenSplit,
|
||
};
|