diff --git a/README.md b/README.md index 32ba582..e9799f1 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ ![](https://img.shields.io/github/forks/llm-red-team/qwen-free-api.svg) ![](https://img.shields.io/docker/pulls/vinlic/qwen-free-api.svg) -支持高速流式输出、支持多轮对话、支持无水印AI绘图、支持长文档解读(正在开发)、图像解析(正在开发),零配置部署,多路token支持,自动清理会话痕迹。 +支持高速流式输出、支持多轮对话、支持无水印AI绘图、支持长文档解读、图像解析,零配置部署,多路token支持,自动清理会话痕迹。 与ChatGPT接口完全兼容。 @@ -66,11 +66,11 @@ https://udify.app/chat/qOXzVl5kkvhQXM8r ### 长文档解读 -正在开发... +![AI绘图](./doc/example-5.png) ### 图像解析 -正在开发... +![AI绘图](./doc/example-6.png) ### 10线程并发测试 @@ -276,11 +276,127 @@ Authorization: Bearer [login_tongyi_ticket] ### 文档解读 -正在开发... +提供一个可访问的文件URL或者BASE64_URL进行解析。 + +**POST /v1/chat/completions** + +header 需要设置 Authorization 头部: + +``` +Authorization: Bearer [refresh_token] +``` + +请求数据: +```json +{ + "model": "qwen", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "file", + "file_url": { + "url": "https://mj101-1317487292.cos.ap-shanghai.myqcloud.com/ai/test.pdf" + } + }, + { + "type": "text", + "text": "文档里说了什么?" + } + ] + } + ] +} +``` + +响应数据: +```json +{ + "id": "b56ea6c9e86140429fa2de6a6ec028ff", + "model": "qwen", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "文档中包含了四个古代魔法仪式或咒语的描述,它们似乎旨在影响或控制一个特定女性的情感和行为,使其对施术者产生强烈的爱意。以下是每个仪式的具体内容:\n\n1. **仪式一**(PMG 4.1390 – 1495):\n - 施术者需留下一些面包,将其掰成七小块。\n - 前往一处英雄、角斗士或其他暴力死亡者丧生的地方。\n - 对着面包碎片念诵咒语后丢弃,并从该地取一些受污染的泥土扔进目标女性的住所。\n - 咒语内容包括向命运三女神(Moirai)、罗马版的命运女神(Fates)、自然力量(Daemons)、饥荒与嫉妒之神以及非正常死亡者献祭食物,并请求他们以痛苦折磨目标,使她在梦中惊醒,心生忧虑与恐惧,最终跟随施术者的步伐并顺从其意愿。此过程以赫卡忒(Hecate)女神为命令的源泉。\n\n2. **仪式二**(PMG 4.1342 – 57):\n - 施术者召唤恶魔(Daemon),通过一系列神秘的神祇名号(如Erekisephthe Araracharara Ephthesikere)要求其将名为Tereous的女子(Apia所生)带至施术者Didymos(Taipiam所生)身边。\n - 请求该女子在灵魂、心智及女性器官上遭受剧烈痛苦,直至她主动找寻Didymos并与之紧密相连(唇对唇、发对发、腹部对腹部)。整个过程要求立即执行。\n\n3. **仪式三**(PGM 4.1265 – 74):\n - 揭示了阿佛洛狄忒(Aphrodite)鲜为人知的名字——NEPHERIĒRI[nfr-iry-t]。\n - 如果想赢得一位美丽女子的芳心,施术者应保持三天纯净,献上乳香,并在心中默念该名字七次。\n - 这样的做法需持续七天,据说这样便能成功吸引女子。\n\n4. **仪式四**(PGM 4.1496 – 1):\n - 施术者在燃烧的煤炭上供奉没药(myrrh),同时念诵咒语。\n - 咒语将没药称为“苦涩的调和者”、“热力的激发者”,并命令它前往指定的女子(及其母亲的名字)处,阻止她进行日常活动(如坐、饮、食、注视他人、亲吻他人),迫使她心中只有施术者,对其产生强烈的欲望与爱意。\n - 咒语还指示没药直接穿透女子的灵魂,驻留在其心中,焚烧其内脏、胸部、肝脏、气息、骨骼、骨髓,直到她来到施术者身边。\n\n这些仪式反映了古代魔法实践中试图借助超自然力量操控他人情感与行为的企图,涉及对神灵、恶魔、神秘名字及特定物质(如面包、泥土、乳香、没药)的运用,通常伴随着严格的仪式规程和咒语念诵。此类行为在现代伦理和法律框架下被视为不恰当甚至违法,且缺乏科学依据。" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + }, + "created": 1712253736 +} +``` ### 图像解析 -正在开发... +提供一个可访问的图像URL或者BASE64_URL进行解析。 + +此格式兼容 [gpt-4-vision-preview](https://platform.openai.com/docs/guides/vision) API格式,您也可以用这个格式传送文档进行解析。 + +**POST /v1/chat/completions** + +header 需要设置 Authorization 头部: + +``` +Authorization: Bearer [refresh_token] +``` + +请求数据: +```json +{ + "model": "qwen", + "messages": [ + { + "role": "user", + "content": [ + { + "type": "file", + "file_url": { + "url": "https://img.alicdn.com/imgextra/i1/O1CN01CC9kic1ig1r4sAY5d_!!6000000004441-2-tps-880-210.png" + } + }, + { + "type": "text", + "text": "图像描述了什么?" + } + ] + } + ] +} +``` + +响应数据: +```json +{ + "id": "895fbe7fa22442d499ba67bb5213e842", + "model": "qwen", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "图像展示了通义千问的标志,一个紫色的六边形和一个蓝色的三角形,以及“通义千问”四个白色的汉字。" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + }, + "created": 1712254066 +} +``` ## 注意事项 diff --git a/doc/example-5.png b/doc/example-5.png new file mode 100644 index 0000000..9a3b396 Binary files /dev/null and b/doc/example-5.png differ diff --git a/doc/example-6.png b/doc/example-6.png new file mode 100644 index 0000000..8b15cf4 Binary files /dev/null and b/doc/example-6.png differ diff --git a/package.json b/package.json index 230404e..878baeb 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "cron": "^3.1.6", "date-fns": "^3.3.1", "eventsource-parser": "^1.1.2", + "form-data": "^4.0.0", "fs-extra": "^11.2.0", "koa": "^2.15.0", "koa-body": "^5.0.0", diff --git a/src/api/controllers/chat.ts b/src/api/controllers/chat.ts index 20804ac..a3d673c 100644 --- a/src/api/controllers/chat.ts +++ b/src/api/controllers/chat.ts @@ -4,6 +4,7 @@ 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"; @@ -87,18 +88,20 @@ async function createCompletion( // 提取引用文件URL并上传qwen获得引用的文件ID列表 const refFileUrls = extractRefFileUrls(messages); - // const refs = refFileUrls.length - // ? await Promise.all( - // refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket)) - // ) - // : []; + const refs = refFileUrls.length + ? await Promise.all( + refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket)) + ) + : []; // 请求流 - 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 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", @@ -118,7 +121,7 @@ async function createCompletion( sessionId: "", sessionType: "text_chat", parentMsgId: "", - contents: messagesPrepare(messages), + contents: messagesPrepare(messages, refs), }) ); req.setEncoding("utf8"); @@ -169,16 +172,16 @@ async function createCompletionStream( // 提取引用文件URL并上传qwen获得引用的文件ID列表 const refFileUrls = extractRefFileUrls(messages); - // const refs = refFileUrls.length - // ? await Promise.all( - // refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket)) - // ) - // : []; + const refs = refFileUrls.length + ? await Promise.all( + refFileUrls.map((fileUrl) => uploadFile(fileUrl, ticket)) + ) + : []; // 请求流 session = await new Promise((resolve, reject) => { const session = http2.connect("https://qianwen.biz.aliyun.com"); - session.on('connect', () => resolve(session)); + session.on("connect", () => resolve(session)); session.on("error", reject); }); const req = session.request({ @@ -200,7 +203,7 @@ async function createCompletionStream( sessionId: "", sessionType: "text_chat", parentMsgId: "", - contents: messagesPrepare(messages), + contents: messagesPrepare(messages, refs), }) ); req.setEncoding("utf8"); @@ -307,29 +310,34 @@ async function generateImages( * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 */ function extractRefFileUrls(messages: any[]) { - return messages.reduce((urls, message) => { - if (_.isArray(message.content)) { - message.content.forEach((v) => { - if (!_.isObject(v) || !["file", "image_url"].includes(v["type"])) - return; - // qwen-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"]); - }); - } + 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; } /** @@ -342,12 +350,12 @@ function extractRefFileUrls(messages: any[]) { * * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 */ -function messagesPrepare(messages: any[]) { +function messagesPrepare(messages: any[], refs: any[] = []) { const 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"] || ""); + return _content + `<|im_start|>${message.role || "user"}\n${v["text"] || ""}<|im_end|>\n`; }, content); } return (content += `<|im_start|>${message.role || "user"}\n${ @@ -361,6 +369,7 @@ function messagesPrepare(messages: any[]) { contentType: "text", content, }, + ...refs ]; } @@ -451,10 +460,7 @@ async function receiveStream(stream: any): Promise { } }); // 将流数据喂给SSE转换器 - stream.on("data", (buffer) => { - console.log(buffer.toString()); - parser.feed(buffer.toString()); - }); + stream.on("data", (buffer) => parser.feed(buffer.toString())); stream.once("error", (err) => reject(err)); stream.once("close", () => resolve(data)); stream.end(); @@ -641,6 +647,208 @@ async function receiveImages( }); } +/** + * 获取上传参数 + * + * @param ticket login_tongyi_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 login_tongyi_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' + ].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切分 *