From 6504381d1ed4ccc6f9b98d272d175837fefe8203 Mon Sep 17 00:00:00 2001 From: Vinlic Date: Sat, 7 Dec 2024 04:06:19 +0800 Subject: [PATCH] update --- README.md | 278 +++++++++++++ src/api/controllers/chat.ts | 712 +++++++++------------------------- src/api/controllers/core.ts | 8 +- src/api/controllers/images.ts | 280 +++++++++---- src/api/routes/chat.ts | 10 +- src/api/routes/images.ts | 57 ++- 6 files changed, 711 insertions(+), 634 deletions(-) diff --git a/README.md b/README.md index 5819e4e..37b7bca 100644 --- a/README.md +++ b/README.md @@ -1 +1,279 @@ # Jimeng AI Free 服务 + +[![](https://img.shields.io/github/license/llm-red-team/jimeng-free-api.svg)](LICENSE) +![](https://img.shields.io/github/stars/llm-red-team/jimeng-free-api.svg) +![](https://img.shields.io/github/forks/llm-red-team/jimeng-free-api.svg) +![](https://img.shields.io/docker/pulls/vinlic/jimeng-free-api.svg) + +支持即梦超强图像生成能力(目前官方每日有 60 积分,可生成 60 次),零配置部署,多路 token 支持,自动清理会话痕迹。 + +与 ChatGPT 接口完全兼容。 + +## 目录 + +- [免责声明](#免责声明) +- [接入准备](#接入准备) +- [Docker 部署](#Docker部署) + - [Docker-compose 部署](#Docker-compose部署) +- [Render 部署](#Render部署) +- [Vercel 部署](#Vercel部署) +- [原生部署](#原生部署) +- [推荐使用客户端](#推荐使用客户端) +- [接口列表](#接口列表) + - [对话补全](#对话补全) + - [图像生成](#图像生成) + +## 免责声明 + +**逆向 API 是不稳定的,建议前往即梦 AI 官方 https://jimeng.jianying.com/ 体验功能,避免封禁的风险。** + +**本组织和个人不接受任何资金捐助和交易,此项目是纯粹研究交流学习性质!** + +**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** + +**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** + +**仅限自用,禁止对外提供服务或商用,避免对官方造成服务压力,否则风险自担!** + +## 接入准备 + +从 [即梦](https://jimeng.jianying.com/) 获取 sessionid + +进入即梦登录账号,然后 F12 打开开发者工具,从 Application > Cookies 中找到`sessionid`的值,这将作为 Authorization 的 Bearer Token 值:`Authorization: Bearer sessionid` + +## Docker 部署 + +请准备一台具有公网 IP 的服务器并将 8000 端口开放。 + +拉取镜像并启动服务 + +```shell +docker run -it -d --init --name jimeng-free-api -p 8000:8000 -e TZ=Asia/Shanghai vinlic/jimeng-free-api:latest +``` + +查看服务实时日志 + +```shell +docker logs -f jimeng-free-api +``` + +重启服务 + +```shell +docker restart jimeng-free-api +``` + +停止服务 + +```shell +docker stop jimeng-free-api +``` + +### Docker-compose 部署 + +```yaml +version: "3" + +services: + jimeng-free-api: + container_name: jimeng-free-api + image: vinlic/jimeng-free-api:latest + restart: always + ports: + - "8000:8000" + environment: + - TZ=Asia/Shanghai +``` + +### Render 部署 + +**注意:部分部署区域可能无法连接即梦,如容器日志出现请求超时或无法连接,请切换其他区域部署!** +**注意:免费账户的容器实例将在一段时间不活动时自动停止运行,这会导致下次请求时遇到 50 秒或更长的延迟,建议查看[Render 容器保活](https://github.com/LLM-Red-Team/free-api-hub/#Render%E5%AE%B9%E5%99%A8%E4%BF%9D%E6%B4%BB)** + +1. fork 本项目到你的 github 账号下。 + +2. 访问 [Render](https://dashboard.render.com/) 并登录你的 github 账号。 + +3. 构建你的 Web Service(New+ -> Build and deploy from a Git repository -> Connect 你 fork 的项目 -> 选择部署区域 -> 选择实例类型为 Free -> Create Web Service)。 + +4. 等待构建完成后,复制分配的域名并拼接 URL 访问即可。 + +### Vercel 部署 + +**注意:Vercel 免费账户的请求响应超时时间为 10 秒,但接口响应通常较久,可能会遇到 Vercel 返回的 504 超时错误!** + +请先确保安装了 Node.js 环境。 + +```shell +npm i -g vercel --registry http://registry.npmmirror.com +vercel login +git clone https://github.com/LLM-Red-Team/jimeng-free-api +cd jimeng-free-api +vercel --prod +``` + +## 原生部署 + +请准备一台具有公网 IP 的服务器并将 8000 端口开放。 + +请先安装好 Node.js 环境并且配置好环境变量,确认 node 命令可用。 + +安装依赖 + +```shell +npm i +``` + +安装 PM2 进行进程守护 + +```shell +npm i -g pm2 +``` + +编译构建,看到 dist 目录就是构建完成 + +```shell +npm run build +``` + +启动服务 + +```shell +pm2 start dist/index.js --name "jimeng-free-api" +``` + +查看服务实时日志 + +```shell +pm2 logs jimeng-free-api +``` + +重启服务 + +```shell +pm2 reload jimeng-free-api +``` + +停止服务 + +```shell +pm2 stop jimeng-free-api +``` + +## 推荐使用客户端 + +使用以下二次开发客户端接入 free-api 系列项目更快更简单,支持文档/图像上传! + +由 [Clivia](https://github.com/Yanyutin753/lobe-chat) 二次开发的 LobeChat [https://github.com/Yanyutin753/lobe-chat](https://github.com/Yanyutin753/lobe-chat) + +由 [时光@](https://github.com/SuYxh) 二次开发的 ChatGPT Web [https://github.com/SuYxh/chatgpt-web-sea](https://github.com/SuYxh/chatgpt-web-sea) + +## 接口列表 + +目前支持与 openai 兼容的 `/v1/chat/completions` 接口,可自行使用与 openai 或其他兼容的客户端接入接口,或者使用 [dify](https://dify.ai/) 等线上服务接入使用。 + +### 对话补全 + +对话补全接口,与 openai 的 [chat-completions-api](https://platform.openai.com/docs/guides/text-generation/chat-completions-api) 兼容。 + +**POST /v1/chat/completions** + +header 需要设置 Authorization 头部: + +``` +Authorization: Bearer [sessionid] +``` + +请求数据: + +```json +{ + // jimeng-2.1(默认) / jimeng-2.0-pro / jimeng-2.0 / jimeng-1.4 / jimeng-xl-pro + "model": "jimeng-2.1", + "messages": [ + { + "role": "user", + "content": "少女祈祷中..." + } + ], + // 如果使用SSE流请设置为true,默认false + "stream": false +} +``` + +响应数据: + +```json +{ + "id": "b88fe520-b40c-11ef-8c0b-cf7f4ccae1fb", + "model": "jimeng-2.1", + "object": "chat.completion", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "![image_0](https://p9-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/29bc1377c1354232a2747ddf443000b5~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=fDT67RrcwTWTeFA69HX7oHKdKTI%3D&format=.webp)\n![image_1](https://p3-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/5e51a41521824afbb280a3a188f65f13~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=oXxyNCPCUXf2SmEQqAH%2B1ecnMI8%3D&format=.webp)\n![image_2](https://p9-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/4580ce92e2574ab6b487134500ca92db~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=9GRYzJJeH0LUMprIFKni7PVuNoo%3D&format=.webp)\n![image_3](https://p6-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/0ce51429b38d49f289f5eecab516b243~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=hkqcRM%2B6PawaHxVIi0FuA0j3wSY%3D&format=.webp)\n" + }, + "finish_reason": "stop" + } + ], + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2 + }, + "created": 1733515220 +} +``` + +### 图像生成 + +图像生成接口,与 openai 的 [images-create-api](https://platform.openai.com/docs/api-reference/images/create) 兼容。 + +**POST /v1/images/generations** + +header 需要设置 Authorization 头部: + +``` +Authorization: Bearer [sessionid] +``` + +请求数据: + +```json +{ + // 提示词,必填 + "prompt": "少女祈祷中...", + // 反向提示词,默认空字符串 + "negativePrompt": "", + // 图像宽度,默认1024 + "width": 1024, + // 图像高度,默认1024 + "height": 1024, + // 精细度,取值范围0-1,默认0.5 + "sample_strength": 0.5 +} +``` + +响应数据: + +```json +{ + "created": 1733515457, + "data": [ + { + "url": "https://p6-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/82e1c62b410c4d56b16ae81e4b758630~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=BGdScKQ8NLzMmsq%2FNJsAoeT7YKw%3D&format=.webp" + }, + { + "url": "https://p3-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/6d1fe992c76d4f5dac28b1fc6f26eca0~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=acKpfgKU353NOpcQZ%2F%2Bt6ZOAU24%3D&format=.webp" + }, + { + "url": "https://p3-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/ba251a7358f44945a28b33b89d4cd85d~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=Efa7AQoj4zd31RGZs2nmq%2FUgCp4%3D&format=.webp" + }, + { + "url": "https://p6-heycan-hgt-sign.byteimg.com/tos-cn-i-3jr8j4ixpe/1de2c37bc8734c32b1eb8c903b9c81f2~tplv-3jr8j4ixpe-aigc_resize:2048:2048.webp?lk3s=43402efa&x-expires=1735344000&x-signature=Tif2ZX0wYOhdrwjB0SXrSlu0EJc%3D&format=.webp" + } + ] +} +``` diff --git a/src/api/controllers/chat.ts b/src/api/controllers/chat.ts index 7bad303..c31bf5b 100644 --- a/src/api/controllers/chat.ts +++ b/src/api/controllers/chat.ts @@ -1,35 +1,31 @@ -import _ from 'lodash'; -import { createParser } from 'eventsource-parser'; -import { PassThrough } from 'stream'; +import _ from "lodash"; +import { PassThrough } from "stream"; -import APIException from '@/lib/exceptions/APIException.ts'; -import EX from '@/api/consts/exceptions.ts'; -import logger from '@/lib/logger.ts'; -import util from '@/lib/util.ts'; -import { request, uploadFile } from './core.ts'; +import APIException from "@/lib/exceptions/APIException.ts"; +import EX from "@/api/consts/exceptions.ts"; +import logger from "@/lib/logger.ts"; +import util from "@/lib/util.ts"; +import { generateImages, DEFAULT_MODEL } from "./images.ts"; -// 模型名称 -const MODEL_NAME = "jimeng"; -// 默认的AgentID -const DEFAULT_ASSISTANT_ID = "513695"; // 最大重试次数 const MAX_RETRY_COUNT = 3; // 重试延迟 const RETRY_DELAY = 5000; /** - * 移除会话 + * 解析模型 * - * 在对话流传输完毕后移除会话,避免创建的会话出现在用户的对话列表中 - * - * @param refreshToken 用于刷新access_token的refresh_token + * @param model 模型名称 + * @returns 模型信息 */ -export async function removeConversation(convId: string, refreshToken: string) { - await request("post", "/samantha/thread/delete", refreshToken, { - data: { - conversation_id: convId, - }, - }); +function parseModel(model: string) { + const [_model, size] = model.split(":"); + const [_, width, height] = /(\d+)[\W\w](\d+)/.exec(size) ?? []; + return { + model: _model, + width: size ? Math.ceil(parseInt(width) / 2) * 2 : 1024, + height: size ? Math.ceil(parseInt(height) / 2) * 2 : 1024, + }; } /** @@ -41,98 +37,59 @@ export async function removeConversation(convId: string, refreshToken: string) { * @param retryCount 重试次数 */ export async function createCompletion( - messages: any[], - refreshToken: string, - assistantId = DEFAULT_ASSISTANT_ID, - refConvId = "", - retryCount = 0 + messages: any[], + refreshToken: string, + _model = DEFAULT_MODEL, + retryCount = 0 ) { - return (async () => { - logger.info(messages); - - // 提取引用文件URL并上传获得引用的文件ID列表 - const refFileUrls = extractRefFileUrls(messages); - const refs = refFileUrls.length - ? await Promise.all( - refFileUrls.map((fileUrl) => uploadFile(fileUrl, refreshToken)) - ) - : []; - - // 如果引用对话ID不正确则重置引用 - if (!/[0-9a-zA-Z]{24}/.test(refConvId)) refConvId = ""; - - // 请求流 - const response = await request( - "post", - "/samantha/chat/completion", - refreshToken, - { - data: { - messages: messagesPrepare(messages, refs, !!refConvId), - completion_option: { - is_regen: false, - with_suggest: true, - need_create_conversation: true, - launch_stage: 1, - is_replace: false, - is_delete: false, - message_from: 0, - event_id: "0", - }, - conversation_id: "0", - local_conversation_id: `local_16${util.generateRandomString({ - length: 14, - charset: "numeric", - })}`, - local_message_id: util.uuid(), - }, - headers: { - Referer: "https://www.jimeng.com/chat/", - "Agw-js-conv": "str", - }, - // 300秒超时 - timeout: 300000, - responseType: "stream", - } - ); - if (response.headers["content-type"].indexOf("text/event-stream") == -1) { - response.data.on("data", (buffer) => logger.error(buffer.toString())); - throw new APIException( - EX.API_REQUEST_FAILED, - `Stream response Content-Type invalid: ${response.headers["content-type"]}` - ); - } - - const streamStartTime = util.timestamp(); - // 接收流为输出文本 - const answer = await receiveStream(response.data); - logger.success( - `Stream has completed transfer ${util.timestamp() - streamStartTime}ms` - ); - - // 异步移除会话 - removeConversation(answer.id, refreshToken).catch( - (err) => !refConvId && console.error("移除会话失败:", err) - ); + return (async () => { + if (messages.length === 0) + throw new APIException(EX.API_REQUEST_PARAMS_INVALID, "消息不能为空"); + + const { model, width, height } = parseModel(_model); + logger.info(messages); + + const imageUrls = await generateImages( + model, + messages[messages.length - 1].content, + { + width, + height, + }, + refreshToken + ); - return answer; - })().catch((err) => { - if (retryCount < MAX_RETRY_COUNT) { - logger.error(`Stream response error: ${err.stack}`); - logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); - return (async () => { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); - return createCompletion( - messages, - refreshToken, - assistantId, - refConvId, - retryCount + 1 - ); - })(); - } - throw err; - }); + return { + id: util.uuid(), + model: _model || model, + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: imageUrls.reduce( + (acc, url, i) => acc + `![image_${i}](${url})\n`, + "" + ), + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + created: util.unixTimestamp(), + }; + })().catch((err) => { + if (retryCount < MAX_RETRY_COUNT) { + logger.error(`Response error: ${err.stack}`); + logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); + return (async () => { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + return createCompletion(messages, refreshToken, _model, retryCount + 1); + })(); + } + throw err; + }); } /** @@ -144,434 +101,121 @@ export async function createCompletion( * @param retryCount 重试次数 */ export async function createCompletionStream( - messages: any[], - refreshToken: string, - assistantId = DEFAULT_ASSISTANT_ID, - refConvId = "", - retryCount = 0 + messages: any[], + refreshToken: string, + _model = DEFAULT_MODEL, + retryCount = 0 ) { - return (async () => { - logger.info(messages); - - // 提取引用文件URL并上传获得引用的文件ID列表 - const refFileUrls = extractRefFileUrls(messages); - const refs = refFileUrls.length - ? await Promise.all( - refFileUrls.map((fileUrl) => uploadFile(fileUrl, refreshToken)) - ) - : []; - - // 如果引用对话ID不正确则重置引用 - if (!/[0-9a-zA-Z]{24}/.test(refConvId)) refConvId = ""; - - // 请求流 - const response = await request( - "post", - "/samantha/chat/completion", - refreshToken, + return (async () => { + const { model, width, height } = parseModel(_model); + logger.info(messages); + + const stream = new PassThrough(); + + stream.write( + "data: " + + JSON.stringify({ + id: util.uuid(), + model: _model || model, + object: "chat.completion.chunk", + choices: [ { - data: { - messages: messagesPrepare(messages, refs, !!refConvId), - completion_option: { - is_regen: false, - with_suggest: true, - need_create_conversation: true, - launch_stage: 1, - is_replace: false, - is_delete: false, - message_from: 0, - event_id: "0", - }, - conversation_id: "0", - local_conversation_id: `local_16${util.generateRandomString({ - length: 14, - charset: "numeric", - })}`, - local_message_id: util.uuid(), - }, - headers: { - Referer: "https://www.jimeng.com/chat/", - "Agw-js-conv": "str", - }, - // 300秒超时 - timeout: 300000, - responseType: "stream", - } - ); - - if (response.headers["content-type"].indexOf("text/event-stream") == -1) { - logger.error( - `Invalid response Content-Type:`, - response.headers["content-type"] - ); - response.data.on("data", (buffer) => logger.error(buffer.toString())); - const transStream = new PassThrough(); - transStream.end( - `data: ${JSON.stringify({ - id: "", - model: MODEL_NAME, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { - role: "assistant", - content: "服务暂时不可用,第三方响应错误", - }, - finish_reason: "stop", - }, - ], - usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, - created: util.unixTimestamp(), - })}\n\n` - ); - return transStream; - } - - const streamStartTime = util.timestamp(); - // 创建转换流将消息格式转换为gpt兼容格式 - return createTransStream(response.data, (convId: string) => { - logger.success( - `Stream has completed transfer ${util.timestamp() - streamStartTime}ms` - ); - // 流传输结束后异步移除会话 - removeConversation(convId, refreshToken).catch( - (err) => !refConvId && console.error(err) - ); - }); - })().catch((err) => { - if (retryCount < MAX_RETRY_COUNT) { - logger.error(`Stream response error: ${err.stack}`); - logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); - return (async () => { - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); - return createCompletionStream( - messages, - refreshToken, - assistantId, - refConvId, - retryCount + 1 - ); - })(); - } - throw err; - }); -} - -/** - * 提取消息中引用的文件URL - * - * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 - */ -export 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; - // jimeng-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; -} - -/** - * 消息预处理 - * - * 由于接口只取第一条消息,此处会将多条消息合并为一条,实现多轮对话效果 - * - * @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文 - * @param refs 参考文件列表 - * @param isRefConv 是否为引用会话 - */ -export 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 { - // 检查最新消息是否含有"type": "image_url"或"type": "file",如果有则注入消息 - let latestMessage = messages[messages.length - 1]; - let hasFileOrImage = - Array.isArray(latestMessage.content) && - latestMessage.content.some( - (v) => - typeof v === "object" && ["file", "image_url"].includes(v["type"]) - ); - if (hasFileOrImage) { - let newFileMessage = { - content: "关注用户最新发送文件和消息", - role: "system", - }; - messages.splice(messages.length - 1, 0, newFileMessage); - logger.info("注入提升尾部文件注意力system prompt"); - } else { - // 由于注入会导致设定污染,暂时注释 - // let newTextMessage = { - // content: "关注用户最新的消息", - // role: "system", - // }; - // messages.splice(messages.length - 1, 0, newTextMessage); - // logger.info("注入提升尾部消息注意力system prompt"); - } - content = messages - .reduce((content, message) => { - const role = message.role - .replace("system", "<|im_start|>system") - .replace("assistant", "<|im_start|>assistant") - .replace("user", "<|im_start|>user"); - if (_.isArray(message.content)) { - return message.content.reduce((_content, v) => { - if (!_.isObject(v) || v["type"] != "text") return _content; - return _content + (`${role}\n` + v["text"] || "") + "\n"; - }, content); - } - return (content += `${role}\n${message.content}\n`) + "<|im_end|>\n"; - }, "") - // 移除MD图像URL避免幻觉 - .replace(/\!\[.+\]\(.+\)/g, "") - // 移除临时路径避免在新会话引发幻觉 - .replace(/\/mnt\/data\/.+/g, ""); - logger.info("\n对话合并:\n" + content); - } - - const fileRefs = refs.filter((ref) => !ref.width && !ref.height); - const imageRefs = refs - .filter((ref) => ref.width || ref.height) - .map((ref) => { - ref.image_url = ref.file_url; - return ref; - }); - return [ - { - content: JSON.stringify({ text: content }), - content_type: 2001, - attachments: [], - references: [], - }, - ]; -} - -/** - * 从流接收完整的消息内容 - * - * @param stream 消息流 - */ -export async function receiveStream(stream: any): Promise { - 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(), - }; - let isEnd = false; - const parser = createParser((event) => { - try { - if (event.type !== "event" || isEnd) return; - // 解析JSON - const rawResult = _.attempt(() => JSON.parse(event.data)); - if (_.isError(rawResult)) - throw new Error(`Stream response invalid: ${event.data}`); - // console.log(rawResult); - if (rawResult.code) - throw new APIException( - EX.API_REQUEST_FAILED, - `[请求jimeng失败]: ${rawResult.code}-${rawResult.message}` - ); - if (rawResult.event_type == 2003) { - isEnd = true; - data.choices[0].message.content = - data.choices[0].message.content.replace(/\n$/, ""); - return resolve(data); - } - if (rawResult.event_type != 2001) return; - const result = _.attempt(() => JSON.parse(rawResult.event_data)); - if (_.isError(result)) - throw new Error(`Stream response invalid: ${rawResult.event_data}`); - if (result.is_finish) { - isEnd = true; - data.choices[0].message.content = - data.choices[0].message.content.replace(/\n$/, ""); - return resolve(data); - } - if (!data.id && result.conversation_id) - data.id = result.conversation_id; - const message = result.message; - if (!message || ![2001, 2008].includes(message.content_type)) return; - const content = JSON.parse(message.content); - if (content.text) data.choices[0].message.content += content.text; - } 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)); - }); -} + index: 0, + delta: { role: "assistant", content: "🎨 图像生成中,请稍候..." }, + finish_reason: null, + }, + ], + }) + + "\n\n" + ); -/** - * 创建转换流 - * - * 将流格式转换为gpt兼容流格式 - * - * @param stream 消息流 - * @param endCallback 传输结束回调 - */ -export function createTransStream(stream: any, endCallback?: Function) { - let isEnd = false; - let convId = ""; - // 消息创建时间 - const created = util.unixTimestamp(); - // 创建转换流 - const transStream = new PassThrough(); - !transStream.closed && - transStream.write( - `data: ${JSON.stringify({ - id: convId, - model: MODEL_NAME, + generateImages( + model, + messages.reduce((acc, message) => acc + message.content, ""), + { width, height }, + refreshToken + ) + .then((imageUrls) => { + for (let i = 0; i < imageUrls.length; i++) { + const url = imageUrls[i]; + stream.write( + "data: " + + JSON.stringify({ + id: util.uuid(), + model: _model || model, object: "chat.completion.chunk", choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: null, + { + index: i + 1, + delta: { + role: "assistant", + content: `![image_${i}](${url})\n`, }, + finish_reason: i < imageUrls.length - 1 ? null : "stop", + }, ], - created, - })}\n\n` - ); - const parser = createParser((event) => { - try { - if (event.type !== "event") return; - // 解析JSON - const rawResult = _.attempt(() => JSON.parse(event.data)); - if (_.isError(rawResult)) - throw new Error(`Stream response invalid: ${event.data}`); - // console.log(rawResult); - if (rawResult.code) - throw new APIException( - EX.API_REQUEST_FAILED, - `[请求jimeng失败]: ${rawResult.code}-${rawResult.message}` - ); - if (rawResult.event_type == 2003) { - isEnd = true; - transStream.write( - `data: ${JSON.stringify({ - id: convId, - model: MODEL_NAME, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: "stop", - }, - ], - created, - })}\n\n` - ); - !transStream.closed && transStream.end("data: [DONE]\n\n"); - endCallback && endCallback(convId); - return; - } - if (rawResult.event_type != 2001) return; - const result = _.attempt(() => JSON.parse(rawResult.event_data)); - if (_.isError(result)) - throw new Error(`Stream response invalid: ${rawResult.event_data}`); - if (!convId) convId = result.conversation_id; - if (result.is_finish) { - isEnd = true; - transStream.write( - `data: ${JSON.stringify({ - id: convId, - model: MODEL_NAME, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { role: "assistant", content: "" }, - finish_reason: "stop", - }, - ], - created, - })}\n\n` - ); - !transStream.closed && transStream.end("data: [DONE]\n\n"); - endCallback && endCallback(convId); - return; - } - const message = result.message; - if (!message || ![2001, 2008].includes(message.content_type)) return; - const content = JSON.parse(message.content); - transStream.write( - `data: ${JSON.stringify({ - id: convId, - model: MODEL_NAME, - object: "chat.completion.chunk", - choices: [ - { - index: 0, - delta: { role: "assistant", content: content.text }, - finish_reason: null, - }, - ], - created, - })}\n\n` - ); - } catch (err) { - logger.error(err); - !transStream.closed && transStream.end("\n\n"); + }) + + "\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") - ); - return transStream; + stream.write( + "data: " + + JSON.stringify({ + id: util.uuid(), + model: _model || model, + object: "chat.completion.chunk", + choices: [ + { + index: imageUrls.length + 1, + delta: { + role: "assistant", + content: "图像生成完成!", + }, + finish_reason: "stop", + }, + ], + }) + + "\n\n" + ); + stream.end("data: [DONE]\n\n"); + }) + .catch((err) => { + stream.write( + "data: " + + JSON.stringify({ + id: util.uuid(), + model: _model || model, + object: "chat.completion.chunk", + choices: [ + { + index: 1, + delta: { + role: "assistant", + content: `生成图片失败: ${err.message}`, + }, + finish_reason: "stop", + }, + ], + }) + + "\n\n" + ); + stream.end("data: [DONE]\n\n"); + }); + return stream; + })().catch((err) => { + if (retryCount < MAX_RETRY_COUNT) { + logger.error(`Response error: ${err.stack}`); + logger.warn(`Try again after ${RETRY_DELAY / 1000}s...`); + return (async () => { + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY)); + return createCompletionStream( + messages, + refreshToken, + _model, + retryCount + 1 + ); + })(); + } + throw err; + }); } diff --git a/src/api/controllers/core.ts b/src/api/controllers/core.ts index 76891d0..383449f 100644 --- a/src/api/controllers/core.ts +++ b/src/api/controllers/core.ts @@ -206,10 +206,10 @@ export async function uploadFile( */ export function checkResult(result: AxiosResponse) { if (!result.data) return null; - const { code, msg, data } = result.data; - if (!_.isFinite(code)) return result.data; - if (code === 0) return data; - throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${msg}`); + const { ret, errmsg, data } = result.data; + if (!_.isFinite(Number(ret))) return result.data; + if (ret === "0") return data; + throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${errmsg}`); } /** diff --git a/src/api/controllers/images.ts b/src/api/controllers/images.ts index d5c36fb..c9984b1 100644 --- a/src/api/controllers/images.ts +++ b/src/api/controllers/images.ts @@ -1,10 +1,14 @@ import _ from "lodash"; +import APIException from "@/lib/exceptions/APIException.ts"; +import EX from "@/api/consts/exceptions.ts"; import util from "@/lib/util.ts"; import { request } from "./core.ts"; +import logger from "@/lib/logger.ts"; const DEFAULT_ASSISTANT_ID = "513695"; -const DEFAULT_MODEL = "jimeng-2.1"; +export const DEFAULT_MODEL = "jimeng-2.1"; +const DRAFT_VERSION = "3.0.2"; const MODEL_MAP = { "jimeng-2.1": "high_aes_general_v21_L:general_v2.1_L", "jimeng-2.0-pro": "high_aes_general_v20_L:general_v2.0_L", @@ -17,89 +21,225 @@ export function getModel(model: string) { return MODEL_MAP[model] || MODEL_MAP[DEFAULT_MODEL]; } -export async function generateImages(model: string, prompt: string, refreshToken: string) { - model = getModel(model); +export async function generateImages( + _model: string, + prompt: string, + { + width = 1024, + height = 1024, + sampleStrength = 0.5, + negativePrompt = "", + }: { + width?: number; + height?: number; + sampleStrength?: number; + negativePrompt?: string; + }, + refreshToken: string +) { + const model = getModel(_model); + logger.info(`使用模型: ${_model} 映射模型: ${model} ${width}x${height} 精细度: ${sampleStrength}`); const componentId = util.uuid(); - const result = await request("post", "/mweb/v1/aigc_draft/generate", refreshToken, { - params: { - babi_param: encodeURIComponent( - JSON.stringify({ - scenario: "image_video_generation", - feature_key: "aigc_to_image", - feature_entrance: "to_image", - feature_entrance_detail: - "to_image-" + model, - }) - ), - }, - data: { - extend: { - root_model: model, - template_id: "", + const { aigc_data } = await request( + "post", + "/mweb/v1/aigc_draft/generate", + refreshToken, + { + params: { + babi_param: encodeURIComponent( + JSON.stringify({ + scenario: "image_video_generation", + feature_key: "aigc_to_image", + feature_entrance: "to_image", + feature_entrance_detail: "to_image-" + model, + }) + ), }, - submit_id: util.uuid(), - metrics_extra: JSON.stringify({ - templateId: "", - generateCount: 1, - promptSource: "custom", - templateSource: "", - lastRequestId: "", - originRequestId: "", - }), - draft_content: JSON.stringify({ - type: "draft", - id: util.uuid(), - min_version: "3.0.2", - is_from_tsn: true, - version: "3.0.2", - main_component_id: componentId, - component_list: [ - { - type: "image_base_component", - id: componentId, - min_version: "3.0.2", - generate_type: "generate", - aigc_mode: "workbench", - abilities: { - type: "", - id: util.uuid(), - generate: { + data: { + extend: { + root_model: model, + template_id: "", + }, + submit_id: util.uuid(), + metrics_extra: JSON.stringify({ + templateId: "", + generateCount: 1, + promptSource: "custom", + templateSource: "", + lastRequestId: "", + originRequestId: "", + }), + draft_content: JSON.stringify({ + type: "draft", + id: util.uuid(), + min_version: DRAFT_VERSION, + is_from_tsn: true, + version: DRAFT_VERSION, + main_component_id: componentId, + component_list: [ + { + type: "image_base_component", + id: componentId, + min_version: DRAFT_VERSION, + generate_type: "generate", + aigc_mode: "workbench", + abilities: { type: "", id: util.uuid(), - core_param: { + generate: { type: "", id: util.uuid(), - model, - prompt, - negative_prompt: "", - seed: 2569958340, - sample_strength: 0.5, - image_ratio: 1, - large_image_info: { + core_param: { + type: "", + id: util.uuid(), + model, + prompt, + negative_prompt: negativePrompt, + seed: Math.floor(Math.random() * 100000000) + 2500000000, + sample_strength: sampleStrength, + image_ratio: 1, + large_image_info: { + type: "", + id: util.uuid(), + height, + width, + }, + }, + history_option: { type: "", id: util.uuid(), - height: 1024, - width: 1024, }, - }, - history_option: { - type: "", - id: util.uuid(), }, }, }, - }, - ], - }), - http_common_info: { - aid: Number(DEFAULT_ASSISTANT_ID), + ], + }), + http_common_info: { + aid: Number(DEFAULT_ASSISTANT_ID), + }, + }, + } + ); + const historyId = aigc_data.history_record_id; + if (!historyId) + throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录ID不存在"); + let status = 20, failCode, item_list = []; + while (status === 20) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const result = await request("post", "/mweb/v1/get_history_by_ids", refreshToken, { + data: { + history_ids: [historyId], + image_info: { + width: 2048, + height: 2048, + format: "webp", + image_scene_list: [ + { + scene: "smart_crop", + width: 360, + height: 360, + uniq_key: "smart_crop-w:360-h:360", + format: "webp", + }, + { + scene: "smart_crop", + width: 480, + height: 480, + uniq_key: "smart_crop-w:480-h:480", + format: "webp", + }, + { + scene: "smart_crop", + width: 720, + height: 720, + uniq_key: "smart_crop-w:720-h:720", + format: "webp", + }, + { + scene: "smart_crop", + width: 720, + height: 480, + uniq_key: "smart_crop-w:720-h:480", + format: "webp", + }, + { + scene: "smart_crop", + width: 360, + height: 240, + uniq_key: "smart_crop-w:360-h:240", + format: "webp", + }, + { + scene: "smart_crop", + width: 240, + height: 320, + uniq_key: "smart_crop-w:240-h:320", + format: "webp", + }, + { + scene: "smart_crop", + width: 480, + height: 640, + uniq_key: "smart_crop-w:480-h:640", + format: "webp", + }, + { + scene: "normal", + width: 2400, + height: 2400, + uniq_key: "2400", + format: "webp", + }, + { + scene: "normal", + width: 1080, + height: 1080, + uniq_key: "1080", + format: "webp", + }, + { + scene: "normal", + width: 720, + height: 720, + uniq_key: "720", + format: "webp", + }, + { + scene: "normal", + width: 480, + height: 480, + uniq_key: "480", + format: "webp", + }, + { + scene: "normal", + width: 360, + height: 360, + uniq_key: "360", + format: "webp", + }, + ], + }, + http_common_info: { + aid: Number(DEFAULT_ASSISTANT_ID), + }, }, - }, - }); - console.log(result); - return result; + }); + if (!result[historyId]) + throw new APIException(EX.API_IMAGE_GENERATION_FAILED, "记录不存在"); + status = result[historyId].status; + failCode = result[historyId].fail_code; + item_list = result[historyId].item_list; + } + if (status === 30) { + if (failCode === '2038') + throw new APIException(EX.API_CONTENT_FILTERED); + else + throw new APIException(EX.API_IMAGE_GENERATION_FAILED); + } + return item_list.map((item) => item.common_attr.cover_url); } export default { - generateImages + generateImages, }; diff --git a/src/api/routes/chat.ts b/src/api/routes/chat.ts index 971aca2..405f21b 100644 --- a/src/api/routes/chat.ts +++ b/src/api/routes/chat.ts @@ -4,7 +4,6 @@ import Request from '@/lib/request/Request.ts'; import Response from '@/lib/response/Response.ts'; import { tokenSplit } from '@/api/controllers/core.ts'; import { createCompletion, createCompletionStream } from '@/api/controllers/chat.ts'; -import logger from '@/lib/logger.ts'; export default { @@ -14,23 +13,22 @@ export default { '/completions': async (request: Request) => { request - .validate('body.conversation_id', v => _.isUndefined(v) || _.isString(v)) + .validate('body.model', v => _.isUndefined(v) || _.isString(v)) .validate('body.messages', _.isArray) .validate('headers.authorization', _.isString) // refresh_token切分 const tokens = tokenSplit(request.headers.authorization); // 随机挑选一个refresh_token const token = _.sample(tokens); - const { model, conversation_id: convId, messages, stream } = request.body; - const assistantId = /^[a-z0-9]{24,}$/.test(model) ? model : undefined + const { model, messages, stream } = request.body; if (stream) { - const stream = await createCompletionStream(messages, token, assistantId, convId); + const stream = await createCompletionStream(messages, token, model); return new Response(stream, { type: "text/event-stream" }); } else - return await createCompletion(messages, token, assistantId, convId); + return await createCompletion(messages, token, model); } } diff --git a/src/api/routes/images.ts b/src/api/routes/images.ts index fd2a661..cc398db 100644 --- a/src/api/routes/images.ts +++ b/src/api/routes/images.ts @@ -3,6 +3,7 @@ import _ from "lodash"; import Request from "@/lib/request/Request.ts"; import { generateImages } from "@/api/controllers/images.ts"; import { tokenSplit } from "@/api/controllers/core.ts"; +import util from "@/lib/util.ts"; export default { prefix: "/v1/images", @@ -10,32 +11,48 @@ export default { post: { "/generations": async (request: Request) => { request + .validate("body.model", v => _.isUndefined(v) || _.isString(v)) .validate("body.prompt", _.isString) + .validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v)) + .validate("body.width", v => _.isUndefined(v) || _.isFinite(v)) + .validate("body.height", v => _.isUndefined(v) || _.isFinite(v)) + .validate("body.sample_strength", v => _.isUndefined(v) || _.isFinite(v)) + .validate("body.response_format", v => _.isUndefined(v) || _.isString(v)) .validate("headers.authorization", _.isString); // refresh_token切分 const tokens = tokenSplit(request.headers.authorization); // 随机挑选一个refresh_token const token = _.sample(tokens); - const prompt = request.body.prompt; - const responseFormat = _.defaultTo(request.body.response_format, "url"); - const assistantId = /^[a-z0-9]{24,}$/.test(request.body.model) ? request.body.model : undefined; - const result = await generateImages(assistantId, prompt, token); - return result; - // const imageUrls = await image.generateImages(assistantId, prompt, token); - // let data = []; - // if (responseFormat == "b64_json") { - // data = ( - // await Promise.all(imageUrls.map((url) => util.fetchFileBASE64(url))) - // ).map((b64) => ({ b64_json: b64 })); - // } else { - // data = imageUrls.map((url) => ({ - // url, - // })); - // } - // return { - // created: util.unixTimestamp(), - // data, - // }; + const { + model, + prompt, + negative_prompt: negativePrompt, + width, + height, + sample_strength: sampleStrength, + response_format, + } = request.body; + const responseFormat = _.defaultTo(response_format, "url"); + const imageUrls = await generateImages(model, prompt, { + width, + height, + sampleStrength, + negativePrompt, + }, token); + let data = []; + if (responseFormat == "b64_json") { + data = ( + await Promise.all(imageUrls.map((url) => util.fetchFileBASE64(url))) + ).map((b64) => ({ b64_json: b64 })); + } else { + data = imageUrls.map((url) => ({ + url, + })); + } + return { + created: util.unixTimestamp(), + data, + }; }, }, };