Initial commit: jimeng-free-api-all project
This commit is contained in:
commit
5c92d6ee63
|
|
@ -0,0 +1,16 @@
|
|||
logs
|
||||
dist
|
||||
doc
|
||||
node_modules
|
||||
.vscode
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
*.tar.gz
|
||||
tmp
|
||||
*.png
|
||||
*.jpeg
|
||||
*.jpg
|
||||
*.mp4
|
||||
.ipynb_checkpoints
|
||||
curl*.txt
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# Auto detect text files and perform LF normalization
|
||||
* text=auto
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
dist/
|
||||
node_modules/
|
||||
logs/
|
||||
.vercel
|
||||
.ipynb_checkpoints/文生视频首位帧-checkpoint.txt
|
||||
文生视频首位帧.txt
|
||||
yarn.lock
|
||||
jimeng-free-api-all.tar
|
||||
yarn.lock
|
||||
.claude/settings.local.json
|
||||
.ipynb_checkpoints/Dockerfile-checkpoint
|
||||
.ipynb_checkpoints/README-checkpoint.md
|
||||
.ipynb_checkpoints/package-checkpoint.json
|
||||
.ipynb_checkpoints/tsconfig-checkpoint.json
|
||||
src/api/controllers/.ipynb_checkpoints/images-checkpoint.ts
|
||||
20260207请求.txt
|
||||
curl(node.js)_Seedance 2.0.txt
|
||||
.ipynb_checkpoints/20260207请求-checkpoint.txt
|
||||
11.jpg
|
||||
22.png
|
||||
curl(node.js)download.txt
|
||||
.ipynb_checkpoints/11-checkpoint.jpg
|
||||
.ipynb_checkpoints/22-checkpoint.png
|
||||
.ipynb_checkpoints/curl(node.js)_Seedance 2.0-checkpoint.txt
|
||||
README.md
|
||||
curl(node.js)_jimeng-5.0preview-txt
|
||||
curl(node.js)_jimeng-4.6.txt
|
||||
.ipynb_checkpoints/CLAUDE-checkpoint.md
|
||||
curl(nodejs)-seedance2.0fast.txt
|
||||
.ipynb_checkpoints/test-seedance-media-checkpoint.py
|
||||
.ipynb_checkpoints/test-seedance-media-checkpoint.sh
|
||||
curl(nodejs)-seedance2.0fast+声音.txt
|
||||
curl(nodejs)-seedance2.0fast+声音2.txt
|
||||
|
|
@ -0,0 +1,326 @@
|
|||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/claude-code) 在此代码仓库中工作时提供指导。
|
||||
|
||||
## 项目概述
|
||||
|
||||
即梦 AI 免费 API 服务 - 逆向工程的 API 服务器,提供 OpenAI 兼容接口,封装即梦 AI 的图像和视频生成能力。
|
||||
|
||||
**版本:** v0.8.6
|
||||
|
||||
**核心功能:**
|
||||
- 文生图:支持 jimeng-5.0、jimeng-4.6、jimeng-4.5 等多款模型,最高 4K 分辨率
|
||||
- 图生图:多图合成,支持 1-10 张输入图片
|
||||
- 视频生成:jimeng-video-3.5-pro 等模型,支持首帧/尾帧控制
|
||||
- Seedance 2.0:多模态智能视频生成,模型名 `jimeng-video-seedance-2.0`(兼容 `seedance-2.0`),支持图片/视频/音频混合上传,@1、@2 占位符引用素材,4-15 秒时长
|
||||
- OpenAI 兼容:完全兼容 OpenAI API 格式,无缝对接现有客户端
|
||||
- 多账号支持:支持多个 sessionid 轮询使用
|
||||
|
||||
## 构建和开发命令
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 安装 Chromium 浏览器(Seedance 模型需要)
|
||||
npx playwright-core install chromium --with-deps
|
||||
|
||||
# 开发模式(热重载)
|
||||
npm run dev
|
||||
|
||||
# 生产环境构建
|
||||
npm run build
|
||||
|
||||
# 启动生产服务
|
||||
npm start
|
||||
```
|
||||
|
||||
## Docker 命令
|
||||
|
||||
```bash
|
||||
# 构建 Docker 镜像
|
||||
docker build -t jimeng-free-api-all:latest .
|
||||
|
||||
# 运行容器
|
||||
docker run -it -d --init --name jimeng-free-api-all -p 8000:8000 -e TZ=Asia/Shanghai jimeng-free-api-all:latest
|
||||
|
||||
# 使用 Docker Hub 预构建镜像
|
||||
docker pull wwwzhouhui569/jimeng-free-api-all:latest
|
||||
docker run -it -d --init --name jimeng-free-api-all -p 8000:8000 -e TZ=Asia/Shanghai wwwzhouhui569/jimeng-free-api-all:latest
|
||||
```
|
||||
|
||||
## 项目架构
|
||||
|
||||
```
|
||||
src/
|
||||
├── index.ts # 应用入口
|
||||
├── daemon.ts # 守护进程管理
|
||||
├── api/
|
||||
│ ├── controllers/ # 业务逻辑控制器
|
||||
│ │ ├── core.ts # 核心工具(Token处理、积分管理、请求封装)
|
||||
│ │ ├── images.ts # 图像生成逻辑(文生图、图生图)
|
||||
│ │ ├── videos.ts # 视频生成逻辑(含 Seedance 2.0)
|
||||
│ │ └── chat.ts # 对话补全逻辑
|
||||
│ ├── routes/ # API 路由定义
|
||||
│ │ ├── index.ts # 路由聚合器
|
||||
│ │ ├── images.ts # /v1/images/* 端点
|
||||
│ │ ├── videos.ts # /v1/videos/* 端点
|
||||
│ │ ├── video.ts # /v1/video/* 端点(videos 的包装路由)
|
||||
│ │ ├── chat.ts # /v1/chat/* 端点
|
||||
│ │ ├── models.ts # /v1/models 端点
|
||||
│ │ ├── ping.ts # /ping 健康检查端点
|
||||
│ │ └── token.ts # /token/* Token管理端点
|
||||
│ └── consts/
|
||||
│ └── exceptions.ts # API 异常定义
|
||||
└── lib/
|
||||
├── server.ts # Koa 服务器配置(含中间件栈)
|
||||
├── browser-service.ts # 浏览器代理服务(Seedance shark 反爬绕过)
|
||||
├── config.ts # 配置管理
|
||||
├── logger.ts # 日志工具
|
||||
├── util.ts # 辅助工具函数
|
||||
├── environment.ts # 环境变量
|
||||
├── initialize.ts # 初始化逻辑
|
||||
├── http-status-codes.ts # HTTP 状态码
|
||||
├── request/
|
||||
│ └── Request.ts # 请求解析与验证(含文件上传规范化)
|
||||
├── response/
|
||||
│ ├── Response.ts # 响应包装器
|
||||
│ ├── Body.ts # 响应体
|
||||
│ └── FailureBody.ts # 错误响应体
|
||||
├── exceptions/
|
||||
│ ├── Exception.ts # 基础异常类
|
||||
│ └── APIException.ts # API 异常类
|
||||
├── interfaces/
|
||||
│ └── ICompletionMessage.ts # 对话消息接口
|
||||
└── configs/ # 配置模式
|
||||
├── model-config.ts # 模型配置(模型参数、分辨率映射等)
|
||||
├── service-config.ts # 服务配置
|
||||
└── system-config.ts # 系统配置
|
||||
```
|
||||
|
||||
## API 端点
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/v1/chat/completions` | POST | OpenAI 兼容的对话接口(用于图像/视频生成) |
|
||||
| `/v1/images/generations` | POST | 文生图/图生图接口(支持 images 可选参数) |
|
||||
| `/v1/images/compositions` | POST | 图生图接口(支持文件上传,向后兼容) |
|
||||
| `/v1/videos/generations` | POST | 视频生成接口(含 Seedance 2.0 / 2.0-fast) |
|
||||
| `/v1/video/generations` | POST | 视频生成接口(别名路由) |
|
||||
| `/v1/models` | GET | 获取可用模型列表 |
|
||||
| `/token/check` | POST | 检查 Token 有效性 |
|
||||
| `/token/points` | POST | 查询账户积分 |
|
||||
| `/ping` | GET | 健康检查端点 |
|
||||
|
||||
## 关键技术细节
|
||||
|
||||
### 认证方式
|
||||
- 使用即梦网站的 `sessionid` Cookie 作为 Bearer Token
|
||||
- 多账号支持:逗号分隔多个 sessionid:`Authorization: Bearer sessionid1,sessionid2`
|
||||
- 每次请求随机选择一个 sessionid 使用
|
||||
|
||||
### 模型映射
|
||||
|
||||
#### 图像模型
|
||||
| 用户模型名 | 内部模型名 | Draft 版本 | 说明 |
|
||||
|-----------|-----------|-----------|------|
|
||||
| `jimeng-5.0` | `high_aes_general_v50` | 3.3.9 | 5.0 正式版(原 jimeng-5.0-preview),最新模型 |
|
||||
| `jimeng-4.6` | `high_aes_general_v42` | 3.3.9 | 推荐使用 |
|
||||
| `jimeng-4.5` | `high_aes_general_v40l` | 3.3.4 | 高质量模型 |
|
||||
| `jimeng-4.1` | `high_aes_general_v41` | 3.3.4 | 高质量模型 |
|
||||
| `jimeng-4.0` | `high_aes_general_v40` | 3.3.4 | 稳定版本 |
|
||||
| `jimeng-3.1` | `high_aes_general_v30l_art_fangzhou` | - | 艺术风格 |
|
||||
| `jimeng-3.0` | `high_aes_general_v30l` | - | 通用模型 |
|
||||
| `jimeng-2.1` | - | - | 旧版模型 |
|
||||
| `jimeng-2.0-pro` | - | - | 旧版专业模型 |
|
||||
| `jimeng-2.0` | - | - | 旧版模型 |
|
||||
| `jimeng-1.4` | - | - | 早期模型 |
|
||||
| `jimeng-xl-pro` | - | - | XL 专业模型 |
|
||||
|
||||
#### 视频模型
|
||||
| 用户模型名 | 内部模型名 | 说明 |
|
||||
|-----------|-----------|------|
|
||||
| `jimeng-video-3.5-pro` | `dreamina_ic_generate_video_model_vgfm_3.5_pro` | 最新视频模型 |
|
||||
| `jimeng-video-3.0` | - | 视频生成 3.0 |
|
||||
| `jimeng-video-3.0-pro` | - | 视频生成 3.0 专业版 |
|
||||
| `jimeng-video-2.0` | - | 视频生成 2.0 |
|
||||
| `jimeng-video-2.0-pro` | - | 视频生成 2.0 专业版 |
|
||||
| `jimeng-video-seedance-2.0` | `dreamina_seedance_40_pro` | Seedance 2.0(上游标准名称,推荐) |
|
||||
| `seedance-2.0` | `dreamina_seedance_40_pro` | 多图智能视频生成(向后兼容别名) |
|
||||
| `seedance-2.0-pro` | `dreamina_seedance_40_pro` | 多图智能视频生成专业版(向后兼容别名) |
|
||||
| `jimeng-video-seedance-2.0-fast` | `dreamina_seedance_40` | Seedance 2.0-fast 快速版(上游标准名称) |
|
||||
| `seedance-2.0-fast` | `dreamina_seedance_40` | Seedance 2.0-fast 快速版(向后兼容别名) |
|
||||
|
||||
### 请求参数
|
||||
|
||||
#### 图像生成参数 (`/v1/images/generations`)
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| model | string | 否 | jimeng-4.5 | 模型名称 |
|
||||
| prompt | string | 是 | - | 提示词,jimeng-4.x/5.x 支持多图生成 |
|
||||
| images | array | 否 | - | 图片URL数组(1-10张),提供则走图生图模式,不提供则走文生图模式 |
|
||||
| negative_prompt | string | 否 | "" | 反向提示词 |
|
||||
| ratio | string | 否 | 1:1 | 宽高比:1:1, 4:3, 3:4, 16:9, 9:16, 3:2, 2:3, 21:9 |
|
||||
| resolution | string | 否 | 2k | 分辨率:1k, 2k, 4k |
|
||||
| sample_strength | float | 否 | 0.5 | 精细度 0.0-1.0 |
|
||||
| response_format | string | 否 | url | url 或 b64_json |
|
||||
|
||||
**说明:**
|
||||
- 当 `images` 参数为空或不提供时,接口执行文生图功能
|
||||
- 当 `images` 参数提供(1-10张图片)时,接口执行图生图功能
|
||||
- 支持 `application/json`(images 为 URL 数组)和 `multipart/form-data`(通过 images 字段上传文件)两种请求格式
|
||||
- 图生图模式下,响应会额外包含 `input_images` 和 `composition_type` 字段
|
||||
|
||||
#### 图生图参数 (`/v1/images/compositions`) - 向后兼容
|
||||
- 与 `/v1/images/generations` 相同的参数
|
||||
- `images` 字段为必填(1-10张图片)
|
||||
- 额外支持 multipart/form-data 文件上传
|
||||
|
||||
#### 视频生成参数 (`/v1/videos/generations`)
|
||||
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|
||||
|------|------|------|--------|------|
|
||||
| model | string | 否 | jimeng-video-3.0 | 模型名称 |
|
||||
| prompt | string | 否 | - | 视频描述(图生视频时可选) |
|
||||
| ratio | string | 否 | 1:1 | 宽高比:1:1, 4:3, 3:4, 16:9, 9:16 |
|
||||
| resolution | string | 否 | 720p | 分辨率:480p, 720p, 1080p |
|
||||
| duration | number | 否 | 5 | 时长:4-15秒(Seedance)、5 或 10 秒(普通) |
|
||||
| file_paths / filePaths | array | 否 | [] | 首帧/尾帧图片 URL |
|
||||
| files | file[] | 否 | - | 上传的素材文件(图片/视频/音频,multipart) |
|
||||
|
||||
#### Seedance 2.0 / 2.0-fast 专用参数
|
||||
- 使用 `unified_edit_input` 结构,包含 `material_list` 和 `meta_list`
|
||||
- 支持多模态素材混合上传:图片(ImageX)、视频/音频(VOD)
|
||||
- 素材类型自动检测:通过 MIME 类型或文件扩展名判断(image/video/audio)
|
||||
- 上游标准模型名:`jimeng-video-seedance-2.0`(兼容 `seedance-2.0`、`seedance-2.0-pro`)
|
||||
- 快速版模型名:`jimeng-video-seedance-2.0-fast`(兼容 `seedance-2.0-fast`)
|
||||
- 内部模型(标准版):`dreamina_seedance_40_pro`,benefit_type:`dreamina_video_seedance_20_pro`
|
||||
- 内部模型(快速版):`dreamina_seedance_40`,benefit_type:`dreamina_seedance_20_fast`(注意:无 `video_` 前缀)
|
||||
- Draft 版本:3.3.9
|
||||
- 时长范围:4-15 秒(连续范围,与上游 iptag/jimeng-api 一致)
|
||||
- 提示词占位符:`@1`、`@2`、`@图1`、`@图2`、`@image1`、`@image2` 引用上传的素材
|
||||
- 支持的素材格式:图片(jpg/png/webp/gif/bmp)、视频(mp4/mov/m4v)、音频(mp3/wav)
|
||||
|
||||
### Shark 反爬与浏览器代理(v0.8.4)
|
||||
- 即梦对 Seedance 的 `/mweb/v1/aigc_draft/generate` 接口启用了 shark 安全中间件,要求请求携带 `a_bogus` 签名
|
||||
- `a_bogus` 由字节跳动 `bdms` SDK 在浏览器中生成,依赖真实浏览器环境(Canvas, WebGL, DOM),Node.js 无法直接运行
|
||||
- 解决方案:通过 `BrowserService`(`src/lib/browser-service.ts`)使用 Playwright 启动 headless Chromium,`bdms` SDK 自动拦截 `fetch` 并注入 `a_bogus`
|
||||
- 仅 Seedance 的 generate 请求走浏览器代理,其他请求继续用 Node.js `axios`
|
||||
- 浏览器懒启动,首次 Seedance 请求时创建;每个 sessionId 独立会话;10 分钟空闲自动清理
|
||||
- 资源拦截:屏蔽图片/字体/CSS,仅允许 bdms SDK 相关脚本(白名单域名:`vlabstatic.com`、`bytescm.com`、`jianying.com`、`byteimg.com`)
|
||||
|
||||
### 文件上传
|
||||
- 支持 multipart/form-data 文件上传
|
||||
- koa-body 配置最大文件大小 100MB
|
||||
- files 字段可以是对象或数组格式(在 Request.ts 中自动规范化)
|
||||
- 支持 formLimit/jsonLimit/textLimit:100mb
|
||||
|
||||
### 上传通道(v0.8.5)
|
||||
- **ImageX 通道**(图片上传):`get_upload_token(scene=2)` → `imagex.bytedanceapi.com` → `ApplyImageUpload` / `CommitImageUpload`,返回 URI 格式 `tos-cn-i-{service_id}/{uuid}`,service_id 为 `tb4s082cfz`
|
||||
- **VOD 通道**(视频/音频上传):`get_upload_token(scene=1)` → `vod.bytedanceapi.com` → `ApplyUploadInner` / `CommitUploadInner`,返回 vid 格式 `v028xxx`,SpaceName 为 `dreamina`
|
||||
- AWS Signature V4 签名:ImageX 使用 service=`imagex`,VOD 使用 service=`vod`,region 均为 `cn-north-1`
|
||||
- VOD 上传自动返回媒体元数据(Duration、Width、Height、Fps 等),音频时长 fallback 使用本地 WAV 头解析
|
||||
|
||||
### 分辨率支持
|
||||
|
||||
#### 图片分辨率
|
||||
| 分辨率 | 1:1 | 4:3 | 3:4 | 16:9 | 9:16 | 3:2 | 2:3 | 21:9 |
|
||||
|--------|-----|-----|-----|------|------|-----|-----|------|
|
||||
| 1k | 1024×1024 | 768×1024 | 1024×768 | 1024×576 | 576×1024 | 1024×682 | 682×1024 | 1195×512 |
|
||||
| 2k | 2048×2048 | 2304×1728 | 1728×2304 | 2560×1440 | 1440×2560 | 2496×1664 | 1664×2496 | 3024×1296 |
|
||||
| 4k | 4096×4096 | 4608×3456 | 3456×4608 | 5120×2880 | 2880×5120 | 4992×3328 | 3328×4992 | 6048×2592 |
|
||||
|
||||
#### 视频分辨率
|
||||
| 分辨率 | 1:1 | 4:3 | 3:4 | 16:9 | 9:16 |
|
||||
|--------|-----|-----|-----|------|------|
|
||||
| 480p | 480×480 | 640×480 | 480×640 | 854×480 | 480×854 |
|
||||
| 720p | 720×720 | 960×720 | 720×960 | 1280×720 | 720×1280 |
|
||||
| 1080p | 1080×1080 | 1440×1080 | 1080×1440 | 1920×1080 | 1080×1920 |
|
||||
|
||||
### 服务器中间件栈
|
||||
1. **CORS 跨域支持**:`koa2-cors()`
|
||||
2. **Range 请求**:`koaRange`(支持分段内容传输)
|
||||
3. **自定义异常处理器**:捕获错误并返回 FailureBody 响应
|
||||
4. **自定义 JSON 解析器**:处理 POST/PUT/PATCH 请求的 JSON(清理问题 Unicode 字符,跳过 multipart 请求)
|
||||
5. **Body 解析器**:`koa-body`(multipart: true,maxFileSize: 100MB)
|
||||
|
||||
## 开发规范
|
||||
|
||||
1. **TypeScript**:项目使用 TypeScript + ESM 模块
|
||||
2. **路径别名**:使用 `@/` 别名指向 `src/` 目录
|
||||
3. **日志**:使用 `@/lib/logger.ts` 中的 logger 保持输出一致
|
||||
4. **配置**:环境配置在 `configs/` 目录,通过 `@/lib/config.ts` 加载
|
||||
5. **API 兼容性**:维护 OpenAI API 兼容性,确保客户端集成正常
|
||||
6. **Node.js 版本**:≥16.0.0
|
||||
|
||||
## 测试 API 调用
|
||||
|
||||
```bash
|
||||
# 文生图(使用最新模型)
|
||||
curl -X POST http://localhost:8000/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-d '{"model": "jimeng-5.0", "prompt": "美丽的日落风景", "ratio": "16:9", "resolution": "2k"}'
|
||||
|
||||
# 图生图(通过 images 参数)
|
||||
curl -X POST http://localhost:8000/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-d '{"model": "jimeng-4.5", "prompt": "将两张图融合成梦幻风格", "images": ["https://example.com/img1.jpg", "https://example.com/img2.jpg"], "ratio": "1:1", "resolution": "2k", "sample_strength": 0.5}'
|
||||
|
||||
# 图生图(multipart 文件上传)
|
||||
curl -X POST http://localhost:8000/v1/images/generations \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-F "model=jimeng-4.5" \
|
||||
-F "prompt=将图片转换为油画风格" \
|
||||
-F "images=@/path/to/image1.jpg" \
|
||||
-F "ratio=1:1" \
|
||||
-F "resolution=2k"
|
||||
|
||||
# 视频生成
|
||||
curl -X POST http://localhost:8000/v1/videos/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-d '{"model": "jimeng-video-3.5-pro", "prompt": "一只小猫在草地上玩耍", "ratio": "16:9", "resolution": "720p"}'
|
||||
|
||||
# Seedance 2.0 多图视频(文件上传)
|
||||
curl -X POST http://localhost:8000/v1/videos/generations \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-F "model=jimeng-video-seedance-2.0" \
|
||||
-F "prompt=@1 和 @2 两人开始跳舞" \
|
||||
-F "ratio=4:3" \
|
||||
-F "duration=4" \
|
||||
-F "files=@/path/to/image1.jpg" \
|
||||
-F "files=@/path/to/image2.jpg"
|
||||
|
||||
# Seedance 2.0-fast 快速多图视频
|
||||
curl -X POST http://localhost:8000/v1/videos/generations \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-F "model=jimeng-video-seedance-2.0-fast" \
|
||||
-F "prompt=@1 图片中的人物开始微笑" \
|
||||
-F "ratio=4:3" \
|
||||
-F "duration=5" \
|
||||
-F "files=@/path/to/image1.jpg"
|
||||
|
||||
# Seedance 图片+音频混合视频
|
||||
curl -X POST http://localhost:8000/v1/videos/generations \
|
||||
-H "Authorization: Bearer your_sessionid" \
|
||||
-F "model=jimeng-video-seedance-2.0-fast" \
|
||||
-F "prompt=@1 图片中的人物随着音乐 @2 开始跳舞" \
|
||||
-F "ratio=9:16" \
|
||||
-F "duration=5" \
|
||||
-F "files=@/path/to/image.png" \
|
||||
-F "files=@/path/to/audio.wav"
|
||||
|
||||
# 健康检查
|
||||
curl http://localhost:8000/ping
|
||||
|
||||
# Token 检查
|
||||
curl -X POST http://localhost:8000/token/check \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"token": "your_sessionid"}'
|
||||
```
|
||||
|
||||
## 配置
|
||||
|
||||
默认端口:8000
|
||||
配置文件在 `configs/` 目录,使用 YAML 格式。
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
FROM node:lts AS BUILD_IMAGE
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY . /app
|
||||
|
||||
RUN yarn install --registry https://registry.npmmirror.com/ --ignore-engines && yarn run build
|
||||
|
||||
FROM node:lts
|
||||
|
||||
# 安装 Chromium 依赖
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libnss3 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
libcups2 \
|
||||
libdrm2 \
|
||||
libxkbcommon0 \
|
||||
libxcomposite1 \
|
||||
libxdamage1 \
|
||||
libxfixes3 \
|
||||
libxrandr2 \
|
||||
libgbm1 \
|
||||
libpango-1.0-0 \
|
||||
libcairo2 \
|
||||
libasound2 \
|
||||
libatspi2.0-0 \
|
||||
libwayland-client0 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=BUILD_IMAGE /app/configs /app/configs
|
||||
COPY --from=BUILD_IMAGE /app/package.json /app/package.json
|
||||
COPY --from=BUILD_IMAGE /app/dist /app/dist
|
||||
COPY --from=BUILD_IMAGE /app/public /app/public
|
||||
COPY --from=BUILD_IMAGE /app/node_modules /app/node_modules
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 安装 Playwright Chromium 浏览器
|
||||
RUN npx playwright-core install chromium
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["npm", "start"]
|
||||
|
|
@ -0,0 +1,674 @@
|
|||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
# 服务名称
|
||||
name: jimeng-free-api
|
||||
# 服务绑定主机地址
|
||||
host: '0.0.0.0'
|
||||
# 服务绑定端口
|
||||
port: 8000
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
# 是否开启请求日志
|
||||
requestLog: true
|
||||
# 临时目录路径
|
||||
tmpDir: ./tmp
|
||||
# 日志目录路径
|
||||
logDir: ./logs
|
||||
# 日志写入间隔(毫秒)
|
||||
logWriteInterval: 200
|
||||
# 日志文件有效期(毫秒)
|
||||
logFileExpires: 2626560000
|
||||
# 公共目录路径
|
||||
publicDir: ./public
|
||||
# 临时文件有效期(毫秒)
|
||||
tmpFileExpires: 86400000
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 448 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
|
|
@ -0,0 +1,170 @@
|
|||
# 即梦 AI 4.1 和 4.5 模型支持
|
||||
|
||||
本文档介绍了 jimeng-free-api-all 项目对即梦 AI 新增的 4.1 和 4.5 模型的支持。
|
||||
|
||||
## 新增模型
|
||||
|
||||
### jimeng-4.5
|
||||
- **内部模型名**: `high_aes_general_v45`
|
||||
- **版本**: 3.2.9
|
||||
- **特性**:
|
||||
- 支持文生图(Text-to-Image)
|
||||
- 支持图生图(Image-to-Image)
|
||||
- 支持多图连续生成
|
||||
- 最高支持 2048x2048 分辨率
|
||||
|
||||
### jimeng-4.1
|
||||
- **内部模型名**: `high_aes_general_v41`
|
||||
- **版本**: 3.2.9
|
||||
- **特性**:
|
||||
- 支持文生图(Text-to-Image)
|
||||
- 支持图生图(Image-to-Image)
|
||||
- 支持多图连续生成
|
||||
- 最高支持 2048x2048 分辨率
|
||||
|
||||
## 与旧版本的差异
|
||||
|
||||
| 特性 | jimeng-4.5/4.1 | jimeng-4.0 | jimeng-3.1 |
|
||||
|------|----------------|------------|------------|
|
||||
| Draft版本 | 3.2.9 | 3.0.2 | 3.0.2 |
|
||||
| 多图生成 | ✅ | ✅ | ❌ |
|
||||
| 最大分辨率 | 2048x2048 | 2048x2048 | 1024x1024 |
|
||||
| 采样强度范围 | 0.1-1.0 | 0.1-1.0 | 0.1-0.8 |
|
||||
|
||||
## API 使用
|
||||
|
||||
### 1. 文生图
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_SESSION_ID" \
|
||||
-d '{
|
||||
"model": "jimeng-4.5",
|
||||
"prompt": "一只可爱的小猫在花园里玩耍",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"sample_strength": 0.7
|
||||
}'
|
||||
```
|
||||
|
||||
### 2. 多图连续生成
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/images/generations \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_SESSION_ID" \
|
||||
-d '{
|
||||
"model": "jimeng-4.5",
|
||||
"prompt": "生成4张连续场景的图片:春夏秋冬四季风景",
|
||||
"width": 1024,
|
||||
"height": 1024,
|
||||
"sample_strength": 0.6
|
||||
}'
|
||||
```
|
||||
|
||||
### 3. 图生图
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/v1/images/compositions \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer YOUR_SESSION_ID" \
|
||||
-d '{
|
||||
"model": "jimeng-4.1",
|
||||
"prompt": "将这些图片合成为一幅美丽的风景画",
|
||||
"images": [
|
||||
"https://example.com/image1.jpg",
|
||||
"https://example.com/image2.jpg"
|
||||
],
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"sample_strength": 0.6
|
||||
}'
|
||||
```
|
||||
|
||||
## 参数说明
|
||||
|
||||
### 支持的分辨率
|
||||
|
||||
- 512x512
|
||||
- 768x768
|
||||
- 1024x1024(默认)
|
||||
- 1280x720
|
||||
- 720x1280
|
||||
- 1536x864
|
||||
- 864x1536
|
||||
- 2048x2048(仅 4.1 和 4.5)
|
||||
|
||||
### 采样强度(sample_strength)
|
||||
|
||||
- 范围:0.1 - 1.0
|
||||
- 默认:0.5
|
||||
- 说明:控制生成图片与提示词的契合度,值越高越贴近提示词
|
||||
|
||||
## 最佳实践
|
||||
|
||||
1. **使用提示词**
|
||||
- 4.5 和 4.1 版本对中文提示词支持更好
|
||||
- 建议使用详细的描述性提示词
|
||||
|
||||
2. **多图生成**
|
||||
- 使用 "连续"、"绘本"、"故事" 等关键词触发多图生成
|
||||
- 使用 "X张" 指定生成图片数量
|
||||
|
||||
3. **分辨率选择**
|
||||
- 普通场景使用 1024x1024
|
||||
- 需要高清细节时使用 2048x2048
|
||||
- 宽屏场景使用 1280x720
|
||||
|
||||
## 测试
|
||||
|
||||
项目提供了测试脚本 `test/test-new-models.js`:
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 修改脚本中的 SESSION_ID
|
||||
vim test/test-new-models.js
|
||||
|
||||
# 运行测试
|
||||
npm run test:models
|
||||
# 或直接运行
|
||||
node test/test-new-models.js
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. 新模型需要更多的积分消耗
|
||||
2. 生成时间可能比旧模型稍长
|
||||
3. 建议在生产环境使用前充分测试
|
||||
4. 遵守即梦 AI 的使用条款和限制
|
||||
|
||||
## 故障排除
|
||||
|
||||
### 常见错误
|
||||
|
||||
1. **参数验证失败**
|
||||
- 检查分辨率是否在支持列表中
|
||||
- 检查采样强度是否在 0.1-1.0 范围内
|
||||
|
||||
2. **模型不支持**
|
||||
- 确保使用的是正确的模型名称(jimeng-4.5 或 jimeng-4.1)
|
||||
- 查看模型列表确认可用性
|
||||
|
||||
3. **生成失败**
|
||||
- 检查积分是否充足
|
||||
- 检查提示词是否符合内容规范
|
||||
- 查看日志获取详细错误信息
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v4.5 支持 (2024-12-07)
|
||||
- 新增 jimeng-4.5 模型支持
|
||||
- 支持最高 2048x2048 分辨率
|
||||
- 优化了提示词理解能力
|
||||
|
||||
### v4.1 支持 (2024-12-07)
|
||||
- 新增 jimeng-4.1 模型支持
|
||||
- 改进了图像生成质量
|
||||
- 增强了多图生成功能
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,52 @@
|
|||
{
|
||||
"name": "jimeng-free-api",
|
||||
"version": "0.8.6",
|
||||
"description": "jimeng Free API Server",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.mjs",
|
||||
"types": "dist/index.d.ts",
|
||||
"directories": {
|
||||
"dist": "dist"
|
||||
},
|
||||
"files": [
|
||||
"dist/"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "tsup src/index.ts --format cjs,esm --sourcemap --dts --publicDir public --watch --onSuccess \"node --enable-source-maps --no-node-snapshot dist/index.js --port 8000\"",
|
||||
"start": "node --enable-source-maps --no-node-snapshot dist/index.js",
|
||||
"build": "tsup src/index.ts --format cjs,esm --sourcemap --dts --clean --publicDir public"
|
||||
},
|
||||
"author": "Vinlic",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.6.7",
|
||||
"colors": "^1.4.0",
|
||||
"crc-32": "^1.2.2",
|
||||
"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",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-range": "^0.3.0",
|
||||
"koa-router": "^12.0.1",
|
||||
"koa2-cors": "^2.0.6",
|
||||
"lodash": "^4.17.21",
|
||||
"mime": "^4.0.1",
|
||||
"minimist": "^1.2.8",
|
||||
"randomstring": "^1.3.0",
|
||||
"semver": "^7.7.2",
|
||||
"uuid": "^9.0.1",
|
||||
"yaml": "^2.3.4",
|
||||
"playwright-core": "^1.49.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash": "^4.14.202",
|
||||
"@types/mime": "^3.0.4",
|
||||
"tsup": "^8.0.2",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<title>🚀 服务已启动</title>
|
||||
</head>
|
||||
<body>
|
||||
<p>jimeng-free-api已启动!<br>请通过LobeChat / NextChat / Dify等客户端或OpenAI SDK接入!</p>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
export default {
|
||||
API_TEST: [-9999, 'API异常错误'],
|
||||
API_REQUEST_PARAMS_INVALID: [-2000, '请求参数非法'],
|
||||
API_REQUEST_FAILED: [-2001, '请求失败'],
|
||||
API_TOKEN_EXPIRES: [-2002, 'Token已失效'],
|
||||
API_FILE_URL_INVALID: [-2003, '远程文件URL非法'],
|
||||
API_FILE_EXECEEDS_SIZE: [-2004, '远程文件超出大小'],
|
||||
API_CHAT_STREAM_PUSHING: [-2005, '已有对话流正在输出'],
|
||||
API_CONTENT_FILTERED: [-2006, '内容由于合规问题已被阻止生成'],
|
||||
API_IMAGE_GENERATION_FAILED: [-2007, '图像生成失败'],
|
||||
API_VIDEO_GENERATION_FAILED: [-2008, '视频生成失败'],
|
||||
API_IMAGE_GENERATION_INSUFFICIENT_POINTS: [-2009, '即梦积分不足'],
|
||||
}
|
||||
|
|
@ -0,0 +1,524 @@
|
|||
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 { generateImages, DEFAULT_MODEL } from "./images.ts";
|
||||
import { generateVideo, generateSeedanceVideo, isSeedanceModel, DEFAULT_MODEL as DEFAULT_VIDEO_MODEL } from "./videos.ts";
|
||||
|
||||
// 最大重试次数
|
||||
const MAX_RETRY_COUNT = 3;
|
||||
// 重试延迟
|
||||
const RETRY_DELAY = 5000;
|
||||
|
||||
/**
|
||||
* 解析模型
|
||||
*
|
||||
* @param model 模型名称
|
||||
* @returns 模型信息
|
||||
*/
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测是否为视频生成请求
|
||||
*
|
||||
* @param model 模型名称
|
||||
* @returns 是否为视频生成请求
|
||||
*/
|
||||
function isVideoModel(model: string) {
|
||||
return model.startsWith("jimeng-video") || model.startsWith("seedance-");
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步对话补全
|
||||
*
|
||||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||||
* @param refreshToken 用于刷新access_token的refresh_token
|
||||
* @param assistantId 智能体ID,默认使用jimeng原版
|
||||
* @param retryCount 重试次数
|
||||
*/
|
||||
export async function createCompletion(
|
||||
messages: any[],
|
||||
refreshToken: string,
|
||||
_model = DEFAULT_MODEL,
|
||||
retryCount = 0
|
||||
) {
|
||||
return (async () => {
|
||||
if (messages.length === 0)
|
||||
throw new APIException(EX.API_REQUEST_PARAMS_INVALID, "消息不能为空");
|
||||
|
||||
const { model, width, height } = parseModel(_model);
|
||||
logger.info(messages);
|
||||
|
||||
// 检查是否为视频生成请求
|
||||
if (isVideoModel(_model)) {
|
||||
try {
|
||||
// 视频生成
|
||||
logger.info(`开始生成视频,模型: ${_model}`);
|
||||
|
||||
let videoUrl: string;
|
||||
|
||||
// 判断是否为 Seedance 模型
|
||||
if (isSeedanceModel(_model)) {
|
||||
// Seedance 模型需要图片,在 chat 模式下不支持图片上传
|
||||
// 返回友好提示
|
||||
return {
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: `Seedance 2.0 是多图智能视频生成模型,需要上传图片才能生成视频。\n\n请使用 POST /v1/videos/generations API 接口:\n\n\`\`\`bash\ncurl -X POST http://localhost:3000/v1/videos/generations \\\n -H "Authorization: your_token" \\\n -F "model=jimeng-video-seedance-2.0" \\\n -F "prompt=@1 图片中的人物开始跳舞" \\\n -F "ratio=4:3" \\\n -F "duration=4" \\\n -F "files=@/path/to/image1.jpg" \\\n -F "files=@/path/to/image2.jpg"\n\`\`\`\n\n**参数说明:**\n- \`model\`: jimeng-video-seedance-2.0(推荐)、jimeng-video-seedance-2.0-fast(快速版)或 seedance-2.0(兼容)\n- \`prompt\`: 提示词,使用 @1, @2 等引用上传的图片\n- \`ratio\`: 视频比例 (默认 4:3)\n- \`duration\`: 视频时长 4-15 秒 (默认 4 秒)\n- \`files\`: 上传的图片文件(支持多张)`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
created: util.unixTimestamp(),
|
||||
};
|
||||
}
|
||||
|
||||
videoUrl = await generateVideo(
|
||||
_model,
|
||||
messages[messages.length - 1].content,
|
||||
{
|
||||
ratio: "16:9",
|
||||
resolution: "720p", // 默认分辨率
|
||||
},
|
||||
refreshToken
|
||||
);
|
||||
|
||||
logger.info(`视频生成成功,URL: ${videoUrl}`);
|
||||
return {
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: `\n`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
created: util.unixTimestamp(),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`视频生成失败: ${error.message}`);
|
||||
// 如果是积分不足等特定错误,直接抛出
|
||||
if (error instanceof APIException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 其他错误返回友好提示
|
||||
return {
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: `生成视频失败: ${error.message}\n\n如果您在即梦官网看到已生成的视频,可能是获取结果时出现了问题,请前往即梦官网查看。`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
created: util.unixTimestamp(),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// 图像生成
|
||||
const imageUrls = await generateImages(
|
||||
model,
|
||||
messages[messages.length - 1].content,
|
||||
{
|
||||
width,
|
||||
height,
|
||||
},
|
||||
refreshToken
|
||||
);
|
||||
|
||||
return {
|
||||
id: util.uuid(),
|
||||
model: _model || model,
|
||||
object: "chat.completion",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: imageUrls.reduce(
|
||||
(acc, url, i) => acc + `\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;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式对话补全
|
||||
*
|
||||
* @param messages 参考gpt系列消息格式,多轮对话请完整提供上下文
|
||||
* @param refreshToken 用于刷新access_token的refresh_token
|
||||
* @param assistantId 智能体ID,默认使用jimeng原版
|
||||
* @param retryCount 重试次数
|
||||
*/
|
||||
export async function createCompletionStream(
|
||||
messages: any[],
|
||||
refreshToken: string,
|
||||
_model = DEFAULT_MODEL,
|
||||
retryCount = 0
|
||||
) {
|
||||
return (async () => {
|
||||
const { model, width, height } = parseModel(_model);
|
||||
logger.info(messages);
|
||||
|
||||
const stream = new PassThrough();
|
||||
|
||||
if (messages.length === 0) {
|
||||
logger.warn("消息为空,返回空流");
|
||||
stream.end("data: [DONE]\n\n");
|
||||
return stream;
|
||||
}
|
||||
|
||||
// 检查是否为视频生成请求
|
||||
if (isVideoModel(_model)) {
|
||||
// 视频生成
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "🎬 视频生成中,请稍候...\n这可能需要1-2分钟,请耐心等待" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
|
||||
// 视频生成
|
||||
logger.info(`开始生成视频,提示词: ${messages[messages.length - 1].content}`);
|
||||
|
||||
// 进度更新定时器
|
||||
const progressInterval = setInterval(() => {
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant", content: "." },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
}, 5000);
|
||||
|
||||
// 设置超时,防止无限等待
|
||||
const timeoutId = setTimeout(() => {
|
||||
clearInterval(progressInterval);
|
||||
logger.warn(`视频生成超时(2分钟),提示用户前往即梦官网查看`);
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 1,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: "\n\n视频生成时间较长(已等待2分钟),但视频可能仍在生成中。\n\n请前往即梦官网查看您的视频:\n1. 访问 https://jimeng.jianying.com/ai-tool/video/generate\n2. 登录后查看您的创作历史\n3. 如果视频已生成,您可以直接在官网下载或分享\n\n您也可以继续等待,系统将在后台继续尝试获取视频(最长约20分钟)。",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
// 注意:这里不结束流,让后台继续尝试获取视频
|
||||
// stream.end("data: [DONE]\n\n");
|
||||
}, 2 * 60 * 1000);
|
||||
|
||||
logger.info(`开始生成视频,模型: ${_model}, 提示词: ${messages[messages.length - 1].content.substring(0, 50)}...`);
|
||||
|
||||
// 先给用户一个初始提示
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: "\n\n🎬 视频生成已开始,这可能需要几分钟时间...",
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
|
||||
generateVideo(
|
||||
_model,
|
||||
messages[messages.length - 1].content,
|
||||
{ ratio: "16:9", resolution: "720p" },
|
||||
refreshToken
|
||||
)
|
||||
.then((videoUrl) => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
logger.info(`视频生成成功,URL: ${videoUrl}`);
|
||||
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 1,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: `\n\n✅ 视频生成完成!\n\n\n\n您可以:\n1. 直接查看上方视频\n2. 使用以下链接下载或分享:${videoUrl}`,
|
||||
},
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 2,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
stream.end("data: [DONE]\n\n");
|
||||
})
|
||||
.catch((err) => {
|
||||
clearInterval(progressInterval);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
logger.error(`视频生成失败: ${err.message}`);
|
||||
logger.error(`错误详情: ${JSON.stringify(err)}`);
|
||||
|
||||
// 记录详细错误信息
|
||||
logger.error(`视频生成失败: ${err.message}`);
|
||||
logger.error(`错误详情: ${JSON.stringify(err)}`);
|
||||
|
||||
// 构建更详细的错误信息
|
||||
let errorMessage = `⚠️ 视频生成过程中遇到问题: ${err.message}`;
|
||||
|
||||
// 如果是历史记录不存在的错误,提供更具体的建议
|
||||
if (err.message.includes("历史记录不存在")) {
|
||||
errorMessage += "\n\n可能原因:\n1. 视频生成请求已发送,但API无法获取历史记录\n2. 视频生成服务暂时不可用\n3. 历史记录ID无效或已过期\n\n建议操作:\n1. 请前往即梦官网查看您的视频是否已生成:https://jimeng.jianying.com/ai-tool/video/generate\n2. 如果官网已显示视频,但这里无法获取,可能是API连接问题\n3. 如果官网也没有显示,请稍后再试或重新生成视频";
|
||||
} else if (err.message.includes("获取视频生成结果超时")) {
|
||||
errorMessage += "\n\n视频生成可能仍在进行中,但等待时间已超过系统设定的限制。\n\n请前往即梦官网查看您的视频:https://jimeng.jianying.com/ai-tool/video/generate\n\n如果您在官网上看到视频已生成,但这里无法显示,可能是因为:\n1. 获取结果的过程超时\n2. 网络连接问题\n3. API访问限制";
|
||||
} else {
|
||||
errorMessage += "\n\n如果您在即梦官网看到已生成的视频,可能是获取结果时出现了问题。\n\n请访问即梦官网查看您的创作历史:https://jimeng.jianying.com/ai-tool/video/generate";
|
||||
}
|
||||
|
||||
// 添加历史ID信息,方便用户在官网查找
|
||||
if (err.historyId) {
|
||||
errorMessage += `\n\n历史记录ID: ${err.historyId}(您可以使用此ID在官网搜索您的视频)`;
|
||||
}
|
||||
|
||||
stream.write(
|
||||
"data: " +
|
||||
JSON.stringify({
|
||||
id: util.uuid(),
|
||||
model: _model,
|
||||
object: "chat.completion.chunk",
|
||||
choices: [
|
||||
{
|
||||
index: 1,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: `\n\n${errorMessage}`,
|
||||
},
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
stream.end("data: [DONE]\n\n");
|
||||
});
|
||||
} else {
|
||||
// 图像生成
|
||||
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,
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
|
||||
generateImages(
|
||||
model,
|
||||
messages[messages.length - 1].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: i + 1,
|
||||
delta: {
|
||||
role: "assistant",
|
||||
content: `\n`,
|
||||
},
|
||||
finish_reason: i < imageUrls.length - 1 ? null : "stop",
|
||||
},
|
||||
],
|
||||
}) +
|
||||
"\n\n"
|
||||
);
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,458 @@
|
|||
import { PassThrough } from "stream";
|
||||
import path from "path";
|
||||
import _ from "lodash";
|
||||
import mime from "mime";
|
||||
import axios, { AxiosRequestConfig, 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 = "jimeng";
|
||||
// 默认的AgentID
|
||||
export const DEFAULT_ASSISTANT_ID = 513695;
|
||||
// 版本号
|
||||
const VERSION_CODE = "8.4.0";
|
||||
// 平台代码
|
||||
const PLATFORM_CODE = "7";
|
||||
// 设备ID
|
||||
const DEVICE_ID = Math.random() * 999999999999999999 + 7000000000000000000;
|
||||
// WebID
|
||||
export const WEB_ID = Math.random() * 999999999999999999 + 7000000000000000000;
|
||||
// 用户ID
|
||||
export const USER_ID = util.uuid(false);
|
||||
// 最大重试次数
|
||||
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",
|
||||
"App-Sdk-Version": "48.0.0",
|
||||
"Cache-control": "no-cache",
|
||||
Appid: DEFAULT_ASSISTANT_ID,
|
||||
Appvr: VERSION_CODE,
|
||||
Lan: "zh-Hans",
|
||||
Loc: "cn",
|
||||
Origin: "https://jimeng.jianying.com",
|
||||
Pragma: "no-cache",
|
||||
Priority: "u=1, i",
|
||||
Referer: "https://jimeng.jianying.com",
|
||||
Pf: PLATFORM_CODE,
|
||||
"Sec-Ch-Ua":
|
||||
'"Google Chrome";v="132", "Chromium";v="132", "Not_A Brand";v="8"',
|
||||
"Sec-Ch-Ua-Mobile": "?0",
|
||||
"Sec-Ch-Ua-Platform": '"Windows"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "same-origin",
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
||||
};
|
||||
// 文件最大大小
|
||||
const FILE_MAX_SIZE = 100 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* 获取缓存中的access_token
|
||||
*
|
||||
* 目前jimeng的access_token是固定的,暂无刷新功能
|
||||
*
|
||||
* @param refreshToken 用于刷新access_token的refresh_token
|
||||
*/
|
||||
export async function acquireToken(refreshToken: string): Promise<string> {
|
||||
return refreshToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成cookie
|
||||
*/
|
||||
export function generateCookie(refreshToken: string) {
|
||||
return [
|
||||
`_tea_web_id=${WEB_ID}`,
|
||||
`is_staff_user=false`,
|
||||
`store-region=cn-gd`,
|
||||
`store-region-src=uid`,
|
||||
`sid_guard=${refreshToken}%7C${util.unixTimestamp()}%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT`,
|
||||
`uid_tt=${USER_ID}`,
|
||||
`uid_tt_ss=${USER_ID}`,
|
||||
`sid_tt=${refreshToken}`,
|
||||
`sessionid=${refreshToken}`,
|
||||
`sessionid_ss=${refreshToken}`,
|
||||
`sid_tt=${refreshToken}`
|
||||
].join("; ");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取浏览器格式的cookie数组(用于Playwright context.addCookies)
|
||||
*/
|
||||
export function getCookiesForBrowser(refreshToken: string) {
|
||||
const domain = ".jianying.com";
|
||||
return [
|
||||
{ name: "_tea_web_id", value: String(WEB_ID), domain, path: "/" },
|
||||
{ name: "is_staff_user", value: "false", domain, path: "/" },
|
||||
{ name: "store-region", value: "cn-gd", domain, path: "/" },
|
||||
{ name: "store-region-src", value: "uid", domain, path: "/" },
|
||||
{ name: "uid_tt", value: USER_ID, domain, path: "/" },
|
||||
{ name: "uid_tt_ss", value: USER_ID, domain, path: "/" },
|
||||
{ name: "sid_tt", value: refreshToken, domain, path: "/" },
|
||||
{ name: "sessionid", value: refreshToken, domain, path: "/" },
|
||||
{ name: "sessionid_ss", value: refreshToken, domain, path: "/" },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取积分信息
|
||||
*
|
||||
* @param refreshToken 用于刷新access_token的refresh_token
|
||||
*/
|
||||
export async function getCredit(refreshToken: string) {
|
||||
const {
|
||||
credit: { gift_credit, purchase_credit, vip_credit }
|
||||
} = await request("POST", "/commerce/v1/benefits/user_credit", refreshToken, {
|
||||
data: {},
|
||||
headers: {
|
||||
// Cookie: 'x-web-secsdk-uid=ef44bd0d-0cf6-448c-b517-fd1b5a7267ba; s_v_web_id=verify_m4b1lhlu_DI8qKRlD_7mJJ_4eqx_9shQ_s8eS2QLAbc4n; passport_csrf_token=86f3619c0c4a9c13f24117f71dc18524; passport_csrf_token_default=86f3619c0c4a9c13f24117f71dc18524; n_mh=9-mIeuD4wZnlYrrOvfzG3MuT6aQmCUtmr8FxV8Kl8xY; sid_guard=a7eb745aec44bb3186dbc2083ea9e1a6%7C1733386629%7C5184000%7CMon%2C+03-Feb-2025+08%3A17%3A09+GMT; uid_tt=59a46c7d3f34bda9588b93590cca2e12; uid_tt_ss=59a46c7d3f34bda9588b93590cca2e12; sid_tt=a7eb745aec44bb3186dbc2083ea9e1a6; sessionid=a7eb745aec44bb3186dbc2083ea9e1a6; sessionid_ss=a7eb745aec44bb3186dbc2083ea9e1a6; is_staff_user=false; sid_ucp_v1=1.0.0-KGRiOGY2ODQyNWU1OTk3NzRhYTE2ZmZhYmFjNjdmYjY3NzRmZGRiZTgKHgjToPCw0cwbEIXDxboGGJ-tHyAMMITDxboGOAhAJhoCaGwiIGE3ZWI3NDVhZWM0NGJiMzE4NmRiYzIwODNlYTllMWE2; ssid_ucp_v1=1.0.0-KGRiOGY2ODQyNWU1OTk3NzRhYTE2ZmZhYmFjNjdmYjY3NzRmZGRiZTgKHgjToPCw0cwbEIXDxboGGJ-tHyAMMITDxboGOAhAJhoCaGwiIGE3ZWI3NDVhZWM0NGJiMzE4NmRiYzIwODNlYTllMWE2; store-region=cn-gd; store-region-src=uid; user_spaces_idc={"7444764277623653426":"lf"}; ttwid=1|cxHJViEev1mfkjntdMziir8SwbU8uPNVSaeh9QpEUs8|1733966961|d8d52f5f56607427691be4ac44253f7870a34d25dd05a01b4d89b8a7c5ea82ad; _tea_web_id=7444838473275573797; fpk1=fa6c6a4d9ba074b90003896f36b6960066521c1faec6a60bdcb69ec8ddf85e8360b4c0704412848ec582b2abca73d57a; odin_tt=efe9dc150207879b88509e651a1c4af4e7ffb4cfcb522425a75bd72fbf894eda570bbf7ffb551c8b1de0aa2bfa0bd1be6c4157411ecdcf4464fcaf8dd6657d66',
|
||||
Referer: "https://jimeng.jianying.com/ai-tool/image/generate",
|
||||
// "Device-Time": 1733966964,
|
||||
// Sign: "f3dbb824b378abea7c03cbb152b3a365"
|
||||
}
|
||||
});
|
||||
logger.info(`\n积分信息: \n赠送积分: ${gift_credit}, 购买积分: ${purchase_credit}, VIP积分: ${vip_credit}`);
|
||||
return {
|
||||
giftCredit: gift_credit,
|
||||
purchaseCredit: purchase_credit,
|
||||
vipCredit: vip_credit,
|
||||
totalCredit: gift_credit + purchase_credit + vip_credit
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 接收今日积分
|
||||
*
|
||||
* @param refreshToken 用于刷新access_token的refresh_token
|
||||
*/
|
||||
export async function receiveCredit(refreshToken: string) {
|
||||
logger.info("正在收取今日积分...")
|
||||
const { cur_total_credits, receive_quota } = await request("POST", "/commerce/v1/benefits/credit_receive", refreshToken, {
|
||||
data: {
|
||||
time_zone: "Asia/Shanghai"
|
||||
},
|
||||
headers: {
|
||||
Referer: "https://jimeng.jianying.com/ai-tool/image/generate"
|
||||
}
|
||||
});
|
||||
logger.info(`\n今日${receive_quota}积分收取成功\n剩余积分: ${cur_total_credits}`);
|
||||
return cur_total_credits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求jimeng
|
||||
*
|
||||
* @param method 请求方法
|
||||
* @param uri 请求路径
|
||||
* @param params 请求参数
|
||||
* @param headers 请求头
|
||||
*/
|
||||
export async function request(
|
||||
method: string,
|
||||
uri: string,
|
||||
refreshToken: string,
|
||||
options: AxiosRequestConfig = {}
|
||||
) {
|
||||
const token = await acquireToken(refreshToken);
|
||||
const deviceTime = util.unixTimestamp();
|
||||
const sign = util.md5(
|
||||
`9e2c|${uri.slice(-7)}|${PLATFORM_CODE}|${VERSION_CODE}|${deviceTime}||11ac`
|
||||
);
|
||||
|
||||
const fullUrl = `https://jimeng.jianying.com${uri}`;
|
||||
const requestParams = {
|
||||
aid: DEFAULT_ASSISTANT_ID,
|
||||
device_platform: "web",
|
||||
region: "cn",
|
||||
webId: WEB_ID,
|
||||
da_version: "3.3.2",
|
||||
web_component_open_flag: 1,
|
||||
web_version: "7.5.0",
|
||||
aigc_features: "app_lip_sync",
|
||||
...(options.params || {}),
|
||||
};
|
||||
|
||||
const headers = {
|
||||
...FAKE_HEADERS,
|
||||
Cookie: generateCookie(token),
|
||||
"Device-Time": deviceTime,
|
||||
Sign: sign,
|
||||
"Sign-Ver": "1",
|
||||
...(options.headers || {}),
|
||||
};
|
||||
|
||||
logger.info(`发送请求: ${method.toUpperCase()} ${fullUrl}`);
|
||||
logger.info(`请求参数: ${JSON.stringify(requestParams)}`);
|
||||
logger.info(`请求数据: ${JSON.stringify(options.data || {})}`);
|
||||
|
||||
// 添加重试逻辑
|
||||
let retries = 0;
|
||||
const maxRetries = 3; // 最大重试次数
|
||||
let lastError = null;
|
||||
|
||||
while (retries <= maxRetries) {
|
||||
try {
|
||||
if (retries > 0) {
|
||||
logger.info(`第 ${retries} 次重试请求: ${method.toUpperCase()} ${fullUrl}`);
|
||||
// 重试前等待一段时间
|
||||
await new Promise(resolve => setTimeout(resolve, 1000 * retries));
|
||||
}
|
||||
|
||||
const response = await axios.request({
|
||||
method,
|
||||
url: fullUrl,
|
||||
params: requestParams,
|
||||
headers: headers,
|
||||
timeout: 45000, // 增加超时时间到45秒
|
||||
validateStatus: () => true, // 允许任何状态码
|
||||
..._.omit(options, "params", "headers"),
|
||||
});
|
||||
|
||||
// 记录响应状态和头信息
|
||||
logger.info(`响应状态: ${response.status} ${response.statusText}`);
|
||||
|
||||
// 流式响应直接返回response
|
||||
if (options.responseType == "stream") return response;
|
||||
|
||||
// 记录响应数据摘要
|
||||
const responseDataSummary = JSON.stringify(response.data).substring(0, 500) +
|
||||
(JSON.stringify(response.data).length > 500 ? "..." : "");
|
||||
logger.info(`响应数据摘要: ${responseDataSummary}`);
|
||||
|
||||
// 检查HTTP状态码
|
||||
if (response.status >= 400) {
|
||||
logger.warn(`HTTP错误: ${response.status} ${response.statusText}`);
|
||||
if (retries < maxRetries) {
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return checkResult(response);
|
||||
}
|
||||
catch (error) {
|
||||
lastError = error;
|
||||
logger.error(`请求失败 (尝试 ${retries + 1}/${maxRetries + 1}): ${error.message}`);
|
||||
|
||||
// 如果是网络错误或超时,尝试重试
|
||||
if ((error.code === 'ECONNABORTED' || error.code === 'ETIMEDOUT' ||
|
||||
error.message.includes('timeout') || error.message.includes('network')) &&
|
||||
retries < maxRetries) {
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 其他错误直接抛出
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 所有重试都失败了,抛出最后一个错误
|
||||
logger.error(`请求失败,已重试 ${retries} 次: ${lastError.message}`);
|
||||
if (lastError.response) {
|
||||
logger.error(`响应状态: ${lastError.response.status}`);
|
||||
logger.error(`响应数据: ${JSON.stringify(lastError.response.data)}`);
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
/**
|
||||
* 预检查文件URL有效性
|
||||
*
|
||||
* @param fileUrl 文件URL
|
||||
*/
|
||||
export 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 refreshToken 用于刷新access_token的refresh_token
|
||||
* @param fileUrl 文件URL或BASE64数据
|
||||
* @param isVideoImage 是否是用于视频图像
|
||||
* @returns 上传结果,包含image_uri
|
||||
*/
|
||||
export async function uploadFile(
|
||||
refreshToken: string,
|
||||
fileUrl: string,
|
||||
isVideoImage: boolean = false
|
||||
) {
|
||||
try {
|
||||
logger.info(`开始上传文件: ${fileUrl}, 视频图像模式: ${isVideoImage}`);
|
||||
|
||||
// 预检查远程文件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");
|
||||
logger.info(`处理BASE64数据,文件名: ${filename}, 类型: ${mimeType}, 大小: ${fileData.length}字节`);
|
||||
}
|
||||
// 下载文件到内存,如果您的服务器内存很小,建议考虑改造为流直传到下一个接口上,避免停留占用内存
|
||||
else {
|
||||
filename = path.basename(fileUrl);
|
||||
logger.info(`开始下载远程文件: ${fileUrl}`);
|
||||
({ data: fileData } = await axios.get(fileUrl, {
|
||||
responseType: "arraybuffer",
|
||||
// 100M限制
|
||||
maxContentLength: FILE_MAX_SIZE,
|
||||
// 60秒超时
|
||||
timeout: 60000,
|
||||
}));
|
||||
logger.info(`文件下载完成,文件名: ${filename}, 大小: ${fileData.length}字节`);
|
||||
}
|
||||
|
||||
// 获取文件的MIME类型
|
||||
mimeType = mimeType || mime.getType(filename);
|
||||
logger.info(`文件MIME类型: ${mimeType}`);
|
||||
|
||||
// 构建FormData
|
||||
const formData = new FormData();
|
||||
const blob = new Blob([fileData], { type: mimeType });
|
||||
formData.append('file', blob, filename);
|
||||
|
||||
// 获取上传凭证
|
||||
logger.info(`请求上传凭证,场景: ${isVideoImage ? 'video_cover' : 'aigc_image'}`);
|
||||
const uploadProofUrl = 'https://imagex.bytedanceapi.com/';
|
||||
const proofResult = await request(
|
||||
'POST',
|
||||
'/mweb/v1/get_upload_image_proof',
|
||||
refreshToken,
|
||||
{
|
||||
data: {
|
||||
scene: isVideoImage ? 'video_cover' : 'aigc_image',
|
||||
file_name: filename,
|
||||
file_size: fileData.length,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
if (!proofResult || !proofResult.proof_info) {
|
||||
logger.error(`获取上传凭证失败: ${JSON.stringify(proofResult)}`);
|
||||
throw new APIException(EX.API_REQUEST_FAILED, '获取上传凭证失败');
|
||||
}
|
||||
|
||||
logger.info(`获取上传凭证成功`);
|
||||
|
||||
// 上传文件
|
||||
const { proof_info } = proofResult;
|
||||
logger.info(`开始上传文件到: ${uploadProofUrl}`);
|
||||
|
||||
const uploadResult = await axios.post(
|
||||
uploadProofUrl,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
...proof_info.headers,
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
params: proof_info.query_params,
|
||||
timeout: 60000,
|
||||
validateStatus: () => true, // 允许任何状态码以便详细处理
|
||||
}
|
||||
);
|
||||
|
||||
logger.info(`上传响应状态: ${uploadResult.status}`);
|
||||
|
||||
if (!uploadResult || uploadResult.status !== 200) {
|
||||
logger.error(`上传文件失败: 状态码 ${uploadResult?.status}, 响应: ${JSON.stringify(uploadResult?.data)}`);
|
||||
throw new APIException(EX.API_REQUEST_FAILED, `上传文件失败: 状态码 ${uploadResult?.status}`);
|
||||
}
|
||||
|
||||
// 验证 proof_info.image_uri 是否存在
|
||||
if (!proof_info.image_uri) {
|
||||
logger.error(`上传凭证中缺少 image_uri: ${JSON.stringify(proof_info)}`);
|
||||
throw new APIException(EX.API_REQUEST_FAILED, '上传凭证中缺少 image_uri');
|
||||
}
|
||||
|
||||
logger.info(`文件上传成功: ${proof_info.image_uri}`);
|
||||
|
||||
// 返回上传结果
|
||||
return {
|
||||
image_uri: proof_info.image_uri,
|
||||
uri: proof_info.image_uri,
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`文件上传过程中发生错误: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查请求结果
|
||||
*
|
||||
* @param result 结果
|
||||
*/
|
||||
export function checkResult(result: AxiosResponse) {
|
||||
const { ret, errmsg, data } = result.data;
|
||||
if (!_.isFinite(Number(ret))) return result.data;
|
||||
if (ret === '0') return data;
|
||||
if (ret === '5000')
|
||||
throw new APIException(EX.API_IMAGE_GENERATION_INSUFFICIENT_POINTS, `[无法生成图像]: 即梦积分可能不足,${errmsg}`);
|
||||
throw new APIException(EX.API_REQUEST_FAILED, `[请求jimeng失败]: ${errmsg}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token切分
|
||||
*
|
||||
* @param authorization 认证字符串
|
||||
*/
|
||||
export function tokenSplit(authorization: string) {
|
||||
return authorization.replace("Bearer ", "").split(",");
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取Token存活状态
|
||||
*/
|
||||
export async function getTokenLiveStatus(refreshToken: string) {
|
||||
const result = await request(
|
||||
"POST",
|
||||
"/passport/account/info/v2",
|
||||
refreshToken,
|
||||
{
|
||||
params: {
|
||||
account_sdk_source: "web",
|
||||
},
|
||||
}
|
||||
);
|
||||
try {
|
||||
const { user_id } = checkResult(result);
|
||||
return !!user_id;
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,36 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
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';
|
||||
|
||||
export default {
|
||||
|
||||
prefix: '/v1/chat',
|
||||
|
||||
post: {
|
||||
|
||||
'/completions': async (request: Request) => {
|
||||
request
|
||||
.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, messages, stream } = request.body;
|
||||
if (stream) {
|
||||
const stream = await createCompletionStream(messages, token, model);
|
||||
return new Response(stream, {
|
||||
type: "text/event-stream"
|
||||
});
|
||||
}
|
||||
else
|
||||
return await createCompletion(messages, token, model);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import fs from "fs";
|
||||
import _ from "lodash";
|
||||
|
||||
import Request from "@/lib/request/Request.ts";
|
||||
import { generateImages, generateImageComposition } from "@/api/controllers/images.ts";
|
||||
import { tokenSplit } from "@/api/controllers/core.ts";
|
||||
import util from "@/lib/util.ts";
|
||||
|
||||
export default {
|
||||
prefix: "/v1/images",
|
||||
|
||||
post: {
|
||||
"/generations": async (request: Request) => {
|
||||
// 检查是否使用了不支持的参数
|
||||
const unsupportedParams = ['size', 'width', 'height'];
|
||||
const bodyKeys = Object.keys(request.body);
|
||||
const foundUnsupported = unsupportedParams.filter(param => bodyKeys.includes(param));
|
||||
|
||||
if (foundUnsupported.length > 0) {
|
||||
throw new Error(`不支持的参数: ${foundUnsupported.join(', ')}。请使用 ratio 和 resolution 参数控制图像尺寸。`);
|
||||
}
|
||||
|
||||
const contentType = request.headers['content-type'] || '';
|
||||
const isMultiPart = contentType.startsWith('multipart/form-data');
|
||||
|
||||
// 根据请求类型进行不同的参数验证
|
||||
if (isMultiPart) {
|
||||
request
|
||||
.validate("body.model", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.prompt", _.isString)
|
||||
.validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.ratio", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.resolution", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.intelligent_ratio", v => _.isUndefined(v) || (typeof v === 'string' && (v === 'true' || v === 'false')) || _.isBoolean(v))
|
||||
.validate("body.sample_strength", v => _.isUndefined(v) || (typeof v === 'string' && !isNaN(parseFloat(v))) || _.isFinite(v))
|
||||
.validate("body.response_format", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("headers.authorization", _.isString);
|
||||
} else {
|
||||
request
|
||||
.validate("body.model", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.prompt", _.isString)
|
||||
.validate("body.images", v => _.isUndefined(v) || _.isArray(v))
|
||||
.validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.ratio", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.resolution", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.intelligent_ratio", v => _.isUndefined(v) || _.isBoolean(v))
|
||||
.validate("body.sample_strength", v => _.isUndefined(v) || _.isFinite(v))
|
||||
.validate("body.response_format", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("headers.authorization", _.isString);
|
||||
}
|
||||
|
||||
// 处理图片数据(如果提供)
|
||||
let images: (string | Buffer)[] | null = null;
|
||||
if (isMultiPart) {
|
||||
const files = (request.files as any)?.images;
|
||||
if (files) {
|
||||
const imageFiles = Array.isArray(files) ? files : [files];
|
||||
if (imageFiles.length > 0) {
|
||||
if (imageFiles.length > 10) {
|
||||
throw new Error("最多支持10张输入图片");
|
||||
}
|
||||
images = imageFiles.map((file: any) => fs.readFileSync(file.filepath));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const bodyImages = request.body.images;
|
||||
if (bodyImages && Array.isArray(bodyImages) && bodyImages.length > 0) {
|
||||
if (bodyImages.length > 10) {
|
||||
throw new Error("最多支持10张输入图片");
|
||||
}
|
||||
bodyImages.forEach((image: any, index: number) => {
|
||||
if (!_.isString(image) && !_.isObject(image)) {
|
||||
throw new Error(`图片 ${index + 1} 格式不正确:应为URL字符串或包含url字段的对象`);
|
||||
}
|
||||
if (_.isObject(image) && !(image as any).url) {
|
||||
throw new Error(`图片 ${index + 1} 缺少url字段`);
|
||||
}
|
||||
});
|
||||
images = bodyImages.map((image: any) => _.isString(image) ? image : (image as any).url);
|
||||
}
|
||||
}
|
||||
|
||||
// refresh_token切分
|
||||
const tokens = tokenSplit(request.headers.authorization);
|
||||
// 随机挑选一个refresh_token
|
||||
const token = _.sample(tokens);
|
||||
|
||||
const {
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt: negativePrompt,
|
||||
ratio,
|
||||
resolution,
|
||||
intelligent_ratio: intelligentRatio,
|
||||
sample_strength: sampleStrength,
|
||||
response_format,
|
||||
} = request.body;
|
||||
|
||||
// 如果是 multipart/form-data,需要将字符串转换为数字和布尔值
|
||||
const finalSampleStrength = isMultiPart && typeof sampleStrength === 'string'
|
||||
? parseFloat(sampleStrength)
|
||||
: sampleStrength;
|
||||
|
||||
const finalIntelligentRatio = isMultiPart && typeof intelligentRatio === 'string'
|
||||
? intelligentRatio === 'true'
|
||||
: intelligentRatio;
|
||||
|
||||
const responseFormat = _.defaultTo(response_format, "url");
|
||||
|
||||
// 根据是否有图片数据决定调用文生图还是图生图
|
||||
let imageUrls: string[];
|
||||
let resultData: any = {
|
||||
created: util.unixTimestamp(),
|
||||
};
|
||||
|
||||
if (images && images.length > 0) {
|
||||
// 图生图模式
|
||||
imageUrls = await generateImageComposition(model, prompt, images, {
|
||||
ratio,
|
||||
resolution,
|
||||
sampleStrength: finalSampleStrength,
|
||||
negativePrompt,
|
||||
intelligentRatio: finalIntelligentRatio,
|
||||
}, token);
|
||||
resultData.input_images = images.length;
|
||||
resultData.composition_type = "multi_image_synthesis";
|
||||
} else {
|
||||
// 文生图模式
|
||||
imageUrls = await generateImages(model, prompt, {
|
||||
ratio,
|
||||
resolution,
|
||||
sampleStrength: finalSampleStrength,
|
||||
negativePrompt,
|
||||
intelligentRatio: finalIntelligentRatio,
|
||||
}, 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,
|
||||
}));
|
||||
}
|
||||
|
||||
resultData.data = data;
|
||||
return resultData;
|
||||
},
|
||||
|
||||
// 图片合成路由(图生图)
|
||||
"/compositions": async (request: Request) => {
|
||||
// 检查是否使用了不支持的参数
|
||||
const unsupportedParams = ['size', 'width', 'height'];
|
||||
const bodyKeys = Object.keys(request.body);
|
||||
const foundUnsupported = unsupportedParams.filter(param => bodyKeys.includes(param));
|
||||
|
||||
if (foundUnsupported.length > 0) {
|
||||
throw new Error(`不支持的参数: ${foundUnsupported.join(', ')}。请使用 ratio 和 resolution 参数控制图像尺寸。`);
|
||||
}
|
||||
|
||||
const contentType = request.headers['content-type'] || '';
|
||||
const isMultiPart = contentType.startsWith('multipart/form-data');
|
||||
|
||||
if (isMultiPart) {
|
||||
request
|
||||
.validate("body.model", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.prompt", _.isString)
|
||||
.validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.ratio", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.resolution", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.intelligent_ratio", v => _.isUndefined(v) || (typeof v === 'string' && (v === 'true' || v === 'false')) || _.isBoolean(v))
|
||||
.validate("body.sample_strength", v => _.isUndefined(v) || (typeof v === 'string' && !isNaN(parseFloat(v))) || _.isFinite(v))
|
||||
.validate("body.response_format", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("headers.authorization", _.isString);
|
||||
} else {
|
||||
request
|
||||
.validate("body.model", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.prompt", _.isString)
|
||||
.validate("body.images", _.isArray)
|
||||
.validate("body.negative_prompt", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.ratio", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.resolution", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("body.intelligent_ratio", v => _.isUndefined(v) || _.isBoolean(v))
|
||||
.validate("body.sample_strength", v => _.isUndefined(v) || _.isFinite(v))
|
||||
.validate("body.response_format", v => _.isUndefined(v) || _.isString(v))
|
||||
.validate("headers.authorization", _.isString);
|
||||
}
|
||||
|
||||
let images: (string | Buffer)[] = [];
|
||||
if (isMultiPart) {
|
||||
const files = (request.files as any)?.images;
|
||||
if (!files) {
|
||||
throw new Error("在form-data中缺少 'images' 字段");
|
||||
}
|
||||
const imageFiles = Array.isArray(files) ? files : [files];
|
||||
if (imageFiles.length === 0) {
|
||||
throw new Error("至少需要提供1张输入图片");
|
||||
}
|
||||
if (imageFiles.length > 10) {
|
||||
throw new Error("最多支持10张输入图片");
|
||||
}
|
||||
images = imageFiles.map((file: any) => fs.readFileSync(file.filepath));
|
||||
} else {
|
||||
const bodyImages = request.body.images;
|
||||
if (!bodyImages || bodyImages.length === 0) {
|
||||
throw new Error("至少需要提供1张输入图片");
|
||||
}
|
||||
if (bodyImages.length > 10) {
|
||||
throw new Error("最多支持10张输入图片");
|
||||
}
|
||||
bodyImages.forEach((image: any, index: number) => {
|
||||
if (!_.isString(image) && !_.isObject(image)) {
|
||||
throw new Error(`图片 ${index + 1} 格式不正确:应为URL字符串或包含url字段的对象`);
|
||||
}
|
||||
if (_.isObject(image) && !(image as any).url) {
|
||||
throw new Error(`图片 ${index + 1} 缺少url字段`);
|
||||
}
|
||||
});
|
||||
images = bodyImages.map((image: any) => _.isString(image) ? image : (image as any).url);
|
||||
}
|
||||
|
||||
// refresh_token切分
|
||||
const tokens = tokenSplit(request.headers.authorization);
|
||||
// 随机挑选一个refresh_token
|
||||
const token = _.sample(tokens);
|
||||
|
||||
const {
|
||||
model,
|
||||
prompt,
|
||||
negative_prompt: negativePrompt,
|
||||
ratio,
|
||||
resolution,
|
||||
intelligent_ratio: intelligentRatio,
|
||||
sample_strength: sampleStrength,
|
||||
response_format,
|
||||
} = request.body;
|
||||
|
||||
// 如果是 multipart/form-data,需要将字符串转换为数字和布尔值
|
||||
const finalSampleStrength = isMultiPart && typeof sampleStrength === 'string'
|
||||
? parseFloat(sampleStrength)
|
||||
: sampleStrength;
|
||||
|
||||
const finalIntelligentRatio = isMultiPart && typeof intelligentRatio === 'string'
|
||||
? intelligentRatio === 'true'
|
||||
: intelligentRatio;
|
||||
|
||||
const responseFormat = _.defaultTo(response_format, "url");
|
||||
const resultUrls = await generateImageComposition(model, prompt, images, {
|
||||
ratio,
|
||||
resolution,
|
||||
sampleStrength: finalSampleStrength,
|
||||
negativePrompt,
|
||||
intelligentRatio: finalIntelligentRatio,
|
||||
}, token);
|
||||
|
||||
let data = [];
|
||||
if (responseFormat == "b64_json") {
|
||||
data = (
|
||||
await Promise.all(resultUrls.map((url) => util.fetchFileBASE64(url)))
|
||||
).map((b64) => ({ b64_json: b64 }));
|
||||
} else {
|
||||
data = resultUrls.map((url) => ({
|
||||
url,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
created: util.unixTimestamp(),
|
||||
data,
|
||||
input_images: images.length,
|
||||
composition_type: "multi_image_synthesis",
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import fs from 'fs-extra';
|
||||
|
||||
import Response from '@/lib/response/Response.ts';
|
||||
import images from "./images.ts";
|
||||
import chat from "./chat.ts";
|
||||
import ping from "./ping.ts";
|
||||
import token from './token.js';
|
||||
import models from './models.ts';
|
||||
import videos from './videos.ts';
|
||||
import video from './video.ts';
|
||||
|
||||
export default [
|
||||
{
|
||||
get: {
|
||||
'/': async () => {
|
||||
const content = await fs.readFile('public/welcome.html');
|
||||
return new Response(content, {
|
||||
type: 'html',
|
||||
headers: {
|
||||
Expires: '-1'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
images,
|
||||
chat,
|
||||
ping,
|
||||
token,
|
||||
models,
|
||||
videos,
|
||||
video
|
||||
];
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
export default {
|
||||
|
||||
prefix: '/v1',
|
||||
|
||||
get: {
|
||||
'/models': async () => {
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "jimeng",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-5.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 5.0 版本(最新)"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-4.6",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 4.6 版本(最新)"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-4.5",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 4.5 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-4.1",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 4.1 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-4.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 4.0 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-3.1",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 3.1 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-3.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 3.0 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-2.1",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 2.1 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-2.0-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 2.0 专业版"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-2.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 2.0 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-1.4",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 1.4 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-xl-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI图像生成模型 XL Pro 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-3.5-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI视频生成模型 3.5 专业版"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-3.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI视频生成模型 3.0 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-3.0-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI视频生成模型 3.0 专业版"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-2.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI视频生成模型 2.0 版本"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-2.0-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "即梦AI视频生成模型 2.0 专业版"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-seedance-2.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "Seedance 2.0 多图智能视频生成模型(上游标准名称,支持4-15秒,多张图片混合生成视频)"
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "Seedance 2.0 多图智能视频生成模型(jimeng-video-seedance-2.0 的别名,向后兼容)"
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-pro",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "Seedance 2.0 Pro 多图智能视频生成模型(jimeng-video-seedance-2.0 的别名,向后兼容)"
|
||||
},
|
||||
{
|
||||
"id": "jimeng-video-seedance-2.0-fast",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "Seedance 2.0-fast 快速多图智能视频生成模型(上游标准名称,支持4-15秒)"
|
||||
},
|
||||
{
|
||||
"id": "seedance-2.0-fast",
|
||||
"object": "model",
|
||||
"owned_by": "jimeng-free-api",
|
||||
"description": "Seedance 2.0-fast 快速多图智能视频生成模型(jimeng-video-seedance-2.0-fast 的别名,向后兼容)"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
prefix: '/ping',
|
||||
get: {
|
||||
'': async () => "pong"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import Request from '@/lib/request/Request.ts';
|
||||
import Response from '@/lib/response/Response.ts';
|
||||
import { getTokenLiveStatus, getCredit, tokenSplit } from '@/api/controllers/core.ts';
|
||||
import logger from '@/lib/logger.ts';
|
||||
|
||||
export default {
|
||||
|
||||
prefix: '/token',
|
||||
|
||||
post: {
|
||||
|
||||
'/check': async (request: Request) => {
|
||||
request
|
||||
.validate('body.token', _.isString)
|
||||
const live = await getTokenLiveStatus(request.body.token);
|
||||
return {
|
||||
live
|
||||
}
|
||||
},
|
||||
|
||||
'/points': async (request: Request) => {
|
||||
request
|
||||
.validate('headers.authorization', _.isString)
|
||||
// refresh_token切分
|
||||
const tokens = tokenSplit(request.headers.authorization);
|
||||
const points = await Promise.all(tokens.map(async (token) => {
|
||||
return {
|
||||
token,
|
||||
points: await getCredit(token)
|
||||
}
|
||||
}))
|
||||
return points;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import videos from './videos.ts';
|
||||
|
||||
export default {
|
||||
...videos,
|
||||
prefix: '/v1/video'
|
||||
};
|
||||
|
|
@ -0,0 +1,135 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import Request from '@/lib/request/Request.ts';
|
||||
import Response from '@/lib/response/Response.ts';
|
||||
import { tokenSplit } from '@/api/controllers/core.ts';
|
||||
import { generateVideo, generateSeedanceVideo, isSeedanceModel, DEFAULT_MODEL } from '@/api/controllers/videos.ts';
|
||||
import util from '@/lib/util.ts';
|
||||
|
||||
export default {
|
||||
|
||||
prefix: '/v1/videos',
|
||||
|
||||
post: {
|
||||
|
||||
'/generations': async (request: Request) => {
|
||||
// 检查是否使用了不支持的参数
|
||||
const unsupportedParams = ['size', 'width', 'height'];
|
||||
const bodyKeys = Object.keys(request.body);
|
||||
const foundUnsupported = unsupportedParams.filter(param => bodyKeys.includes(param));
|
||||
|
||||
if (foundUnsupported.length > 0) {
|
||||
throw new Error(`不支持的参数: ${foundUnsupported.join(', ')}。请使用 ratio 和 resolution 参数控制视频尺寸。`);
|
||||
}
|
||||
|
||||
const contentType = request.headers['content-type'] || '';
|
||||
const isMultiPart = contentType.startsWith('multipart/form-data');
|
||||
|
||||
request
|
||||
.validate('body.model', v => _.isUndefined(v) || _.isString(v))
|
||||
.validate('body.prompt', v => _.isUndefined(v) || _.isString(v))
|
||||
.validate('body.ratio', v => _.isUndefined(v) || _.isString(v))
|
||||
.validate('body.resolution', v => _.isUndefined(v) || _.isString(v))
|
||||
.validate('body.duration', v => {
|
||||
if (_.isUndefined(v)) return true;
|
||||
// 对于 multipart/form-data,允许字符串类型的数字
|
||||
if (isMultiPart && typeof v === 'string') {
|
||||
const num = parseInt(v);
|
||||
// Seedance 支持 4-15 秒连续范围,普通视频支持 5 或 10 秒
|
||||
return (num >= 4 && num <= 15) || num === 5 || num === 10;
|
||||
}
|
||||
// 对于 JSON,要求数字类型
|
||||
// Seedance 支持 4-15 秒连续范围,普通视频支持 5 或 10 秒
|
||||
return _.isFinite(v) && ((v >= 4 && v <= 15) || v === 5 || v === 10);
|
||||
})
|
||||
.validate('body.file_paths', v => _.isUndefined(v) || _.isArray(v))
|
||||
.validate('body.filePaths', v => _.isUndefined(v) || _.isArray(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 {
|
||||
model = DEFAULT_MODEL,
|
||||
prompt,
|
||||
ratio = "1:1",
|
||||
resolution = "720p",
|
||||
duration = 5,
|
||||
file_paths = [],
|
||||
filePaths = [],
|
||||
response_format = "url"
|
||||
} = request.body;
|
||||
|
||||
// 如果是 multipart/form-data,需要将字符串转换为数字
|
||||
const finalDuration = isMultiPart && typeof duration === 'string'
|
||||
? parseInt(duration)
|
||||
: duration;
|
||||
|
||||
// 兼容两种参数名格式:file_paths 和 filePaths
|
||||
const finalFilePaths = filePaths.length > 0 ? filePaths : file_paths;
|
||||
|
||||
// 根据模型类型选择不同的生成函数
|
||||
let videoUrl: string;
|
||||
if (isSeedanceModel(model)) {
|
||||
// Seedance 2.0 多图智能视频生成
|
||||
// Seedance 默认时长为 4 秒,默认比例为 4:3
|
||||
const seedanceDuration = finalDuration === 5 ? 4 : finalDuration; // 如果是默认的5秒,转为4秒
|
||||
const seedanceRatio = ratio === "1:1" ? "4:3" : ratio; // 如果是默认的1:1,转为4:3
|
||||
|
||||
videoUrl = await generateSeedanceVideo(
|
||||
model,
|
||||
prompt,
|
||||
{
|
||||
ratio: seedanceRatio,
|
||||
resolution,
|
||||
duration: seedanceDuration,
|
||||
filePaths: finalFilePaths,
|
||||
files: request.files,
|
||||
},
|
||||
token
|
||||
);
|
||||
} else {
|
||||
// 普通视频生成
|
||||
videoUrl = await generateVideo(
|
||||
model,
|
||||
prompt,
|
||||
{
|
||||
ratio,
|
||||
resolution,
|
||||
duration: finalDuration,
|
||||
filePaths: finalFilePaths,
|
||||
files: request.files,
|
||||
},
|
||||
token
|
||||
);
|
||||
}
|
||||
|
||||
// 根据response_format返回不同格式的结果
|
||||
if (response_format === "b64_json") {
|
||||
// 获取视频内容并转换为BASE64
|
||||
const videoBase64 = await util.fetchFileBASE64(videoUrl);
|
||||
return {
|
||||
created: util.unixTimestamp(),
|
||||
data: [{
|
||||
b64_json: videoBase64,
|
||||
revised_prompt: prompt
|
||||
}]
|
||||
};
|
||||
} else {
|
||||
// 默认返回URL
|
||||
return {
|
||||
created: util.unixTimestamp(),
|
||||
data: [{
|
||||
url: videoUrl,
|
||||
revised_prompt: prompt
|
||||
}]
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* 守护进程
|
||||
*/
|
||||
|
||||
import process from 'process';
|
||||
import path from 'path';
|
||||
import { spawn } from 'child_process';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import { format as dateFormat } from 'date-fns';
|
||||
import 'colors';
|
||||
|
||||
const CRASH_RESTART_LIMIT = 600; //进程崩溃重启次数限制
|
||||
const CRASH_RESTART_DELAY = 5000; //进程崩溃重启延迟
|
||||
const LOG_PATH = path.resolve("./logs/daemon.log"); //守护进程日志路径
|
||||
let crashCount = 0; //进程崩溃次数
|
||||
let currentProcess; //当前运行进程
|
||||
|
||||
/**
|
||||
* 写入守护进程日志
|
||||
*/
|
||||
function daemonLog(value, color?: string) {
|
||||
try {
|
||||
const head = `[daemon][${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")}] `;
|
||||
value = head + value;
|
||||
console.log(color ? value[color] : value);
|
||||
fs.ensureDirSync(path.dirname(LOG_PATH));
|
||||
fs.appendFileSync(LOG_PATH, value + "\n");
|
||||
}
|
||||
catch(err) {
|
||||
console.error("daemon log write error:", err);
|
||||
}
|
||||
}
|
||||
|
||||
daemonLog(`daemon pid: ${process.pid}`);
|
||||
|
||||
function createProcess() {
|
||||
const childProcess = spawn("node", ["index.js", ...process.argv.slice(2)]); //启动子进程
|
||||
childProcess.stdout.pipe(process.stdout, { end: false }); //将子进程输出管道到当前进程输出
|
||||
childProcess.stderr.pipe(process.stderr, { end: false }); //将子进程错误输出管道到当前进程输出
|
||||
currentProcess = childProcess; //更新当前进程
|
||||
daemonLog(`process(${childProcess.pid}) has started`);
|
||||
childProcess.on("error", err => daemonLog(`process(${childProcess.pid}) error: ${err.stack}`, "red"));
|
||||
childProcess.on("close", code => {
|
||||
if(code === 0) //进程正常退出
|
||||
daemonLog(`process(${childProcess.pid}) has exited`);
|
||||
else if(code === 2) //进程已被杀死
|
||||
daemonLog(`process(${childProcess.pid}) has been killed!`, "bgYellow");
|
||||
else if(code === 3) { //进程主动重启
|
||||
daemonLog(`process(${childProcess.pid}) has restart`, "yellow");
|
||||
createProcess(); //重新创建进程
|
||||
}
|
||||
else { //进程发生崩溃
|
||||
if(crashCount++ < CRASH_RESTART_LIMIT) { //进程崩溃次数未达重启次数上限前尝试重启
|
||||
daemonLog(`process(${childProcess.pid}) has crashed! delay ${CRASH_RESTART_DELAY}ms try restarting...(${crashCount})`, "bgRed");
|
||||
setTimeout(() => createProcess(), CRASH_RESTART_DELAY); //延迟指定时长后再重启
|
||||
}
|
||||
else //进程已崩溃,且无法重启
|
||||
daemonLog(`process(${childProcess.pid}) has crashed! unable to restart`, "bgRed");
|
||||
}
|
||||
}); //子进程关闭监听
|
||||
}
|
||||
|
||||
process.on("exit", code => {
|
||||
if(code === 0)
|
||||
daemonLog("daemon process exited");
|
||||
else if(code === 2)
|
||||
daemonLog("daemon process has been killed!");
|
||||
}); //守护进程退出事件
|
||||
|
||||
process.on("SIGTERM", () => {
|
||||
daemonLog("received kill signal", "yellow");
|
||||
currentProcess && currentProcess.kill("SIGINT");
|
||||
process.exit(2);
|
||||
}); //kill退出守护进程
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
currentProcess && currentProcess.kill("SIGINT");
|
||||
process.exit(0);
|
||||
}); //主动退出守护进程
|
||||
|
||||
createProcess(); //创建进程
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
"use strict";
|
||||
|
||||
import environment from "@/lib/environment.ts";
|
||||
import config from "@/lib/config.ts";
|
||||
import "@/lib/initialize.ts";
|
||||
import server from "@/lib/server.ts";
|
||||
import routes from "@/api/routes/index.ts";
|
||||
import logger from "@/lib/logger.ts";
|
||||
|
||||
const startupTime = performance.now();
|
||||
|
||||
(async () => {
|
||||
logger.header();
|
||||
|
||||
logger.info("<<<< jimeng free server >>>>");
|
||||
logger.info("Version:", environment.package.version);
|
||||
logger.info("Process id:", process.pid);
|
||||
logger.info("Environment:", environment.env);
|
||||
logger.info("Service name:", config.service.name);
|
||||
|
||||
server.attachRoutes(routes);
|
||||
await server.listen();
|
||||
|
||||
config.service.bindAddress &&
|
||||
logger.success("Service bind address:", config.service.bindAddress);
|
||||
})()
|
||||
.then(() =>
|
||||
logger.success(
|
||||
`Service startup completed (${Math.floor(performance.now() - startupTime)}ms)`
|
||||
)
|
||||
)
|
||||
.catch((err) => console.error(err));
|
||||
|
|
@ -0,0 +1,289 @@
|
|||
import { chromium, Browser, BrowserContext, Page } from "playwright-core";
|
||||
import logger from "@/lib/logger.ts";
|
||||
import { getCookiesForBrowser } from "@/api/controllers/core.ts";
|
||||
|
||||
// bdms SDK 相关脚本的白名单域名
|
||||
const SCRIPT_WHITELIST_DOMAINS = [
|
||||
"vlabstatic.com",
|
||||
"bytescm.com",
|
||||
"jianying.com",
|
||||
"byteimg.com",
|
||||
];
|
||||
|
||||
// 需要屏蔽的资源类型(加速加载、减少内存)
|
||||
const BLOCKED_RESOURCE_TYPES = ["image", "font", "stylesheet", "media"];
|
||||
|
||||
// 会话空闲超时时间(毫秒)
|
||||
const SESSION_IDLE_TIMEOUT = 10 * 60 * 1000;
|
||||
|
||||
// bdms SDK 就绪等待超时(毫秒)
|
||||
const BDMS_READY_TIMEOUT = 30000;
|
||||
|
||||
interface BrowserSession {
|
||||
context: BrowserContext;
|
||||
page: Page;
|
||||
lastUsed: number;
|
||||
idleTimer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
class BrowserService {
|
||||
private browser: Browser | null = null;
|
||||
private sessions: Map<string, BrowserSession> = new Map();
|
||||
private launching: Promise<Browser> | null = null;
|
||||
|
||||
/**
|
||||
* 懒启动浏览器实例
|
||||
*/
|
||||
private async ensureBrowser(): Promise<Browser> {
|
||||
if (this.browser?.isConnected()) {
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
// 防止并发启动
|
||||
if (this.launching) {
|
||||
return this.launching;
|
||||
}
|
||||
|
||||
this.launching = (async () => {
|
||||
logger.info("BrowserService: 正在启动 Chromium 浏览器...");
|
||||
try {
|
||||
this.browser = await chromium.launch({
|
||||
headless: true,
|
||||
args: [
|
||||
"--no-sandbox",
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage",
|
||||
"--disable-gpu",
|
||||
"--no-first-run",
|
||||
"--no-zygote",
|
||||
"--single-process",
|
||||
],
|
||||
});
|
||||
|
||||
this.browser.on("disconnected", () => {
|
||||
logger.warn("BrowserService: 浏览器已断开连接");
|
||||
this.browser = null;
|
||||
this.sessions.clear();
|
||||
});
|
||||
|
||||
logger.info("BrowserService: Chromium 浏览器启动成功");
|
||||
return this.browser;
|
||||
} finally {
|
||||
this.launching = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return this.launching;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建指定 token 的浏览器会话
|
||||
*/
|
||||
private async getSession(token: string): Promise<BrowserSession> {
|
||||
const existing = this.sessions.get(token);
|
||||
if (existing) {
|
||||
existing.lastUsed = Date.now();
|
||||
// 重置空闲计时器
|
||||
if (existing.idleTimer) {
|
||||
clearTimeout(existing.idleTimer);
|
||||
}
|
||||
existing.idleTimer = setTimeout(() => this.closeSession(token), SESSION_IDLE_TIMEOUT);
|
||||
return existing;
|
||||
}
|
||||
|
||||
return this.createSession(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新的浏览器会话
|
||||
*/
|
||||
private async createSession(token: string): Promise<BrowserSession> {
|
||||
const browser = await this.ensureBrowser();
|
||||
|
||||
logger.info(`BrowserService: 为 token ${token.substring(0, 8)}... 创建新会话`);
|
||||
|
||||
const context = await browser.newContext({
|
||||
userAgent:
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36",
|
||||
viewport: { width: 1920, height: 1080 },
|
||||
locale: "zh-CN",
|
||||
});
|
||||
|
||||
// 注入 cookies
|
||||
const cookies = getCookiesForBrowser(token);
|
||||
await context.addCookies(cookies);
|
||||
|
||||
// 配置资源拦截
|
||||
await context.route("**/*", (route) => {
|
||||
const request = route.request();
|
||||
const resourceType = request.resourceType();
|
||||
const url = request.url();
|
||||
|
||||
// 屏蔽不需要的资源类型
|
||||
if (BLOCKED_RESOURCE_TYPES.includes(resourceType)) {
|
||||
return route.abort();
|
||||
}
|
||||
|
||||
// 对于脚本资源,只允许白名单域名
|
||||
if (resourceType === "script") {
|
||||
const isWhitelisted = SCRIPT_WHITELIST_DOMAINS.some((domain) =>
|
||||
url.includes(domain)
|
||||
);
|
||||
if (!isWhitelisted) {
|
||||
return route.abort();
|
||||
}
|
||||
}
|
||||
|
||||
return route.continue();
|
||||
});
|
||||
|
||||
const page = await context.newPage();
|
||||
|
||||
// 导航到即梦页面,让 bdms SDK 加载
|
||||
logger.info("BrowserService: 正在导航到 jimeng.jianying.com ...");
|
||||
await page.goto("https://jimeng.jianying.com", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// 等待 bdms SDK 就绪
|
||||
logger.info("BrowserService: 等待 bdms SDK 就绪...");
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// bdms SDK 会替换 window.fetch,检测其是否被替换
|
||||
// 也可以检测 window.bdms 或 window.byted_acrawler
|
||||
return (
|
||||
(window as any).bdms?.init ||
|
||||
(window as any).byted_acrawler ||
|
||||
// 检测 fetch 是否被替换(bdms 会替换原生 fetch)
|
||||
window.fetch.toString().indexOf("native code") === -1
|
||||
);
|
||||
},
|
||||
{ timeout: BDMS_READY_TIMEOUT }
|
||||
);
|
||||
logger.info("BrowserService: bdms SDK 已就绪");
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
"BrowserService: bdms SDK 等待超时,可能未完全加载,继续尝试..."
|
||||
);
|
||||
}
|
||||
|
||||
const session: BrowserSession = {
|
||||
context,
|
||||
page,
|
||||
lastUsed: Date.now(),
|
||||
idleTimer: setTimeout(() => this.closeSession(token), SESSION_IDLE_TIMEOUT),
|
||||
};
|
||||
|
||||
this.sessions.set(token, session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定 token 的会话
|
||||
*/
|
||||
private async closeSession(token: string) {
|
||||
const session = this.sessions.get(token);
|
||||
if (!session) return;
|
||||
|
||||
logger.info(`BrowserService: 关闭空闲会话 ${token.substring(0, 8)}...`);
|
||||
if (session.idleTimer) {
|
||||
clearTimeout(session.idleTimer);
|
||||
}
|
||||
|
||||
try {
|
||||
await session.context.close();
|
||||
} catch (err) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
|
||||
this.sessions.delete(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过浏览器代理发送 fetch 请求
|
||||
* bdms SDK 会自动拦截 fetch 并注入 a_bogus 签名
|
||||
*
|
||||
* @param token sessionid
|
||||
* @param url 完整的请求 URL
|
||||
* @param options fetch 选项 (method, headers, body)
|
||||
* @returns 解析后的 JSON 响应
|
||||
*/
|
||||
async fetch(
|
||||
token: string,
|
||||
url: string,
|
||||
options: { method?: string; headers?: Record<string, string>; body?: string }
|
||||
): Promise<any> {
|
||||
const session = await this.getSession(token);
|
||||
|
||||
logger.info(`BrowserService: 代理请求 ${options.method || "GET"} ${url.substring(0, 100)}...`);
|
||||
|
||||
try {
|
||||
const result = await session.page.evaluate(
|
||||
async ({ url, options }) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: options.method || "GET",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(options.headers || {}),
|
||||
},
|
||||
body: options.body,
|
||||
credentials: "include",
|
||||
});
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, status: res.status, text };
|
||||
} catch (err: any) {
|
||||
return { ok: false, status: 0, text: "", error: err.message };
|
||||
}
|
||||
},
|
||||
{ url, options }
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
throw new Error(`浏览器 fetch 失败: ${result.error}`);
|
||||
}
|
||||
|
||||
logger.info(`BrowserService: 响应状态 ${result.status}`);
|
||||
|
||||
try {
|
||||
return JSON.parse(result.text);
|
||||
} catch {
|
||||
logger.warn(`BrowserService: 响应不是有效 JSON: ${result.text.substring(0, 200)}`);
|
||||
return result.text;
|
||||
}
|
||||
} catch (err) {
|
||||
// 如果执行失败(页面崩溃等),清理会话以便下次重建
|
||||
logger.error(`BrowserService: 请求执行失败: ${(err as Error).message}`);
|
||||
await this.closeSession(token);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有会话和浏览器实例
|
||||
*/
|
||||
async close() {
|
||||
logger.info("BrowserService: 正在关闭所有会话和浏览器...");
|
||||
|
||||
for (const [token] of this.sessions) {
|
||||
await this.closeSession(token);
|
||||
}
|
||||
|
||||
if (this.browser) {
|
||||
try {
|
||||
await this.browser.close();
|
||||
} catch (err) {
|
||||
// 忽略关闭错误
|
||||
}
|
||||
this.browser = null;
|
||||
}
|
||||
|
||||
logger.info("BrowserService: 已关闭");
|
||||
}
|
||||
}
|
||||
|
||||
// 单例导出
|
||||
const browserService = new BrowserService();
|
||||
export default browserService;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import serviceConfig from "./configs/service-config.ts";
|
||||
import systemConfig from "./configs/system-config.ts";
|
||||
|
||||
class Config {
|
||||
|
||||
/** 服务配置 */
|
||||
service = serviceConfig;
|
||||
|
||||
/** 系统配置 */
|
||||
system = systemConfig;
|
||||
|
||||
}
|
||||
|
||||
export default new Config();
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
/**
|
||||
* 模型配置文件
|
||||
* 定义不同模型的特定参数和配置
|
||||
*/
|
||||
|
||||
export interface ModelConfig {
|
||||
// 模型内部名称
|
||||
internalModel: string;
|
||||
// draft版本
|
||||
draftVersion: string;
|
||||
// 支持的功能
|
||||
features: {
|
||||
// 是否支持多图生成
|
||||
multiImage: boolean;
|
||||
// 是否支持图生图
|
||||
imageToImage: boolean;
|
||||
// 是否支持视频生成
|
||||
videoGeneration: boolean;
|
||||
};
|
||||
// 默认参数
|
||||
defaultParams: {
|
||||
// 默认宽度
|
||||
width: number;
|
||||
// 默认高度
|
||||
height: number;
|
||||
// 支持的分辨率列表
|
||||
resolutions: Array<{ width: number; height: number }>;
|
||||
// 采样强度范围
|
||||
sampleStrengthRange: [number, number];
|
||||
};
|
||||
// 特殊配置
|
||||
specialConfig?: {
|
||||
// 是否需要特定的头部信息
|
||||
specialHeaders?: Record<string, string>;
|
||||
// 是否有特殊的参数要求
|
||||
extraParams?: Record<string, any>;
|
||||
};
|
||||
}
|
||||
|
||||
// 模型配置映射
|
||||
export const MODEL_CONFIGS: Record<string, ModelConfig> = {
|
||||
"jimeng-5.0": {
|
||||
internalModel: "high_aes_general_v50",
|
||||
draftVersion: "3.3.9",
|
||||
features: {
|
||||
multiImage: true,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1024, height: 576 },
|
||||
{ width: 576, height: 1024 },
|
||||
{ width: 1024, height: 682 },
|
||||
{ width: 682, height: 1024 },
|
||||
{ width: 1195, height: 512 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 2304, height: 1728 },
|
||||
{ width: 1728, height: 2304 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2496, height: 1664 },
|
||||
{ width: 1664, height: 2496 },
|
||||
{ width: 3024, height: 1296 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-4.6": {
|
||||
internalModel: "high_aes_general_v42",
|
||||
draftVersion: "3.3.9",
|
||||
features: {
|
||||
multiImage: true,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1024, height: 576 },
|
||||
{ width: 576, height: 1024 },
|
||||
{ width: 1024, height: 682 },
|
||||
{ width: 682, height: 1024 },
|
||||
{ width: 1195, height: 512 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 2304, height: 1728 },
|
||||
{ width: 1728, height: 2304 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2496, height: 1664 },
|
||||
{ width: 1664, height: 2496 },
|
||||
{ width: 3024, height: 1296 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-video-3.5-pro": {
|
||||
internalModel: "dreamina_ic_generate_video_model_vgfm_3.5_pro",
|
||||
draftVersion: "3.3.4",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: true,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
resolutions: [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 720, height: 1280 },
|
||||
{ width: 1080, height: 1080 },
|
||||
{ width: 1920, height: 1080 },
|
||||
{ width: 1080, height: 1920 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-4.5": {
|
||||
internalModel: "high_aes_general_v40l",
|
||||
draftVersion: "3.3.4",
|
||||
features: {
|
||||
multiImage: true,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1024, height: 576 },
|
||||
{ width: 576, height: 1024 },
|
||||
{ width: 1024, height: 682 },
|
||||
{ width: 682, height: 1024 },
|
||||
{ width: 1195, height: 512 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 2304, height: 1728 },
|
||||
{ width: 1728, height: 2304 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2496, height: 1664 },
|
||||
{ width: 1664, height: 2496 },
|
||||
{ width: 3024, height: 1296 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-4.1": {
|
||||
internalModel: "high_aes_general_v41",
|
||||
draftVersion: "3.3.4",
|
||||
features: {
|
||||
multiImage: true,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1024, height: 576 },
|
||||
{ width: 576, height: 1024 },
|
||||
{ width: 1024, height: 682 },
|
||||
{ width: 682, height: 1024 },
|
||||
{ width: 1195, height: 512 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 2304, height: 1728 },
|
||||
{ width: 1728, height: 2304 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2496, height: 1664 },
|
||||
{ width: 1664, height: 2496 },
|
||||
{ width: 3024, height: 1296 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-4.0": {
|
||||
internalModel: "high_aes_general_v40",
|
||||
draftVersion: "3.3.4",
|
||||
features: {
|
||||
multiImage: true,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 2048,
|
||||
height: 2048,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 768, height: 1024 },
|
||||
{ width: 1024, height: 768 },
|
||||
{ width: 1024, height: 576 },
|
||||
{ width: 576, height: 1024 },
|
||||
{ width: 1024, height: 682 },
|
||||
{ width: 682, height: 1024 },
|
||||
{ width: 1195, height: 512 },
|
||||
{ width: 2048, height: 2048 },
|
||||
{ width: 2304, height: 1728 },
|
||||
{ width: 1728, height: 2304 },
|
||||
{ width: 2560, height: 1440 },
|
||||
{ width: 1440, height: 2560 },
|
||||
{ width: 2496, height: 1664 },
|
||||
{ width: 1664, height: 2496 },
|
||||
{ width: 3024, height: 1296 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 1.0],
|
||||
},
|
||||
},
|
||||
"jimeng-3.1": {
|
||||
internalModel: "high_aes_general_v30l_art_fangzhou:general_v3.0_18b",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
{ width: 1024, height: 1024 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.8],
|
||||
},
|
||||
},
|
||||
"jimeng-3.0": {
|
||||
internalModel: "high_aes_general_v30l:general_v3.0_18b",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
{ width: 1024, height: 1024 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.8],
|
||||
},
|
||||
},
|
||||
"jimeng-2.1": {
|
||||
internalModel: "high_aes_general_v21_L:general_v2.1_L",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.7],
|
||||
},
|
||||
},
|
||||
"jimeng-2.0-pro": {
|
||||
internalModel: "high_aes_general_v20_L:general_v2.0_L",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.7],
|
||||
},
|
||||
},
|
||||
"jimeng-2.0": {
|
||||
internalModel: "high_aes_general_v20",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.7],
|
||||
},
|
||||
},
|
||||
"jimeng-1.4": {
|
||||
internalModel: "high_aes_general_v14:general_v1.4",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 512,
|
||||
height: 512,
|
||||
resolutions: [
|
||||
{ width: 512, height: 512 },
|
||||
{ width: 768, height: 768 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.6],
|
||||
},
|
||||
},
|
||||
"jimeng-xl-pro": {
|
||||
internalModel: "text2img_xl_sft",
|
||||
draftVersion: "3.0.2",
|
||||
features: {
|
||||
multiImage: false,
|
||||
imageToImage: true,
|
||||
videoGeneration: false,
|
||||
},
|
||||
defaultParams: {
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
resolutions: [
|
||||
{ width: 1024, height: 1024 },
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 720, height: 1280 },
|
||||
],
|
||||
sampleStrengthRange: [0.1, 0.8],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 获取模型配置
|
||||
export function getModelConfig(modelName: string): ModelConfig {
|
||||
const config = MODEL_CONFIGS[modelName];
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported model: ${modelName}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// 获取所有支持的图像生成模型
|
||||
export function getSupportedImageModels(): string[] {
|
||||
return Object.keys(MODEL_CONFIGS);
|
||||
}
|
||||
|
||||
// 检查模型是否支持特定功能
|
||||
export function doesModelSupport(modelName: string, feature: keyof ModelConfig['features']): boolean {
|
||||
const config = getModelConfig(modelName);
|
||||
return config.features[feature];
|
||||
}
|
||||
|
||||
// 验证参数是否在模型支持的范围内
|
||||
export function validateModelParams(modelName: string, params: {
|
||||
width?: number;
|
||||
height?: number;
|
||||
sampleStrength?: number;
|
||||
}): { isValid: boolean; errors: string[] } {
|
||||
const config = getModelConfig(modelName);
|
||||
const errors: string[] = [];
|
||||
|
||||
// 验证分辨率
|
||||
if (params.width && params.height) {
|
||||
const isValidResolution = config.defaultParams.resolutions.some(
|
||||
res => res.width === params.width && res.height === params.height
|
||||
);
|
||||
if (!isValidResolution) {
|
||||
errors.push(
|
||||
`Unsupported resolution ${params.width}x${params.height}. Supported resolutions: ${config.defaultParams.resolutions.map(r => `${r.width}x${r.height}`).join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 验证采样强度
|
||||
if (params.sampleStrength !== undefined) {
|
||||
const [min, max] = config.defaultParams.sampleStrengthRange;
|
||||
if (params.sampleStrength < min || params.sampleStrength > max) {
|
||||
errors.push(`Sample strength must be between ${min} and ${max}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import _ from 'lodash';
|
||||
|
||||
import environment from '../environment.ts';
|
||||
import util from '../util.ts';
|
||||
|
||||
const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/service.yml");
|
||||
|
||||
/**
|
||||
* 服务配置
|
||||
*/
|
||||
export class ServiceConfig {
|
||||
|
||||
/** 服务名称 */
|
||||
name: string;
|
||||
/** @type {string} 服务绑定主机地址 */
|
||||
host;
|
||||
/** @type {number} 服务绑定端口 */
|
||||
port;
|
||||
/** @type {string} 服务路由前缀 */
|
||||
urlPrefix;
|
||||
/** @type {string} 服务绑定地址(外部访问地址) */
|
||||
bindAddress;
|
||||
|
||||
constructor(options?: any) {
|
||||
const { name, host, port, urlPrefix, bindAddress } = options || {};
|
||||
this.name = _.defaultTo(name, 'jimeng-free-api');
|
||||
this.host = _.defaultTo(host, '0.0.0.0');
|
||||
this.port = _.defaultTo(port, 5566);
|
||||
this.urlPrefix = _.defaultTo(urlPrefix, '');
|
||||
this.bindAddress = bindAddress;
|
||||
}
|
||||
|
||||
get addressHost() {
|
||||
if(this.bindAddress) return this.bindAddress;
|
||||
const ipAddresses = util.getIPAddressesByIPv4();
|
||||
for(let ipAddress of ipAddresses) {
|
||||
if(ipAddress === this.host)
|
||||
return ipAddress;
|
||||
}
|
||||
return ipAddresses[0] || "127.0.0.1";
|
||||
}
|
||||
|
||||
get address() {
|
||||
return `${this.addressHost}:${this.port}`;
|
||||
}
|
||||
|
||||
get pageDirUrl() {
|
||||
return `http://127.0.0.1:${this.port}/page`;
|
||||
}
|
||||
|
||||
get publicDirUrl() {
|
||||
return `http://127.0.0.1:${this.port}/public`;
|
||||
}
|
||||
|
||||
static load() {
|
||||
const external = _.pickBy(environment, (v, k) => ["name", "host", "port"].includes(k) && !_.isUndefined(v));
|
||||
if(!fs.pathExistsSync(CONFIG_PATH)) return new ServiceConfig(external);
|
||||
const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString());
|
||||
return new ServiceConfig({ ...data, ...external });
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ServiceConfig.load();
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import yaml from 'yaml';
|
||||
import _ from 'lodash';
|
||||
|
||||
import environment from '../environment.ts';
|
||||
|
||||
const CONFIG_PATH = path.join(path.resolve(), 'configs/', environment.env, "/system.yml");
|
||||
|
||||
/**
|
||||
* 系统配置
|
||||
*/
|
||||
export class SystemConfig {
|
||||
|
||||
/** 是否开启请求日志 */
|
||||
requestLog: boolean;
|
||||
/** 临时目录路径 */
|
||||
tmpDir: string;
|
||||
/** 日志目录路径 */
|
||||
logDir: string;
|
||||
/** 日志写入间隔(毫秒) */
|
||||
logWriteInterval: number;
|
||||
/** 日志文件有效期(毫秒) */
|
||||
logFileExpires: number;
|
||||
/** 公共目录路径 */
|
||||
publicDir: string;
|
||||
/** 临时文件有效期(毫秒) */
|
||||
tmpFileExpires: number;
|
||||
/** 请求体配置 */
|
||||
requestBody: any;
|
||||
/** 是否调试模式 */
|
||||
debug: boolean;
|
||||
|
||||
constructor(options?: any) {
|
||||
const { requestLog, tmpDir, logDir, logWriteInterval, logFileExpires, publicDir, tmpFileExpires, requestBody, debug } = options || {};
|
||||
this.requestLog = _.defaultTo(requestLog, false);
|
||||
this.tmpDir = _.defaultTo(tmpDir, './tmp');
|
||||
this.logDir = _.defaultTo(logDir, './logs');
|
||||
this.logWriteInterval = _.defaultTo(logWriteInterval, 200);
|
||||
this.logFileExpires = _.defaultTo(logFileExpires, 2626560000);
|
||||
this.publicDir = _.defaultTo(publicDir, './public');
|
||||
this.tmpFileExpires = _.defaultTo(tmpFileExpires, 86400000);
|
||||
this.requestBody = Object.assign(requestBody || {}, {
|
||||
enableTypes: ['form', 'text', 'xml'], // 移除 json,由自定义中间件处理
|
||||
encoding: 'utf-8',
|
||||
formLimit: '100mb',
|
||||
jsonLimit: '100mb',
|
||||
textLimit: '100mb',
|
||||
xmlLimit: '100mb',
|
||||
formidable: {
|
||||
maxFileSize: '100mb'
|
||||
},
|
||||
multipart: true,
|
||||
parsedMethods: ['POST', 'PUT', 'PATCH']
|
||||
});
|
||||
this.debug = _.defaultTo(debug, true);
|
||||
}
|
||||
|
||||
get rootDirPath() {
|
||||
return path.resolve();
|
||||
}
|
||||
|
||||
get tmpDirPath() {
|
||||
return path.resolve(this.tmpDir);
|
||||
}
|
||||
|
||||
get logDirPath() {
|
||||
return path.resolve(this.logDir);
|
||||
}
|
||||
|
||||
get publicDirPath() {
|
||||
return path.resolve(this.publicDir);
|
||||
}
|
||||
|
||||
static load() {
|
||||
if (!fs.pathExistsSync(CONFIG_PATH)) return new SystemConfig();
|
||||
const data = yaml.parse(fs.readFileSync(CONFIG_PATH).toString());
|
||||
return new SystemConfig(data);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default SystemConfig.load();
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export default {
|
||||
SYSTEM_ERROR: [-1000, '系统异常'],
|
||||
SYSTEM_REQUEST_VALIDATION_ERROR: [-1001, '请求参数校验错误'],
|
||||
SYSTEM_NOT_ROUTE_MATCHING: [-1002, '无匹配的路由']
|
||||
} as Record<string, [number, string]>
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import path from 'path';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import minimist from 'minimist';
|
||||
import _ from 'lodash';
|
||||
|
||||
const cmdArgs = minimist(process.argv.slice(2)); //获取命令行参数
|
||||
const envVars = process.env; //获取环境变量
|
||||
|
||||
class Environment {
|
||||
|
||||
/** 命令行参数 */
|
||||
cmdArgs: any;
|
||||
/** 环境变量 */
|
||||
envVars: any;
|
||||
/** 环境名称 */
|
||||
env?: string;
|
||||
/** 服务名称 */
|
||||
name?: string;
|
||||
/** 服务地址 */
|
||||
host?: string;
|
||||
/** 服务端口 */
|
||||
port?: number;
|
||||
/** 包参数 */
|
||||
package: any;
|
||||
|
||||
constructor(options: any = {}) {
|
||||
const { cmdArgs, envVars, package: _package } = options;
|
||||
this.cmdArgs = cmdArgs;
|
||||
this.envVars = envVars;
|
||||
this.env = _.defaultTo(cmdArgs.env || envVars.SERVER_ENV, 'dev');
|
||||
this.name = cmdArgs.name || envVars.SERVER_NAME || undefined;
|
||||
this.host = cmdArgs.host || envVars.SERVER_HOST || undefined;
|
||||
this.port = Number(cmdArgs.port || envVars.SERVER_PORT) ? Number(cmdArgs.port || envVars.SERVER_PORT) : undefined;
|
||||
this.package = _package;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Environment({
|
||||
cmdArgs,
|
||||
envVars,
|
||||
package: JSON.parse(fs.readFileSync(path.join(path.resolve(), "package.json")).toString())
|
||||
});
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import Exception from './Exception.js';
|
||||
|
||||
export default class APIException extends Exception {
|
||||
|
||||
/**
|
||||
* 构造异常
|
||||
*
|
||||
* @param {[number, string]} exception 异常
|
||||
*/
|
||||
constructor(exception: (string | number)[], errmsg?: string) {
|
||||
super(exception, errmsg);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
import assert from 'assert';
|
||||
|
||||
import _ from 'lodash';
|
||||
|
||||
export default class Exception extends Error {
|
||||
|
||||
/** 错误码 */
|
||||
errcode: number;
|
||||
/** 错误消息 */
|
||||
errmsg: string;
|
||||
/** 数据 */
|
||||
data: any;
|
||||
/** HTTP状态码 */
|
||||
httpStatusCode: number;
|
||||
|
||||
/**
|
||||
* 构造异常
|
||||
*
|
||||
* @param exception 异常
|
||||
* @param _errmsg 异常消息
|
||||
*/
|
||||
constructor(exception: (string | number)[], _errmsg?: string) {
|
||||
assert(_.isArray(exception), 'Exception must be Array');
|
||||
const [errcode, errmsg] = exception as [number, string];
|
||||
assert(_.isFinite(errcode), 'Exception errcode invalid');
|
||||
assert(_.isString(errmsg), 'Exception errmsg invalid');
|
||||
super(_errmsg || errmsg);
|
||||
this.errcode = errcode;
|
||||
this.errmsg = _errmsg || errmsg;
|
||||
}
|
||||
|
||||
compare(exception: (string | number)[]) {
|
||||
const [errcode] = exception as [number, string];
|
||||
return this.errcode == errcode;
|
||||
}
|
||||
|
||||
setHTTPStatusCode(value: number) {
|
||||
this.httpStatusCode = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
setData(value: any) {
|
||||
this.data = _.defaultTo(value, null);
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
export default {
|
||||
|
||||
CONTINUE: 100, //客户端应当继续发送请求。这个临时响应是用来通知客户端它的部分请求已经被服务器接收,且仍未被拒绝。客户端应当继续发送请求的剩余部分,或者如果请求已经完成,忽略这个响应。服务器必须在请求完成后向客户端发送一个最终响应
|
||||
SWITCHING_PROTOCOLS: 101, //服务器已经理解了客户端的请求,并将通过Upgrade 消息头通知客户端采用不同的协议来完成这个请求。在发送完这个响应最后的空行后,服务器将会切换到在Upgrade 消息头中定义的那些协议。只有在切换新的协议更有好处的时候才应该采取类似措施。例如,切换到新的HTTP 版本比旧版本更有优势,或者切换到一个实时且同步的协议以传送利用此类特性的资源
|
||||
PROCESSING: 102, //处理将被继续执行
|
||||
|
||||
OK: 200, //请求已成功,请求所希望的响应头或数据体将随此响应返回
|
||||
CREATED: 201, //请求已经被实现,而且有一个新的资源已经依据请求的需要而建立,且其 URI 已经随Location 头信息返回。假如需要的资源无法及时建立的话,应当返回 '202 Accepted'
|
||||
ACCEPTED: 202, //服务器已接受请求,但尚未处理。正如它可能被拒绝一样,最终该请求可能会也可能不会被执行。在异步操作的场合下,没有比发送这个状态码更方便的做法了。返回202状态码的响应的目的是允许服务器接受其他过程的请求(例如某个每天只执行一次的基于批处理的操作),而不必让客户端一直保持与服务器的连接直到批处理操作全部完成。在接受请求处理并返回202状态码的响应应当在返回的实体中包含一些指示处理当前状态的信息,以及指向处理状态监视器或状态预测的指针,以便用户能够估计操作是否已经完成
|
||||
NON_AUTHORITATIVE_INFO: 203, //服务器已成功处理了请求,但返回的实体头部元信息不是在原始服务器上有效的确定集合,而是来自本地或者第三方的拷贝。当前的信息可能是原始版本的子集或者超集。例如,包含资源的元数据可能导致原始服务器知道元信息的超级。使用此状态码不是必须的,而且只有在响应不使用此状态码便会返回200 OK的情况下才是合适的
|
||||
NO_CONTENT: 204, //服务器成功处理了请求,但不需要返回任何实体内容,并且希望返回更新了的元信息。响应可能通过实体头部的形式,返回新的或更新后的元信息。如果存在这些头部信息,则应当与所请求的变量相呼应。如果客户端是浏览器的话,那么用户浏览器应保留发送了该请求的页面,而不产生任何文档视图上的变化,即使按照规范新的或更新后的元信息应当被应用到用户浏览器活动视图中的文档。由于204响应被禁止包含任何消息体,因此它始终以消息头后的第一个空行结尾
|
||||
RESET_CONTENT: 205, //服务器成功处理了请求,且没有返回任何内容。但是与204响应不同,返回此状态码的响应要求请求者重置文档视图。该响应主要是被用于接受用户输入后,立即重置表单,以便用户能够轻松地开始另一次输入。与204响应一样,该响应也被禁止包含任何消息体,且以消息头后的第一个空行结束
|
||||
PARTIAL_CONTENT: 206, //服务器已经成功处理了部分 GET 请求。类似于FlashGet或者迅雷这类的HTTP下载工具都是使用此类响应实现断点续传或者将一个大文档分解为多个下载段同时下载。该请求必须包含 Range 头信息来指示客户端希望得到的内容范围,并且可能包含 If-Range 来作为请求条件。响应必须包含如下的头部域:Content-Range 用以指示本次响应中返回的内容的范围;如果是Content-Type为multipart/byteranges的多段下载,则每一段multipart中都应包含Content-Range域用以指示本段的内容范围。假如响应中包含Content-Length,那么它的数值必须匹配它返回的内容范围的真实字节数。Date和ETag或Content-Location,假如同样的请求本应该返回200响应。Expires, Cache-Control,和/或 Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了 If-Range 强缓存验证,那么本次响应不应该包含其他实体头;假如本响应的请求使用了 If-Range 弱缓存验证,那么本次响应禁止包含其他实体头;这避免了缓存的实体内容和更新了的实体头信息之间的不一致。否则,本响应就应当包含所有本应该返回200响应中应当返回的所有实体头部域。假如 ETag 或 Latest-Modified 头部不能精确匹配的话,则客户端缓存应禁止将206响应返回的内容与之前任何缓存过的内容组合在一起。任何不支持 Range 以及 Content-Range 头的缓存都禁止缓存206响应返回的内容
|
||||
MULTIPLE_STATUS: 207, //代表之后的消息体将是一个XML消息,并且可能依照之前子请求数量的不同,包含一系列独立的响应代码
|
||||
|
||||
MULTIPLE_CHOICES: 300, //被请求的资源有一系列可供选择的回馈信息,每个都有自己特定的地址和浏览器驱动的商议信息。用户或浏览器能够自行选择一个首选的地址进行重定向。除非这是一个HEAD请求,否则该响应应当包括一个资源特性及地址的列表的实体,以便用户或浏览器从中选择最合适的重定向地址。这个实体的格式由Content-Type定义的格式所决定。浏览器可能根据响应的格式以及浏览器自身能力,自动作出最合适的选择。当然,RFC 2616规范并没有规定这样的自动选择该如何进行。如果服务器本身已经有了首选的回馈选择,那么在Location中应当指明这个回馈的 URI;浏览器可能会将这个 Location 值作为自动重定向的地址。此外,除非额外指定,否则这个响应也是可缓存的
|
||||
MOVED_PERMANENTLY: 301, //被请求的资源已永久移动到新位置,并且将来任何对此资源的引用都应该使用本响应返回的若干个URI之一。如果可能,拥有链接编辑功能的客户端应当自动把请求的地址修改为从服务器反馈回来的地址。除非额外指定,否则这个响应也是可缓存的。新的永久性的URI应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,因此浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:对于某些使用 HTTP/1.0 协议的浏览器,当它们发送的POST请求得到了一个301响应的话,接下来的重定向请求将会变成GET方式
|
||||
FOUND: 302, //请求的资源现在临时从不同的URI响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI应当在响应的 Location 域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化。注意:虽然RFC 1945和RFC 2068规范不允许客户端在重定向时改变请求的方法,但是很多现存的浏览器将302响应视作为303响应,并且使用GET方式访问在Location中规定的URI,而无视原先请求的方法。状态码303和307被添加了进来,用以明确服务器期待客户端进行何种反应
|
||||
SEE_OTHER: 303, //对应当前请求的响应可以在另一个URI上被找到,而且客户端应当采用 GET 的方式访问那个资源。这个方法的存在主要是为了允许由脚本激活的POST请求输出重定向到一个新的资源。这个新的 URI 不是原始资源的替代引用。同时,303响应禁止被缓存。当然,第二个请求(重定向)可能被缓存。新的 URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI的超链接及简短说明。注意:许多 HTTP/1.1 版以前的浏览器不能正确理解303状态。如果需要考虑与这些浏览器之间的互动,302状态码应该可以胜任,因为大多数的浏览器处理302响应时的方式恰恰就是上述规范要求客户端处理303响应时应当做的
|
||||
NOT_MODIFIED: 304, //如果客户端发送了一个带条件的GET请求且该请求已被允许,而文档的内容(自上次访问以来或者根据请求的条件)并没有改变,则服务器应当返回这个状态码。304响应禁止包含消息体,因此始终以消息头后的第一个空行结尾。该响应必须包含以下的头信息:Date,除非这个服务器没有时钟。假如没有时钟的服务器也遵守这些规则,那么代理服务器以及客户端可以自行将Date字段添加到接收到的响应头中去(正如RFC 2068中规定的一样),缓存机制将会正常工作。ETag或 Content-Location,假如同样的请求本应返回200响应。Expires, Cache-Control,和/或Vary,假如其值可能与之前相同变量的其他响应对应的值不同的话。假如本响应请求使用了强缓存验证,那么本次响应不应该包含其他实体头;否则(例如,某个带条件的 GET 请求使用了弱缓存验证),本次响应禁止包含其他实体头;这避免了缓存了的实体内容和更新了的实体头信息之间的不一致。假如某个304响应指明了当前某个实体没有缓存,那么缓存系统必须忽视这个响应,并且重复发送不包含限制条件的请求。假如接收到一个要求更新某个缓存条目的304响应,那么缓存系统必须更新整个条目以反映所有在响应中被更新的字段的值
|
||||
USE_PROXY: 305, //被请求的资源必须通过指定的代理才能被访问。Location域中将给出指定的代理所在的URI信息,接收者需要重复发送一个单独的请求,通过这个代理才能访问相应资源。只有原始服务器才能建立305响应。注意:RFC 2068中没有明确305响应是为了重定向一个单独的请求,而且只能被原始服务器建立。忽视这些限制可能导致严重的安全后果
|
||||
UNUSED: 306, //在最新版的规范中,306状态码已经不再被使用
|
||||
TEMPORARY_REDIRECT: 307, //请求的资源现在临时从不同的URI 响应请求。由于这样的重定向是临时的,客户端应当继续向原有地址发送以后的请求。只有在Cache-Control或Expires中进行了指定的情况下,这个响应才是可缓存的。新的临时性的URI 应当在响应的Location域中返回。除非这是一个HEAD请求,否则响应的实体中应当包含指向新的URI 的超链接及简短说明。因为部分浏览器不能识别307响应,因此需要添加上述必要信息以便用户能够理解并向新的 URI 发出访问请求。如果这不是一个GET或者HEAD请求,那么浏览器禁止自动进行重定向,除非得到用户的确认,因为请求的条件可能因此发生变化
|
||||
|
||||
BAD_REQUEST: 400, //1.语义有误,当前请求无法被服务器理解。除非进行修改,否则客户端不应该重复提交这个请求 2.请求参数有误
|
||||
UNAUTHORIZED: 401, //当前请求需要用户验证。该响应必须包含一个适用于被请求资源的 WWW-Authenticate 信息头用以询问用户信息。客户端可以重复提交一个包含恰当的 Authorization 头信息的请求。如果当前请求已经包含了 Authorization 证书,那么401响应代表着服务器验证已经拒绝了那些证书。如果401响应包含了与前一个响应相同的身份验证询问,且浏览器已经至少尝试了一次验证,那么浏览器应当向用户展示响应中包含的实体信息,因为这个实体信息中可能包含了相关诊断信息。参见RFC 2617
|
||||
PAYMENT_REQUIRED: 402, //该状态码是为了将来可能的需求而预留的
|
||||
FORBIDDEN: 403, //服务器已经理解请求,但是拒绝执行它。与401响应不同的是,身份验证并不能提供任何帮助,而且这个请求也不应该被重复提交。如果这不是一个HEAD请求,而且服务器希望能够讲清楚为何请求不能被执行,那么就应该在实体内描述拒绝的原因。当然服务器也可以返回一个404响应,假如它不希望让客户端获得任何信息
|
||||
NOT_FOUND: 404, //请求失败,请求所希望得到的资源未被在服务器上发现。没有信息能够告诉用户这个状况到底是暂时的还是永久的。假如服务器知道情况的话,应当使用410状态码来告知旧资源因为某些内部的配置机制问题,已经永久的不可用,而且没有任何可以跳转的地址。404这个状态码被广泛应用于当服务器不想揭示到底为何请求被拒绝或者没有其他适合的响应可用的情况下
|
||||
METHOD_NOT_ALLOWED: 405, //请求行中指定的请求方法不能被用于请求相应的资源。该响应必须返回一个Allow 头信息用以表示出当前资源能够接受的请求方法的列表。鉴于PUT,DELETE方法会对服务器上的资源进行写操作,因而绝大部分的网页服务器都不支持或者在默认配置下不允许上述请求方法,对于此类请求均会返回405错误
|
||||
NO_ACCEPTABLE: 406, //请求的资源的内容特性无法满足请求头中的条件,因而无法生成响应实体。除非这是一个 HEAD 请求,否则该响应就应当返回一个包含可以让用户或者浏览器从中选择最合适的实体特性以及地址列表的实体。实体的格式由Content-Type头中定义的媒体类型决定。浏览器可以根据格式及自身能力自行作出最佳选择。但是,规范中并没有定义任何作出此类自动选择的标准
|
||||
PROXY_AUTHENTICATION_REQUIRED: 407, //与401响应类似,只不过客户端必须在代理服务器上进行身份验证。代理服务器必须返回一个Proxy-Authenticate用以进行身份询问。客户端可以返回一个Proxy-Authorization信息头用以验证。参见RFC 2617
|
||||
REQUEST_TIMEOUT: 408, //请求超时。客户端没有在服务器预备等待的时间内完成一个请求的发送。客户端可以随时再次提交这一请求而无需进行任何更改
|
||||
CONFLICT: 409, //由于和被请求的资源的当前状态之间存在冲突,请求无法完成。这个代码只允许用在这样的情况下才能被使用:用户被认为能够解决冲突,并且会重新提交新的请求。该响应应当包含足够的信息以便用户发现冲突的源头。冲突通常发生于对PUT请求的处理中。例如,在采用版本检查的环境下,某次PUT提交的对特定资源的修改请求所附带的版本信息与之前的某个(第三方)请求向冲突,那么此时服务器就应该返回一个409错误,告知用户请求无法完成。此时,响应实体中很可能会包含两个冲突版本之间的差异比较,以便用户重新提交归并以后的新版本
|
||||
GONE: 410, //被请求的资源在服务器上已经不再可用,而且没有任何已知的转发地址。这样的状况应当被认为是永久性的。如果可能,拥有链接编辑功能的客户端应当在获得用户许可后删除所有指向这个地址的引用。如果服务器不知道或者无法确定这个状况是否是永久的,那么就应该使用404状态码。除非额外说明,否则这个响应是可缓存的。410响应的目的主要是帮助网站管理员维护网站,通知用户该资源已经不再可用,并且服务器拥有者希望所有指向这个资源的远端连接也被删除。这类事件在限时、增值服务中很普遍。同样,410响应也被用于通知客户端在当前服务器站点上,原本属于某个个人的资源已经不再可用。当然,是否需要把所有永久不可用的资源标记为'410 Gone',以及是否需要保持此标记多长时间,完全取决于服务器拥有者
|
||||
LENGTH_REQUIRED: 411, //服务器拒绝在没有定义Content-Length头的情况下接受请求。在添加了表明请求消息体长度的有效Content-Length头之后,客户端可以再次提交该请求
|
||||
PRECONDITION_FAILED: 412, //服务器在验证在请求的头字段中给出先决条件时,没能满足其中的一个或多个。这个状态码允许客户端在获取资源时在请求的元信息(请求头字段数据)中设置先决条件,以此避免该请求方法被应用到其希望的内容以外的资源上
|
||||
REQUEST_ENTITY_TOO_LARGE: 413, //服务器拒绝处理当前请求,因为该请求提交的实体数据大小超过了服务器愿意或者能够处理的范围。此种情况下,服务器可以关闭连接以免客户端继续发送此请求。如果这个状况是临时的,服务器应当返回一个 Retry-After 的响应头,以告知客户端可以在多少时间以后重新尝试
|
||||
REQUEST_URI_TOO_LONG: 414, //请求的URI长度超过了服务器能够解释的长度,因此服务器拒绝对该请求提供服务。这比较少见,通常的情况包括:本应使用POST方法的表单提交变成了GET方法,导致查询字符串(Query String)过长。重定向URI “黑洞”,例如每次重定向把旧的URI作为新的URI的一部分,导致在若干次重定向后URI超长。客户端正在尝试利用某些服务器中存在的安全漏洞攻击服务器。这类服务器使用固定长度的缓冲读取或操作请求的URI,当GET后的参数超过某个数值后,可能会产生缓冲区溢出,导致任意代码被执行[1]。没有此类漏洞的服务器,应当返回414状态码
|
||||
UNSUPPORTED_MEDIA_TYPE: 415, //对于当前请求的方法和所请求的资源,请求中提交的实体并不是服务器中所支持的格式,因此请求被拒绝
|
||||
REQUESTED_RANGE_NOT_SATISFIABLE: 416, //如果请求中包含了Range请求头,并且Range中指定的任何数据范围都与当前资源的可用范围不重合,同时请求中又没有定义If-Range请求头,那么服务器就应当返回416状态码。假如Range使用的是字节范围,那么这种情况就是指请求指定的所有数据范围的首字节位置都超过了当前资源的长度。服务器也应当在返回416状态码的同时,包含一个Content-Range实体头,用以指明当前资源的长度。这个响应也被禁止使用multipart/byteranges作为其 Content-Type
|
||||
EXPECTION_FAILED: 417, //在请求头Expect中指定的预期内容无法被服务器满足,或者这个服务器是一个代理服务器,它有明显的证据证明在当前路由的下一个节点上,Expect的内容无法被满足
|
||||
TOO_MANY_CONNECTIONS: 421, //从当前客户端所在的IP地址到服务器的连接数超过了服务器许可的最大范围。通常,这里的IP地址指的是从服务器上看到的客户端地址(比如用户的网关或者代理服务器地址)。在这种情况下,连接数的计算可能涉及到不止一个终端用户
|
||||
UNPROCESSABLE_ENTITY: 422, //请求格式正确,但是由于含有语义错误,无法响应
|
||||
FAILED_DEPENDENCY: 424, //由于之前的某个请求发生的错误,导致当前请求失败,例如PROPPATCH
|
||||
UNORDERED_COLLECTION: 425, //在WebDav Advanced Collections 草案中定义,但是未出现在《WebDAV 顺序集协议》(RFC 3658)中
|
||||
UPGRADE_REQUIRED: 426, //客户端应当切换到TLS/1.0
|
||||
RETRY_WITH: 449, //由微软扩展,代表请求应当在执行完适当的操作后进行重试
|
||||
|
||||
INTERNAL_SERVER_ERROR: 500, //服务器遇到了一个未曾预料的状况,导致了它无法完成对请求的处理。一般来说,这个问题都会在服务器的程序码出错时出现
|
||||
NOT_IMPLEMENTED: 501, //服务器不支持当前请求所需要的某个功能。当服务器无法识别请求的方法,并且无法支持其对任何资源的请求
|
||||
BAD_GATEWAY: 502, //作为网关或者代理工作的服务器尝试执行请求时,从上游服务器接收到无效的响应
|
||||
SERVICE_UNAVAILABLE: 503, //由于临时的服务器维护或者过载,服务器当前无法处理请求。这个状况是临时的,并且将在一段时间以后恢复。如果能够预计延迟时间,那么响应中可以包含一个 Retry-After 头用以标明这个延迟时间。如果没有给出这个 Retry-After 信息,那么客户端应当以处理500响应的方式处理它。注意:503状态码的存在并不意味着服务器在过载的时候必须使用它。某些服务器只不过是希望拒绝客户端的连接
|
||||
GATEWAY_TIMEOUT: 504, //作为网关或者代理工作的服务器尝试执行请求时,未能及时从上游服务器(URI标识出的服务器,例如HTTP、FTP、LDAP)或者辅助服务器(例如DNS)收到响应。注意:某些代理服务器在DNS查询超时时会返回400或者500错误
|
||||
HTTP_VERSION_NOT_SUPPORTED: 505, //服务器不支持,或者拒绝支持在请求中使用的HTTP版本。这暗示着服务器不能或不愿使用与客户端相同的版本。响应中应当包含一个描述了为何版本不被支持以及服务器支持哪些协议的实体
|
||||
VARIANT_ALSO_NEGOTIATES: 506, //服务器存在内部配置错误:被请求的协商变元资源被配置为在透明内容协商中使用自己,因此在一个协商处理中不是一个合适的重点
|
||||
INSUFFICIENT_STORAGE: 507, //服务器无法存储完成请求所必须的内容。这个状况被认为是临时的
|
||||
BANDWIDTH_LIMIT_EXCEEDED: 509, //服务器达到带宽限制。这不是一个官方的状态码,但是仍被广泛使用
|
||||
NOT_EXTENDED: 510 //获取资源所需要的策略并没有没满足
|
||||
|
||||
};
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import logger from './logger.js';
|
||||
import browserService from './browser-service.js';
|
||||
|
||||
// 允许无限量的监听器
|
||||
process.setMaxListeners(Infinity);
|
||||
// 输出未捕获异常
|
||||
process.on("uncaughtException", (err, origin) => {
|
||||
logger.error(`An unhandled error occurred: ${origin}`, err);
|
||||
});
|
||||
// 输出未处理的Promise.reject
|
||||
process.on("unhandledRejection", (_, promise) => {
|
||||
promise.catch(err => logger.error("An unhandled rejection occurred:", err));
|
||||
});
|
||||
// 输出系统警告信息
|
||||
process.on("warning", warning => logger.warn("System warning: ", warning));
|
||||
// 进程退出监听
|
||||
process.on("exit", () => {
|
||||
logger.info("Service exit");
|
||||
logger.footer();
|
||||
});
|
||||
// 进程被kill
|
||||
process.on("SIGTERM", () => {
|
||||
logger.warn("received kill signal");
|
||||
browserService.close().finally(() => process.exit(2));
|
||||
});
|
||||
// Ctrl-C进程退出
|
||||
process.on("SIGINT", () => {
|
||||
browserService.close().finally(() => process.exit(0));
|
||||
});
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export default interface ICompletionMessage {
|
||||
role: 'system' | 'assistant' | 'user' | 'function';
|
||||
content: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
import path from 'path';
|
||||
import _util from 'util';
|
||||
|
||||
import 'colors';
|
||||
import _ from 'lodash';
|
||||
import fs from 'fs-extra';
|
||||
import { format as dateFormat } from 'date-fns';
|
||||
|
||||
import config from './config.ts';
|
||||
import util from './util.ts';
|
||||
|
||||
const isVercelEnv = process.env.VERCEL;
|
||||
|
||||
class LogWriter {
|
||||
|
||||
#buffers = [];
|
||||
|
||||
constructor() {
|
||||
!isVercelEnv && fs.ensureDirSync(config.system.logDirPath);
|
||||
!isVercelEnv && this.work();
|
||||
}
|
||||
|
||||
push(content) {
|
||||
const buffer = Buffer.from(content);
|
||||
this.#buffers.push(buffer);
|
||||
}
|
||||
|
||||
writeSync(buffer) {
|
||||
!isVercelEnv && fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer);
|
||||
}
|
||||
|
||||
async write(buffer) {
|
||||
!isVercelEnv && await fs.appendFile(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), buffer);
|
||||
}
|
||||
|
||||
flush() {
|
||||
if(!this.#buffers.length) return;
|
||||
!isVercelEnv && fs.appendFileSync(path.join(config.system.logDirPath, `/${util.getDateString()}.log`), Buffer.concat(this.#buffers));
|
||||
}
|
||||
|
||||
work() {
|
||||
if (!this.#buffers.length) return setTimeout(this.work.bind(this), config.system.logWriteInterval);
|
||||
const buffer = Buffer.concat(this.#buffers);
|
||||
this.#buffers = [];
|
||||
this.write(buffer)
|
||||
.finally(() => setTimeout(this.work.bind(this), config.system.logWriteInterval))
|
||||
.catch(err => console.error("Log write error:", err));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class LogText {
|
||||
|
||||
/** @type {string} 日志级别 */
|
||||
level;
|
||||
/** @type {string} 日志文本 */
|
||||
text;
|
||||
/** @type {string} 日志来源 */
|
||||
source;
|
||||
/** @type {Date} 日志发生时间 */
|
||||
time = new Date();
|
||||
|
||||
constructor(level, ...params) {
|
||||
this.level = level;
|
||||
this.text = _util.format.apply(null, params);
|
||||
this.source = this.#getStackTopCodeInfo();
|
||||
}
|
||||
|
||||
#getStackTopCodeInfo() {
|
||||
const unknownInfo = { name: "unknown", codeLine: 0, codeColumn: 0 };
|
||||
const stackArray = new Error().stack.split("\n");
|
||||
const text = stackArray[4];
|
||||
if (!text)
|
||||
return unknownInfo;
|
||||
const match = text.match(/at (.+) \((.+)\)/) || text.match(/at (.+)/);
|
||||
if (!match || !_.isString(match[2] || match[1]))
|
||||
return unknownInfo;
|
||||
const temp = match[2] || match[1];
|
||||
const _match = temp.match(/([a-zA-Z0-9_\-\.]+)\:(\d+)\:(\d+)$/);
|
||||
if (!_match)
|
||||
return unknownInfo;
|
||||
const [, scriptPath, codeLine, codeColumn] = _match as any;
|
||||
return {
|
||||
name: scriptPath ? scriptPath.replace(/.js$/, "") : "unknown",
|
||||
path: scriptPath || null,
|
||||
codeLine: parseInt(codeLine || 0),
|
||||
codeColumn: parseInt(codeColumn || 0)
|
||||
};
|
||||
}
|
||||
|
||||
toString() {
|
||||
return `[${dateFormat(this.time, "yyyy-MM-dd HH:mm:ss.SSS")}][${this.level}][${this.source.name}<${this.source.codeLine},${this.source.codeColumn}>] ${this.text}`;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class Logger {
|
||||
|
||||
/** @type {Object} 系统配置 */
|
||||
config = {};
|
||||
/** @type {Object} 日志级别映射 */
|
||||
static Level = {
|
||||
Success: "success",
|
||||
Info: "info",
|
||||
Log: "log",
|
||||
Debug: "debug",
|
||||
Warning: "warning",
|
||||
Error: "error",
|
||||
Fatal: "fatal"
|
||||
};
|
||||
/** @type {Object} 日志级别文本颜色樱色 */
|
||||
static LevelColor = {
|
||||
[Logger.Level.Success]: "green",
|
||||
[Logger.Level.Info]: "brightCyan",
|
||||
[Logger.Level.Debug]: "white",
|
||||
[Logger.Level.Warning]: "brightYellow",
|
||||
[Logger.Level.Error]: "brightRed",
|
||||
[Logger.Level.Fatal]: "red"
|
||||
};
|
||||
#writer;
|
||||
|
||||
constructor() {
|
||||
this.#writer = new LogWriter();
|
||||
}
|
||||
|
||||
header() {
|
||||
this.#writer.writeSync(Buffer.from(`\n\n===================== LOG START ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`));
|
||||
}
|
||||
|
||||
footer() {
|
||||
this.#writer.flush(); //将未写入文件的日志缓存写入
|
||||
this.#writer.writeSync(Buffer.from(`\n\n===================== LOG END ${dateFormat(new Date(), "yyyy-MM-dd HH:mm:ss.SSS")} =====================\n\n`));
|
||||
}
|
||||
|
||||
success(...params) {
|
||||
const content = new LogText(Logger.Level.Success, ...params).toString();
|
||||
console.info(content[Logger.LevelColor[Logger.Level.Success]]);
|
||||
this.#writer.push(content + "\n");
|
||||
}
|
||||
|
||||
info(...params) {
|
||||
const content = new LogText(Logger.Level.Info, ...params).toString();
|
||||
console.info(content[Logger.LevelColor[Logger.Level.Info]]);
|
||||
this.#writer.push(content + "\n");
|
||||
}
|
||||
|
||||
log(...params) {
|
||||
const content = new LogText(Logger.Level.Log, ...params).toString();
|
||||
console.log(content[Logger.LevelColor[Logger.Level.Log]]);
|
||||
this.#writer.push(content + "\n");
|
||||
}
|
||||
|
||||
debug(...params) {
|
||||
if(!config.system.debug) return; //非调试模式忽略debug
|
||||
const content = new LogText(Logger.Level.Debug, ...params).toString();
|
||||
console.debug(content[Logger.LevelColor[Logger.Level.Debug]]);
|
||||
this.#writer.push(content + "\n");
|
||||
}
|
||||
|
||||
warn(...params) {
|
||||
const content = new LogText(Logger.Level.Warning, ...params).toString();
|
||||
console.warn(content[Logger.LevelColor[Logger.Level.Warning]]);
|
||||
this.#writer.push(content + "\n");
|
||||
}
|
||||
|
||||
error(...params) {
|
||||
const content = new LogText(Logger.Level.Error, ...params).toString();
|
||||
console.error(content[Logger.LevelColor[Logger.Level.Error]]);
|
||||
this.#writer.push(content);
|
||||
}
|
||||
|
||||
fatal(...params) {
|
||||
const content = new LogText(Logger.Level.Fatal, ...params).toString();
|
||||
console.error(content[Logger.LevelColor[Logger.Level.Fatal]]);
|
||||
this.#writer.push(content);
|
||||
}
|
||||
|
||||
destory() {
|
||||
this.#writer.destory();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Logger();
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
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';
|
||||
|
||||
export interface RequestOptions {
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export default class Request {
|
||||
|
||||
/** 请求方法 */
|
||||
method: string;
|
||||
/** 请求URL */
|
||||
url: string;
|
||||
/** 请求路径 */
|
||||
path: string;
|
||||
/** 请求载荷类型 */
|
||||
type: string;
|
||||
/** 请求headers */
|
||||
headers: any;
|
||||
/** 请求原始查询字符串 */
|
||||
search: string;
|
||||
/** 请求查询参数 */
|
||||
query: any;
|
||||
/** 请求URL参数 */
|
||||
params: any;
|
||||
/** 请求载荷 */
|
||||
body: any;
|
||||
/** 上传的文件 */
|
||||
files: any[];
|
||||
/** 客户端IP地址 */
|
||||
remoteIP: string | null;
|
||||
/** 请求接受时间戳(毫秒) */
|
||||
time: number;
|
||||
|
||||
constructor(ctx, options: RequestOptions = {}) {
|
||||
const { time } = options;
|
||||
this.method = ctx.request.method;
|
||||
this.url = ctx.request.url;
|
||||
this.path = ctx.request.path;
|
||||
this.type = ctx.request.type;
|
||||
this.headers = ctx.request.headers || {};
|
||||
this.search = ctx.request.search;
|
||||
this.query = ctx.query || {};
|
||||
this.params = ctx.params || {};
|
||||
this.body = ctx.request.body || {};
|
||||
// koa-body 的 files 可能是对象 { files: [File, File] } 或 { files: File }
|
||||
// 需要统一转换为数组格式
|
||||
const rawFiles = ctx.request.files;
|
||||
if (rawFiles) {
|
||||
if (Array.isArray(rawFiles)) {
|
||||
this.files = rawFiles;
|
||||
} else if (typeof rawFiles === 'object') {
|
||||
// 遍历对象,提取所有文件
|
||||
const filesArray: any[] = [];
|
||||
for (const key in rawFiles) {
|
||||
const fileOrFiles = rawFiles[key];
|
||||
if (Array.isArray(fileOrFiles)) {
|
||||
filesArray.push(...fileOrFiles);
|
||||
} else if (fileOrFiles) {
|
||||
filesArray.push(fileOrFiles);
|
||||
}
|
||||
}
|
||||
this.files = filesArray;
|
||||
} else {
|
||||
this.files = [];
|
||||
}
|
||||
} else {
|
||||
this.files = [];
|
||||
}
|
||||
this.remoteIP = this.headers["X-Real-IP"] || this.headers["x-real-ip"] || this.headers["X-Forwarded-For"] || this.headers["x-forwarded-for"] || ctx.ip || null;
|
||||
this.time = Number(_.defaultTo(time, util.timestamp()));
|
||||
}
|
||||
|
||||
validate(key: string, fn?: Function, message?: string) {
|
||||
try {
|
||||
const value = _.get(this, key);
|
||||
if (fn) {
|
||||
if (fn(value) === false)
|
||||
throw `[Mismatch] -> ${fn}`;
|
||||
}
|
||||
else if (_.isUndefined(value))
|
||||
throw '[Undefined]';
|
||||
}
|
||||
catch (err) {
|
||||
logger.warn(`Params ${key} invalid:`, err);
|
||||
throw new APIException(EX.API_REQUEST_PARAMS_INVALID, message || `Params ${key} invalid`);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
export interface BodyOptions {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: any;
|
||||
statusCode?: number;
|
||||
}
|
||||
|
||||
export default class Body {
|
||||
|
||||
/** 状态码 */
|
||||
code: number;
|
||||
/** 状态消息 */
|
||||
message: string;
|
||||
/** 载荷 */
|
||||
data: any;
|
||||
/** HTTP状态码 */
|
||||
statusCode: number;
|
||||
|
||||
constructor(options: BodyOptions = {}) {
|
||||
const { code, message, data, statusCode } = options;
|
||||
this.code = Number(_.defaultTo(code, 0));
|
||||
this.message = _.defaultTo(message, 'OK');
|
||||
this.data = _.defaultTo(data, null);
|
||||
this.statusCode = Number(_.defaultTo(statusCode, 200));
|
||||
}
|
||||
|
||||
toObject() {
|
||||
return {
|
||||
code: this.code,
|
||||
message: this.message,
|
||||
data: this.data
|
||||
};
|
||||
}
|
||||
|
||||
static isInstance(value) {
|
||||
return value instanceof Body;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import Body from './Body.ts';
|
||||
import Exception from '../exceptions/Exception.ts';
|
||||
import APIException from '../exceptions/APIException.ts';
|
||||
import EX from '../consts/exceptions.ts';
|
||||
import HTTP_STATUS_CODES from '../http-status-codes.ts';
|
||||
|
||||
export default class FailureBody extends Body {
|
||||
|
||||
constructor(error: APIException | Exception | Error, _data?: any) {
|
||||
let errcode, errmsg, data = _data, httpStatusCode = HTTP_STATUS_CODES.OK;;
|
||||
if(_.isString(error))
|
||||
error = new Exception(EX.SYSTEM_ERROR, error);
|
||||
else if(error instanceof APIException || error instanceof Exception)
|
||||
({ errcode, errmsg, data, httpStatusCode } = error);
|
||||
else if(_.isError(error))
|
||||
({ errcode, errmsg, data, httpStatusCode } = new Exception(EX.SYSTEM_ERROR, error.message));
|
||||
super({
|
||||
code: errcode || -1,
|
||||
message: errmsg || 'Internal error',
|
||||
data,
|
||||
statusCode: httpStatusCode
|
||||
});
|
||||
}
|
||||
|
||||
static isInstance(value) {
|
||||
return value instanceof FailureBody;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import mime from 'mime';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Body from './Body.ts';
|
||||
import util from '../util.ts';
|
||||
|
||||
export interface ResponseOptions {
|
||||
statusCode?: number;
|
||||
type?: string;
|
||||
headers?: Record<string, any>;
|
||||
redirect?: string;
|
||||
body?: any;
|
||||
size?: number;
|
||||
time?: number;
|
||||
}
|
||||
|
||||
export default class Response {
|
||||
|
||||
/** 响应HTTP状态码 */
|
||||
statusCode: number;
|
||||
/** 响应内容类型 */
|
||||
type: string;
|
||||
/** 响应headers */
|
||||
headers: Record<string, any>;
|
||||
/** 重定向目标 */
|
||||
redirect: string;
|
||||
/** 响应载荷 */
|
||||
body: any;
|
||||
/** 响应载荷大小 */
|
||||
size: number;
|
||||
/** 响应时间戳 */
|
||||
time: number;
|
||||
|
||||
constructor(body: any, options: ResponseOptions = {}) {
|
||||
const { statusCode, type, headers, redirect, size, time } = options;
|
||||
this.statusCode = Number(_.defaultTo(statusCode, Body.isInstance(body) ? body.statusCode : undefined))
|
||||
this.type = type;
|
||||
this.headers = headers;
|
||||
this.redirect = redirect;
|
||||
this.size = size;
|
||||
this.time = Number(_.defaultTo(time, util.timestamp()));
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
injectTo(ctx) {
|
||||
this.redirect && ctx.redirect(this.redirect);
|
||||
this.statusCode && (ctx.status = this.statusCode);
|
||||
this.type && (ctx.type = mime.getType(this.type) || this.type);
|
||||
const headers = this.headers || {};
|
||||
if(this.size && !headers["Content-Length"] && !headers["content-length"])
|
||||
headers["Content-Length"] = this.size;
|
||||
ctx.set(headers);
|
||||
if(Body.isInstance(this.body))
|
||||
ctx.body = this.body.toObject();
|
||||
else
|
||||
ctx.body = this.body;
|
||||
}
|
||||
|
||||
static isInstance(value) {
|
||||
return value instanceof Response;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import _ from 'lodash';
|
||||
|
||||
import Body from './Body.ts';
|
||||
|
||||
export default class SuccessfulBody extends Body {
|
||||
|
||||
constructor(data: any, message?: string) {
|
||||
super({
|
||||
code: 0,
|
||||
message: _.defaultTo(message, "OK"),
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
static isInstance(value) {
|
||||
return value instanceof SuccessfulBody;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
import Koa from 'koa';
|
||||
import KoaRouter from 'koa-router';
|
||||
import koaRange from 'koa-range';
|
||||
import koaCors from "koa2-cors";
|
||||
import koaBody from 'koa-body';
|
||||
import _ from 'lodash';
|
||||
|
||||
import Exception from './exceptions/Exception.ts';
|
||||
import Request from './request/Request.ts';
|
||||
import Response from './response/Response.js';
|
||||
import FailureBody from './response/FailureBody.ts';
|
||||
import EX from './consts/exceptions.ts';
|
||||
import logger from './logger.ts';
|
||||
import config from './config.ts';
|
||||
|
||||
class Server {
|
||||
|
||||
app;
|
||||
router;
|
||||
koaBodyMiddleware;
|
||||
|
||||
constructor() {
|
||||
this.app = new Koa();
|
||||
this.app.use(koaCors());
|
||||
// 范围请求支持
|
||||
this.app.use(koaRange);
|
||||
this.router = new KoaRouter({ prefix: config.service.urlPrefix });
|
||||
|
||||
// 预先创建 koa-body 中间件,支持 multipart 文件上传
|
||||
this.koaBodyMiddleware = koaBody({
|
||||
multipart: true,
|
||||
formidable: {
|
||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
||||
keepExtensions: true,
|
||||
},
|
||||
formLimit: '100mb',
|
||||
jsonLimit: '100mb',
|
||||
textLimit: '100mb',
|
||||
parsedMethods: ['POST', 'PUT', 'PATCH'],
|
||||
});
|
||||
|
||||
// 前置处理异常拦截
|
||||
this.app.use(async (ctx: any, next: Function) => {
|
||||
if(ctx.request.type === "application/xml" || ctx.request.type === "application/ssml+xml")
|
||||
ctx.req.headers["content-type"] = "text/xml";
|
||||
try { await next() }
|
||||
catch (err) {
|
||||
logger.error(err);
|
||||
const failureBody = new FailureBody(err);
|
||||
new Response(failureBody).injectTo(ctx);
|
||||
}
|
||||
});
|
||||
// 自定义 JSON 解析中间件
|
||||
this.app.use(async (ctx: any, next: Function) => {
|
||||
// 跳过 multipart 请求,让 koa-body 处理
|
||||
if (ctx.is('multipart')) {
|
||||
await next();
|
||||
return;
|
||||
}
|
||||
if (ctx.is('application/json') && ['POST', 'PUT', 'PATCH'].includes(ctx.method)) {
|
||||
logger.debug('开始自定义 JSON 解析');
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
ctx.req.on('data', (chunk: Buffer) => {
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
ctx.req.on('end', () => {
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
ctx.req.on('error', reject);
|
||||
});
|
||||
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
|
||||
// 清理问题字符
|
||||
let cleanedBody = body
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '\n')
|
||||
.replace(/\u00A0/g, ' ')
|
||||
.replace(/[\u2000-\u200B]/g, ' ')
|
||||
.replace(/\uFEFF/g, '')
|
||||
.trim();
|
||||
|
||||
const parsedBody = JSON.parse(cleanedBody);
|
||||
|
||||
logger.debug('JSON 解析成功,跳过 koa-body');
|
||||
|
||||
ctx.request.body = parsedBody;
|
||||
ctx.request.rawBody = cleanedBody;
|
||||
|
||||
// 标记已处理,避免 koa-body 再次处理
|
||||
ctx._jsonProcessed = true;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
// 载荷解析器支持(只处理未被自定义解析器处理的请求)
|
||||
this.app.use(async (ctx: any, next: Function) => {
|
||||
if (!ctx._jsonProcessed) {
|
||||
await this.koaBodyMiddleware(ctx, next);
|
||||
} else {
|
||||
await next();
|
||||
}
|
||||
});
|
||||
this.app.on("error", (err: any) => {
|
||||
// 忽略连接重试、中断、管道、取消错误
|
||||
if (["ECONNRESET", "ECONNABORTED", "EPIPE", "ECANCELED"].includes(err.code)) return;
|
||||
logger.error(err);
|
||||
});
|
||||
logger.success("Server initialized");
|
||||
}
|
||||
|
||||
/**
|
||||
* 附加路由
|
||||
*
|
||||
* @param routes 路由列表
|
||||
*/
|
||||
attachRoutes(routes: any[]) {
|
||||
routes.forEach((route: any) => {
|
||||
const prefix = route.prefix || "";
|
||||
for (let method in route) {
|
||||
if(method === "prefix") continue;
|
||||
if (!_.isObject(route[method])) {
|
||||
logger.warn(`Router ${prefix} ${method} invalid`);
|
||||
continue;
|
||||
}
|
||||
for (let uri in route[method]) {
|
||||
this.router[method](`${prefix}${uri}`, async ctx => {
|
||||
const { request, response } = await this.#requestProcessing(ctx, route[method][uri]);
|
||||
if(response != null && config.system.requestLog)
|
||||
logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`);
|
||||
});
|
||||
}
|
||||
}
|
||||
logger.info(`Route ${config.service.urlPrefix || ""}${prefix} attached`);
|
||||
});
|
||||
this.app.use(this.router.routes());
|
||||
this.app.use((ctx: any) => {
|
||||
const request = new Request(ctx);
|
||||
logger.debug(`-> ${ctx.request.method} ${ctx.request.url} request is not supported - ${request.remoteIP || "unknown"}`);
|
||||
// const failureBody = new FailureBody(new Exception(EX.SYSTEM_NOT_ROUTE_MATCHING, "Request is not supported"));
|
||||
// const response = new Response(failureBody);
|
||||
const message = `[请求有误]: 正确请求为 POST -> /v1/chat/completions,当前请求为 ${ctx.request.method} -> ${ctx.request.url} 请纠正`;
|
||||
logger.warn(message);
|
||||
const failureBody = new FailureBody(new Error(message));
|
||||
const response = new Response(failureBody);
|
||||
response.injectTo(ctx);
|
||||
if(config.system.requestLog)
|
||||
logger.info(`<- ${request.method} ${request.url} ${response.time - request.time}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求处理
|
||||
*
|
||||
* @param ctx 上下文
|
||||
* @param routeFn 路由方法
|
||||
*/
|
||||
#requestProcessing(ctx: any, routeFn: Function): Promise<any> {
|
||||
return new Promise(resolve => {
|
||||
const request = new Request(ctx);
|
||||
try {
|
||||
if(config.system.requestLog)
|
||||
logger.info(`-> ${request.method} ${request.url}`);
|
||||
routeFn(request)
|
||||
.then(response => {
|
||||
try {
|
||||
if(!Response.isInstance(response)) {
|
||||
const _response = new Response(response);
|
||||
_response.injectTo(ctx);
|
||||
return resolve({ request, response: _response });
|
||||
}
|
||||
response.injectTo(ctx);
|
||||
resolve({ request, response });
|
||||
}
|
||||
catch(err) {
|
||||
logger.error(err);
|
||||
const failureBody = new FailureBody(err);
|
||||
const response = new Response(failureBody);
|
||||
response.injectTo(ctx);
|
||||
resolve({ request, response });
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
try {
|
||||
logger.error(err);
|
||||
const failureBody = new FailureBody(err);
|
||||
const response = new Response(failureBody);
|
||||
response.injectTo(ctx);
|
||||
resolve({ request, response });
|
||||
}
|
||||
catch(err) {
|
||||
logger.error(err);
|
||||
const failureBody = new FailureBody(err);
|
||||
const response = new Response(failureBody);
|
||||
response.injectTo(ctx);
|
||||
resolve({ request, response });
|
||||
}
|
||||
});
|
||||
}
|
||||
catch(err) {
|
||||
logger.error(err);
|
||||
const failureBody = new FailureBody(err);
|
||||
const response = new Response(failureBody);
|
||||
response.injectTo(ctx);
|
||||
resolve({ request, response });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听端口
|
||||
*/
|
||||
async listen() {
|
||||
const host = config.service.host;
|
||||
const port = config.service.port;
|
||||
await Promise.all([
|
||||
new Promise((resolve, reject) => {
|
||||
if(host === "0.0.0.0" || host === "localhost" || host === "127.0.0.1")
|
||||
return resolve(null);
|
||||
this.app.listen(port, "localhost", err => {
|
||||
if(err) return reject(err);
|
||||
resolve(null);
|
||||
});
|
||||
}),
|
||||
new Promise((resolve, reject) => {
|
||||
this.app.listen(port, host, err => {
|
||||
if(err) return reject(err);
|
||||
resolve(null);
|
||||
});
|
||||
})
|
||||
]);
|
||||
logger.success(`Server listening on port ${port} (${host})`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default new Server();
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import os from "os";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { Readable, Writable } from "stream";
|
||||
|
||||
import "colors";
|
||||
import mime from "mime";
|
||||
import axios from "axios";
|
||||
import fs from "fs-extra";
|
||||
import { v1 as uuid } from "uuid";
|
||||
import { format as dateFormat } from "date-fns";
|
||||
import CRC32 from "crc-32";
|
||||
import randomstring from "randomstring";
|
||||
import _ from "lodash";
|
||||
import { CronJob } from "cron";
|
||||
|
||||
import HTTP_STATUS_CODE from "./http-status-codes.ts";
|
||||
|
||||
const autoIdMap = new Map();
|
||||
|
||||
const util = {
|
||||
is2DArrays(value: any) {
|
||||
return (
|
||||
_.isArray(value) &&
|
||||
(!value[0] || (_.isArray(value[0]) && _.isArray(value[value.length - 1])))
|
||||
);
|
||||
},
|
||||
|
||||
uuid: (separator = true) => (separator ? uuid() : uuid().replace(/\-/g, "")),
|
||||
|
||||
autoId: (prefix = "") => {
|
||||
let index = autoIdMap.get(prefix);
|
||||
if (index > 999999) index = 0; //超过最大数字则重置为0
|
||||
autoIdMap.set(prefix, (index || 0) + 1);
|
||||
return `${prefix}${index || 1}`;
|
||||
},
|
||||
|
||||
ignoreJSONParse(value: string) {
|
||||
const result = _.attempt(() => JSON.parse(value));
|
||||
if (_.isError(result)) return null;
|
||||
return result;
|
||||
},
|
||||
|
||||
generateRandomString(options: any): string {
|
||||
return randomstring.generate(options);
|
||||
},
|
||||
|
||||
getResponseContentType(value: any): string | null {
|
||||
return value.headers
|
||||
? value.headers["content-type"] || value.headers["Content-Type"]
|
||||
: null;
|
||||
},
|
||||
|
||||
mimeToExtension(value: string) {
|
||||
let extension = mime.getExtension(value);
|
||||
if (extension == "mpga") return "mp3";
|
||||
return extension;
|
||||
},
|
||||
|
||||
extractURLExtension(value: string) {
|
||||
const extname = path.extname(new URL(value).pathname);
|
||||
return extname.substring(1).toLowerCase();
|
||||
},
|
||||
|
||||
createCronJob(cronPatterns: any, callback?: Function) {
|
||||
if (!_.isFunction(callback))
|
||||
throw new Error("callback must be an Function");
|
||||
return new CronJob(
|
||||
cronPatterns,
|
||||
() => callback(),
|
||||
null,
|
||||
false,
|
||||
"Asia/Shanghai"
|
||||
);
|
||||
},
|
||||
|
||||
getDateString(format = "yyyy-MM-dd", date = new Date()) {
|
||||
return dateFormat(date, format);
|
||||
},
|
||||
|
||||
getIPAddressesByIPv4(): string[] {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses = [];
|
||||
for (let name in interfaces) {
|
||||
const networks = interfaces[name];
|
||||
const results = networks.filter(
|
||||
(network) =>
|
||||
network.family === "IPv4" &&
|
||||
network.address !== "127.0.0.1" &&
|
||||
!network.internal
|
||||
);
|
||||
if (results[0] && results[0].address) addresses.push(results[0].address);
|
||||
}
|
||||
return addresses;
|
||||
},
|
||||
|
||||
getMACAddressesByIPv4(): string[] {
|
||||
const interfaces = os.networkInterfaces();
|
||||
const addresses = [];
|
||||
for (let name in interfaces) {
|
||||
const networks = interfaces[name];
|
||||
const results = networks.filter(
|
||||
(network) =>
|
||||
network.family === "IPv4" &&
|
||||
network.address !== "127.0.0.1" &&
|
||||
!network.internal
|
||||
);
|
||||
if (results[0] && results[0].mac) addresses.push(results[0].mac);
|
||||
}
|
||||
return addresses;
|
||||
},
|
||||
|
||||
generateSSEData(event?: string, data?: string, retry?: number) {
|
||||
return `event: ${event || "message"}\ndata: ${(data || "")
|
||||
.replace(/\n/g, "\\n")
|
||||
.replace(/\s/g, "\\s")}\nretry: ${retry || 3000}\n\n`;
|
||||
},
|
||||
|
||||
buildDataBASE64(type, ext, buffer) {
|
||||
return `data:${type}/${ext.replace("jpg", "jpeg")};base64,${buffer.toString(
|
||||
"base64"
|
||||
)}`;
|
||||
},
|
||||
|
||||
isLinux() {
|
||||
return os.platform() !== "win32";
|
||||
},
|
||||
|
||||
isIPAddress(value) {
|
||||
return (
|
||||
_.isString(value) &&
|
||||
(/^((2[0-4]\d|25[0-5]|[01]?\d\d?)\.){3}(2[0-4]\d|25[0-5]|[01]?\d\d?)$/.test(
|
||||
value
|
||||
) ||
|
||||
/\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*/.test(
|
||||
value
|
||||
))
|
||||
);
|
||||
},
|
||||
|
||||
isPort(value) {
|
||||
return _.isNumber(value) && value > 0 && value < 65536;
|
||||
},
|
||||
|
||||
isReadStream(value): boolean {
|
||||
return (
|
||||
value &&
|
||||
(value instanceof Readable || "readable" in value || value.readable)
|
||||
);
|
||||
},
|
||||
|
||||
isWriteStream(value): boolean {
|
||||
return (
|
||||
value &&
|
||||
(value instanceof Writable || "writable" in value || value.writable)
|
||||
);
|
||||
},
|
||||
|
||||
isHttpStatusCode(value) {
|
||||
return _.isNumber(value) && Object.values(HTTP_STATUS_CODE).includes(value);
|
||||
},
|
||||
|
||||
isURL(value) {
|
||||
return !_.isUndefined(value) && /^(http|https)/.test(value);
|
||||
},
|
||||
|
||||
isSrc(value) {
|
||||
return !_.isUndefined(value) && /^\/.+\.[0-9a-zA-Z]+(\?.+)?$/.test(value);
|
||||
},
|
||||
|
||||
isBASE64(value) {
|
||||
return !_.isUndefined(value) && /^[a-zA-Z0-9\/\+]+(=?)+$/.test(value);
|
||||
},
|
||||
|
||||
isBASE64Data(value) {
|
||||
return /^data:/.test(value);
|
||||
},
|
||||
|
||||
extractBASE64DataFormat(value): string | null {
|
||||
const match = value.trim().match(/^data:(.+);base64,/);
|
||||
if (!match) return null;
|
||||
return match[1];
|
||||
},
|
||||
|
||||
removeBASE64DataHeader(value): string {
|
||||
return value.replace(/^data:(.+);base64,/, "");
|
||||
},
|
||||
|
||||
isDataString(value): boolean {
|
||||
return /^(base64|json):/.test(value);
|
||||
},
|
||||
|
||||
isStringNumber(value) {
|
||||
return _.isFinite(Number(value));
|
||||
},
|
||||
|
||||
isUnixTimestamp(value) {
|
||||
return /^[0-9]{10}$/.test(`${value}`);
|
||||
},
|
||||
|
||||
isTimestamp(value) {
|
||||
return /^[0-9]{13}$/.test(`${value}`);
|
||||
},
|
||||
|
||||
isEmail(value) {
|
||||
return /^([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+@([a-zA-Z0-9]+[_|\_|\.]?)*[a-zA-Z0-9]+\.[a-zA-Z]{2,3}$/.test(
|
||||
value
|
||||
);
|
||||
},
|
||||
|
||||
isAsyncFunction(value) {
|
||||
return Object.prototype.toString.call(value) === "[object AsyncFunction]";
|
||||
},
|
||||
|
||||
async isAPNG(filePath) {
|
||||
let head;
|
||||
const readStream = fs.createReadStream(filePath, { start: 37, end: 40 });
|
||||
const readPromise = new Promise((resolve, reject) => {
|
||||
readStream.once("end", resolve);
|
||||
readStream.once("error", reject);
|
||||
});
|
||||
readStream.once("data", (data) => (head = data));
|
||||
await readPromise;
|
||||
return head.compare(Buffer.from([0x61, 0x63, 0x54, 0x4c])) === 0;
|
||||
},
|
||||
|
||||
unixTimestamp() {
|
||||
return parseInt(`${Date.now() / 1000}`);
|
||||
},
|
||||
|
||||
timestamp() {
|
||||
return Date.now();
|
||||
},
|
||||
|
||||
urlJoin(...values) {
|
||||
let url = "";
|
||||
for (let i = 0; i < values.length; i++)
|
||||
url += `${i > 0 ? "/" : ""}${values[i]
|
||||
.replace(/^\/*/, "")
|
||||
.replace(/\/*$/, "")}`;
|
||||
return url;
|
||||
},
|
||||
|
||||
millisecondsToHmss(milliseconds) {
|
||||
if (_.isString(milliseconds)) return milliseconds;
|
||||
milliseconds = parseInt(milliseconds);
|
||||
const sec = Math.floor(milliseconds / 1000);
|
||||
const hours = Math.floor(sec / 3600);
|
||||
const minutes = Math.floor((sec - hours * 3600) / 60);
|
||||
const seconds = sec - hours * 3600 - minutes * 60;
|
||||
const ms = (milliseconds % 60000) - seconds * 1000;
|
||||
return `${hours > 9 ? hours : "0" + hours}:${
|
||||
minutes > 9 ? minutes : "0" + minutes
|
||||
}:${seconds > 9 ? seconds : "0" + seconds}.${ms}`;
|
||||
},
|
||||
|
||||
millisecondsToTimeString(milliseconds) {
|
||||
if (milliseconds < 1000) return `${milliseconds}ms`;
|
||||
if (milliseconds < 60000)
|
||||
return `${parseFloat((milliseconds / 1000).toFixed(2))}s`;
|
||||
return `${Math.floor(milliseconds / 1000 / 60)}m${Math.floor(
|
||||
(milliseconds / 1000) % 60
|
||||
)}s`;
|
||||
},
|
||||
|
||||
rgbToHex(r, g, b): string {
|
||||
return ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
|
||||
},
|
||||
|
||||
hexToRgb(hex) {
|
||||
const value = parseInt(hex.replace(/^#/, ""), 16);
|
||||
return [(value >> 16) & 255, (value >> 8) & 255, value & 255];
|
||||
},
|
||||
|
||||
md5(value) {
|
||||
return crypto.createHash("md5").update(value).digest("hex");
|
||||
},
|
||||
|
||||
crc32(value) {
|
||||
return _.isBuffer(value) ? CRC32.buf(value) : CRC32.str(value);
|
||||
},
|
||||
|
||||
arrayParse(value): any[] {
|
||||
return _.isArray(value) ? value : [value];
|
||||
},
|
||||
|
||||
booleanParse(value) {
|
||||
return value === "true" || value === true ? true : false;
|
||||
},
|
||||
|
||||
encodeBASE64(value) {
|
||||
return Buffer.from(value).toString("base64");
|
||||
},
|
||||
|
||||
decodeBASE64(value) {
|
||||
return Buffer.from(value, "base64").toString();
|
||||
},
|
||||
|
||||
async fetchFileBASE64(url: string) {
|
||||
const result = await axios.get(url, {
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
return result.data.toString("base64");
|
||||
},
|
||||
};
|
||||
|
||||
export default util;
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Seedance 2.0 测试3: 图片+音频混合上传"""
|
||||
|
||||
import sys
|
||||
import requests
|
||||
|
||||
TOKEN = sys.argv[1] if len(sys.argv) > 1 else "99999"
|
||||
BASE_URL = "http://localhost:8000"
|
||||
IMAGE_FILE = "/mnt/f/tmp/2026年2月20日/11.png"
|
||||
AUDIO_FILE = "/mnt/f/tmp/2026年2月20日/22.wav"
|
||||
|
||||
print("=" * 42)
|
||||
print(" [测试3] 图片+音频混合上传")
|
||||
print("=" * 42)
|
||||
print(f"POST {BASE_URL}/v1/videos/generations")
|
||||
print(f" model=seedance-2.0-fast")
|
||||
print(f" files=11.png (image) + 22.wav (audio)")
|
||||
print()
|
||||
|
||||
resp = requests.post(
|
||||
f"{BASE_URL}/v1/videos/generations",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
data={
|
||||
"model": "seedance-2.0-fast",
|
||||
"prompt": "@1 图片中的人物随着音乐 @2 开始跳舞",
|
||||
"ratio": "9:16",
|
||||
"duration": "5",
|
||||
},
|
||||
files=[
|
||||
("files", ("11.png", open(IMAGE_FILE, "rb"), "image/png")),
|
||||
("files", ("22.wav", open(AUDIO_FILE, "rb"), "audio/wav")),
|
||||
],
|
||||
)
|
||||
|
||||
print(f"HTTP {resp.status_code}")
|
||||
print()
|
||||
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
print(f"created: {result.get('created', '')}")
|
||||
data = result.get("data", [])
|
||||
if data:
|
||||
for i, item in enumerate(data):
|
||||
url = item.get("url", "")
|
||||
prompt = item.get("revised_prompt", "")
|
||||
print(f"revised_prompt: {prompt}")
|
||||
print()
|
||||
print(f"Video URL:")
|
||||
print(url)
|
||||
else:
|
||||
print("data 为空,未生成视频")
|
||||
print(f"原始响应: {resp.text}")
|
||||
else:
|
||||
print(f"请求失败:")
|
||||
print(resp.text)
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
#!/bin/bash
|
||||
# Seedance 2.0 多类型素材(图片+音频)测试脚本
|
||||
# 用法: bash test-seedance-media.sh [sessionid]
|
||||
|
||||
TOKEN="${1:-99999}"
|
||||
BASE_URL="http://localhost:8000"
|
||||
IMAGE_FILE="/mnt/f/tmp/2026年2月20日/11.png"
|
||||
AUDIO_FILE="/mnt/f/tmp/2026年2月20日/22.wav"
|
||||
|
||||
echo "=========================================="
|
||||
echo " Seedance 2.0 多类型素材测试"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# 测试1: 健康检查
|
||||
echo "[测试1] 健康检查 /ping"
|
||||
echo "------------------------------------------"
|
||||
curl -s "${BASE_URL}/ping"
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# 测试2: 仅图片(回归测试,验证原有功能不受影响)
|
||||
echo "[测试2] 仅图片上传(回归测试)"
|
||||
echo "------------------------------------------"
|
||||
echo "POST /v1/videos/generations"
|
||||
echo " model=seedance-2.0-fast"
|
||||
echo " files=11.png (image)"
|
||||
echo ""
|
||||
curl -v -X POST "${BASE_URL}/v1/videos/generations" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-F "model=seedance-2.0" \
|
||||
-F "prompt=图片中的场景开始动起来" \
|
||||
-F "ratio=9:16" \
|
||||
-F "duration=4" \
|
||||
-F "files=@${IMAGE_FILE}" \
|
||||
2>&1
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# 测试3: 图片+音频混合上传
|
||||
echo "[测试3] 图片+音频混合上传"
|
||||
echo "------------------------------------------"
|
||||
echo "POST /v1/videos/generations"
|
||||
echo " model=seedance-2.0-fast"
|
||||
echo " files=11.png (image) + 22.wav (audio)"
|
||||
echo ""
|
||||
curl -v -X POST "${BASE_URL}/v1/videos/generations" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-F "model=seedance-2.0-fast" \
|
||||
-F "prompt=@1 图片中的人物随着音乐 @2 开始跳舞" \
|
||||
-F "ratio=9:16" \
|
||||
-F "duration=5" \
|
||||
-F "files=@${IMAGE_FILE}" \
|
||||
-F "files=@${AUDIO_FILE}" \
|
||||
2>&1
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# 测试4: seedance-2.0-fast 图片+音频
|
||||
echo "[测试4] seedance-2.0-fast 图片+音频"
|
||||
echo "------------------------------------------"
|
||||
echo "POST /v1/videos/generations"
|
||||
echo " model=seedance-2.0-fast"
|
||||
echo " files=11.png (image) + 22.wav (audio)"
|
||||
echo ""
|
||||
curl -v -X POST "${BASE_URL}/v1/videos/generations" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-F "model=seedance-2.0-fast" \
|
||||
-F "prompt=@1 配合 @2 的音乐节奏动起来" \
|
||||
-F "ratio=4:3" \
|
||||
-F "duration=5" \
|
||||
-F "files=@${IMAGE_FILE}" \
|
||||
-F "files=@${AUDIO_FILE}" \
|
||||
2>&1
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
# 测试5: 仅音频(无图片)
|
||||
echo "[测试5] 仅音频上传(预期:音频上传暂未实现的错误)"
|
||||
echo "------------------------------------------"
|
||||
curl -v -X POST "${BASE_URL}/v1/videos/generations" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-F "model=seedance-2.0-fast" \
|
||||
-F "prompt=根据音乐生成舞蹈视频" \
|
||||
-F "ratio=9:16" \
|
||||
-F "duration=4" \
|
||||
-F "files=@${AUDIO_FILE}" \
|
||||
2>&1
|
||||
echo ""
|
||||
echo ""
|
||||
|
||||
echo "=========================================="
|
||||
echo " 测试完成"
|
||||
echo "=========================================="
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noEmit": true,
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*", "libs.d.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"builds": [
|
||||
{
|
||||
"src": "./dist/*.html",
|
||||
"use": "@vercel/static"
|
||||
},
|
||||
{
|
||||
"src": "./dist/index.js",
|
||||
"use": "@vercel/node"
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"src": "/",
|
||||
"dest": "/dist/welcome.html"
|
||||
},
|
||||
{
|
||||
"src": "/(.*)",
|
||||
"dest": "/dist",
|
||||
"headers": {
|
||||
"Access-Control-Allow-Credentials": "true",
|
||||
"Access-Control-Allow-Methods": "GET,OPTIONS,PATCH,DELETE,POST,PUT",
|
||||
"Access-Control-Allow-Headers": "X-CSRF-Token, X-Requested-With, Accept, Accept-Version, Content-Length, Content-MD5, Content-Type, Date, X-Api-Version, Content-Type, Authorization"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
Reference in New Issue