Spaces:
Paused
Paused
| 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[], | |
| 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() | |
| }, | |
| 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[], | |
| 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() | |
| }, | |
| 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("�"); | |
| 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("�"); | |
| 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.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, | |
| }; | |