feat: Add Jimeng image generation service and related functionality
- Implemented JimengService for image generation with API integration. - Created OSSService for handling image uploads to S3. - Developed PBService for managing PocketBase interactions. - Added task management for image creation and downloading using BullMQ. - Introduced routes for creating image generation tasks. - Implemented logging and error handling for image processing. - Added configuration management for Redis and other services. - Created scripts for testing image generation and PocketBase integration. - Updated package dependencies and added new scripts for worker management.
This commit is contained in:
1870
pnpm-lock.yaml
generated
1870
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
13
prompts/.claude/skills/list/SKILL.md
Normal file
13
prompts/.claude/skills/list/SKILL.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
name: 列出前20个storage的文件名
|
||||||
|
description: 读取storage存储的列表,并列出前20个文件名
|
||||||
|
allowed-tools: Read, Bash
|
||||||
|
---
|
||||||
|
|
||||||
|
执行技能时,在项目根目录运行以下命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun .claude/skills/list/scripts/list.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
脚本会读取 `storage` 目录下的文件,并输出前20个文件名。
|
||||||
21
prompts/.claude/skills/list/scripts/list.ts
Normal file
21
prompts/.claude/skills/list/scripts/list.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { createStorage } from 'unstorage'
|
||||||
|
import fsDriver from 'unstorage/drivers/fs'
|
||||||
|
|
||||||
|
const storage = createStorage({
|
||||||
|
driver: fsDriver({ base: 'storage' }),
|
||||||
|
})
|
||||||
|
|
||||||
|
async function listFiles() {
|
||||||
|
const files = await storage.getKeys()
|
||||||
|
const first20Files = files.slice(0, 20)
|
||||||
|
console.log('前20个文件名:')
|
||||||
|
first20Files.forEach((file) => {
|
||||||
|
console.log(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
listFiles().catch((err) => {
|
||||||
|
console.error('Error listing files:', err)
|
||||||
|
})
|
||||||
84
prompts/.env
84
prompts/.env
@@ -1 +1,85 @@
|
|||||||
|
LOG_LEVEL=DEBUG
|
||||||
|
IS_DEV=true
|
||||||
|
|
||||||
|
POSTGRES_HOST=1.15.101.247
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
POSTGRES_USER=postgres
|
||||||
|
POSTGRES_PASSWORD=abearxiong!
|
||||||
|
POSTGRES_DB=postgres
|
||||||
|
|
||||||
|
# DATABASE_URL=postgresql://postgres:abearxiong!@1.15.101.247:5432/postgres
|
||||||
|
DATABASE_URL=postgresql://postgres:abearxiong@118.196.32.29:5432/postgres
|
||||||
|
|
||||||
|
REDIS_HOST=light.xiongxiao.me
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=abearxiong!
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# POCKETBASE
|
||||||
|
POCKETBASE_URL=https://pocketbase.pro.xiongxiao.me
|
||||||
|
POCKETBASE_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjb2xsZWN0aW9uSWQiOiJwYmNfMzE0MjYzNTgyMyIsImV4cCI6MTc3NTYyODY5NywiaWQiOiI0cGRtMXF4cjlkOXNuam0iLCJyZWZyZXNoYWJsZSI6ZmFsc2UsInR5cGUiOiJhdXRoIn0.2ABYhI0ayxpEV09gNvWUIM0lXfAx7hfBT02WcVPmyNw
|
||||||
|
|
||||||
|
# S3
|
||||||
|
S3_ACCESS_KEY_ID=AKLTOWNhNmJkNDJmNzFkNGI3MDlmMWQzYTA2ZjBkYTc2YTg
|
||||||
|
S3_ACCESS_KEY_SECRET=TWpjME9EVm1OVFJtTkROaE5ESXlaR0ptWlRnd1lqVm1Nems0TW1Ka1pUZw==
|
||||||
|
S3_REGION=cn-shanghai
|
||||||
|
S3_BUCKET_NAME=envision
|
||||||
|
S3_ENDPOINT=https://tos-s3-cn-shanghai.volces.com
|
||||||
|
|
||||||
|
# Minio 配置
|
||||||
|
MINIO_ENDPOINT=light.xiongxiao.me
|
||||||
|
MINIO_PORT=9000
|
||||||
|
MINIO_BUCKET_NAME=resources
|
||||||
|
MINIO_USE_SSL=false
|
||||||
|
MINIO_ACCESS_KEY=abearxiong
|
||||||
|
MINIO_SECRET_KEY=xiongxiao
|
||||||
|
|
||||||
|
# 域名
|
||||||
|
DOMAIN=xiongxiao.me
|
||||||
|
PORT=4005
|
||||||
|
|
||||||
|
# 代理配置
|
||||||
|
PROXY_DOMAIN=kevisual.xiongxiao.me
|
||||||
|
PROXY_RESOURCES=http://localhost:9000/resources
|
||||||
|
PROXY_ALLOWED_ORIGINS=localhost,xiongxiao.me
|
||||||
|
|
||||||
KEVISUAL_NEW_API_KEY=sk-YyVo5WqJBmAnhIPfww9XpUPvHNhsuiXs9a1OSfBul94d7O47
|
KEVISUAL_NEW_API_KEY=sk-YyVo5WqJBmAnhIPfww9XpUPvHNhsuiXs9a1OSfBul94d7O47
|
||||||
|
KEVISUAL_TOKEN="st_c7kyhg7sfhhhpiogydyogpoqzgzrnas7"
|
||||||
|
KEVISUAL_PASSWORD=123456xx
|
||||||
|
|
||||||
|
## gitea
|
||||||
|
GITEA_URL=https://git.xiongxiao.me
|
||||||
|
GITEA_TOKEN=18cd3c00308c3813765dde41d093d48bed76fabd
|
||||||
|
|
||||||
|
## ---- AI ----
|
||||||
|
# BAILIAN API
|
||||||
|
BAILIAN_API_KEY='sk-0fc39ea048484ccf9e35e4ed4b4950be'
|
||||||
|
ZHIPU_API_KEY="6e7a1bc2760a4bd79c6f436b552527be.2j8Ob751NKi6oiVY"
|
||||||
|
MINIMAX_API_KEY="sk-cp-_nvABjDELuG_o3_vmvlo0uAY1jHJAxglKqKly8ihAxKJcbCyvwqsld08c3R4QZbNfocMn1juB_FdUc1sdjC-gXj5unVykTJ2a6THYaWozQkNyJ5FwJ_aJdI"
|
||||||
|
# jimeng API
|
||||||
|
JIMENG_API_KEY=4e962fc85078d5bfc02c9882bfe659eb
|
||||||
|
JIMENG_API_URL=https://jimeng-api.kevisual.cn/v1
|
||||||
|
JIMENG_TIMEOUT=300000
|
||||||
|
|
||||||
|
VOLCENGINE_AUC_APPID=6968490116
|
||||||
|
VOLCENGINE_AUC_TOKEN=t1WIgIEUswuunOReyW8kiRCe5lW_lcFB
|
||||||
|
|
||||||
|
#-------
|
||||||
|
|
||||||
|
DATA_WEBSITE_ID=5fd42d1d-109e-43ab-b3a7-d4fda0c92d13
|
||||||
|
|
||||||
|
## 微信
|
||||||
|
# 微信开放平台 登陆
|
||||||
|
WX_OPEN_APP_ID=wx9378885c8390e09b
|
||||||
|
WX_OPEN_APP_SECRET=4a0d588fe0de9713ad0a7e680be3d225
|
||||||
|
|
||||||
|
# 微信公众号 登陆
|
||||||
|
WX_MP_APP_ID=wxff97d569b1db16b6
|
||||||
|
WX_MP_APP_SECRET=012d84d0d2b914de95f4e9ca84923aed
|
||||||
|
|
||||||
|
##
|
||||||
|
# Queue
|
||||||
|
QUEUE_CONCURRENCY=5
|
||||||
|
QUEUE_MAX_FAILED=10
|
||||||
|
|
||||||
|
FEISHU_NOTIFY_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/c1c32e36-ddc6-4965-8943-fc826f4f5060
|
||||||
24
prompts/.env.example
Normal file
24
prompts/.env.example
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# AI Configuration
|
||||||
|
KEVISUAL_NEW_API_KEY=your_api_key_here
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# jimeng API
|
||||||
|
JIMENG_API_KEY=your_jimeng_api_key
|
||||||
|
JIMENG_API_URL=https://jimeng-api.kevisual.cn/v1
|
||||||
|
JIMENG_TIMEOUT=300000
|
||||||
|
|
||||||
|
# S3
|
||||||
|
S3_BUCKET=your_bucket_name
|
||||||
|
S3_ACCESS_KEY_ID=your_access_key
|
||||||
|
S3_ACCESS_KEY_SECRET=your_secret_key
|
||||||
|
S3_REGION=cn-beijing
|
||||||
|
S3_ENDPOINT=tos-cn-beijing.volces.com
|
||||||
|
|
||||||
|
# Queue
|
||||||
|
QUEUE_CONCURRENCY=5
|
||||||
|
QUEUE_MAX_FAILED=10
|
||||||
189
prompts/QUEUE-README.md
Normal file
189
prompts/QUEUE-README.md
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
# 图片生成任务系统使用指南
|
||||||
|
|
||||||
|
基于 BullMQ 和 Redis 的异步图片生成任务系统。
|
||||||
|
|
||||||
|
## 系统架构
|
||||||
|
|
||||||
|
```
|
||||||
|
触发任务 → BullMQ 队列 → Worker 处理 → jimeng API 生成图片 → 上传 TOS → 更新 storage
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前置要求
|
||||||
|
|
||||||
|
1. **Redis 服务**:确保 Redis 已启动
|
||||||
|
```bash
|
||||||
|
# 检查 Redis 状态
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **环境变量配置**:确保 `.env` 文件已配置
|
||||||
|
- Redis 连接信息
|
||||||
|
- jimeng API 密钥
|
||||||
|
- TOS 配置
|
||||||
|
|
||||||
|
3. **PM2**:用于管理 Worker 进程
|
||||||
|
```bash
|
||||||
|
npm install -g pm2
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用方法
|
||||||
|
|
||||||
|
### 1. 启动 Worker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动 Worker
|
||||||
|
bun run worker:start
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
bun run worker:logs
|
||||||
|
|
||||||
|
# 查看 Worker 状态
|
||||||
|
bun run worker:status
|
||||||
|
|
||||||
|
# 重启 Worker
|
||||||
|
bun run worker:restart
|
||||||
|
|
||||||
|
# 停止 Worker
|
||||||
|
bun run worker:stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 添加任务
|
||||||
|
|
||||||
|
#### 单个任务
|
||||||
|
```bash
|
||||||
|
bun run queue:single <promptId>
|
||||||
|
|
||||||
|
# 示例
|
||||||
|
bun run queue:single aadpldhvpwpdpwrp
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 批量任务(所有待处理)
|
||||||
|
```bash
|
||||||
|
bun run queue:pending
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 查询任务状态
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun run queue:status <jobId>
|
||||||
|
|
||||||
|
# 示例
|
||||||
|
bun run queue:status aadpldhvpwpdpdpwrp
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务流程
|
||||||
|
|
||||||
|
1. **添加任务**:从 storage 读取提示词,添加到 BullMQ 队列
|
||||||
|
2. **Worker 处理**:
|
||||||
|
- 验证提示词存在于 storage
|
||||||
|
- 调用 jimeng API 生成图片
|
||||||
|
- 下载图片到内存
|
||||||
|
- 上传到 TOS 服务器
|
||||||
|
- 更新 storage 文件(添加 imageUrl)
|
||||||
|
3. **结果返回**:任务完成或失败的信息
|
||||||
|
|
||||||
|
## 队列配置
|
||||||
|
|
||||||
|
- **并发数**:5(可通过 `QUEUE_CONCURRENCY` 环境变量调整)
|
||||||
|
- **重试次数**:3 次
|
||||||
|
- **重试策略**:指数退避(起始 5 秒)
|
||||||
|
- **失败暂停**:连续失败 10 个任务后自动暂停队列
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── config/
|
||||||
|
│ ├── redis.config.ts # Redis 配置
|
||||||
|
│ └── queue.config.ts # 队列配置
|
||||||
|
├── services/
|
||||||
|
│ ├── storage.service.ts # Storage 操作
|
||||||
|
│ ├── jimeng.service.ts # jimeng API
|
||||||
|
│ └── oss.service.ts # TOS 上传
|
||||||
|
├── queue/
|
||||||
|
│ ├── types.ts # 类型定义
|
||||||
|
│ ├── connection.ts # Redis 连接
|
||||||
|
│ ├── queue.ts # BullMQ 队列
|
||||||
|
│ ├── processor.ts # 任务处理器
|
||||||
|
│ └── index.ts # 导出
|
||||||
|
workers/
|
||||||
|
└── image-worker.ts # Worker 进程
|
||||||
|
scripts/
|
||||||
|
├── queue-single.ts # 单个任务脚本
|
||||||
|
├── queue-pending.ts # 批量任务脚本
|
||||||
|
└── check-status.ts # 状态查询脚本
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Redis
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_PASSWORD=
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# jimeng API
|
||||||
|
JIMENG_API_KEY=4e962fc85078d5bfc02c9882bfe659eb
|
||||||
|
JIMENG_API_URL=https://jimeng-api.kevisual.cn/v1
|
||||||
|
JIMENG_TIMEOUT=30000
|
||||||
|
|
||||||
|
# TOS
|
||||||
|
TOS_BUCKET=kevisual-images
|
||||||
|
TOS_ACCESS_KEY_ID=
|
||||||
|
TOS_ACCESS_KEY_SECRET=
|
||||||
|
TOS_REGION=cn-beijing
|
||||||
|
TOS_ENDPOINT=tos-cn-beijing.volces.com
|
||||||
|
|
||||||
|
# Queue
|
||||||
|
QUEUE_CONCURRENCY=5
|
||||||
|
QUEUE_MAX_FAILED=10
|
||||||
|
```
|
||||||
|
|
||||||
|
## 日志
|
||||||
|
|
||||||
|
Worker 日志位于 `logs/` 目录:
|
||||||
|
- `worker-error.log` - 错误日志
|
||||||
|
- `worker-out.log` - 输出日志
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### 1. Worker 启动失败
|
||||||
|
|
||||||
|
检查 Redis 是否运行:
|
||||||
|
```bash
|
||||||
|
redis-cli ping
|
||||||
|
```
|
||||||
|
|
||||||
|
检查环境变量是否配置正确:
|
||||||
|
```bash
|
||||||
|
cat .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 任务失败
|
||||||
|
|
||||||
|
查看 Worker 日志:
|
||||||
|
```bash
|
||||||
|
bun run worker:logs
|
||||||
|
```
|
||||||
|
|
||||||
|
查询任务状态:
|
||||||
|
```bash
|
||||||
|
bun run queue:status <promptId>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 队列暂停
|
||||||
|
|
||||||
|
如果连续失败 10 个任务,队列会自动暂停。需要:
|
||||||
|
1. 排查失败原因
|
||||||
|
2. 手动恢复队列(代码中暂未实现,可使用 PM2 重启)
|
||||||
|
```bash
|
||||||
|
bun run worker:restart
|
||||||
|
```
|
||||||
|
|
||||||
|
## 下一步
|
||||||
|
|
||||||
|
- 添加 Bull Board 进行可视化监控
|
||||||
|
- 添加 HTTP API 代替 CLI 脚本
|
||||||
|
- 添加 Prometheus 指标
|
||||||
|
- 支持多 Worker 实例
|
||||||
0
prompts/TASKS-v1.0.0.md
Normal file
0
prompts/TASKS-v1.0.0.md
Normal file
11
prompts/docs/jimeng.md
Normal file
11
prompts/docs/jimeng.md
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
```sh
|
||||||
|
curl -X POST https://jimeng-api.kevisual.cn/v1/images/generations \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer 4e962fc85078d5bfc02c9882bfe659eb" \
|
||||||
|
-d '{
|
||||||
|
"model": "jimeng-4.0",
|
||||||
|
"prompt": "生成一个水墨山水画",
|
||||||
|
"ratio": "1:1",
|
||||||
|
"resolution": "2k"
|
||||||
|
}'
|
||||||
|
```
|
||||||
19
prompts/ecosystem.config.js
Normal file
19
prompts/ecosystem.config.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
module.exports = {
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
name: 'image-worker',
|
||||||
|
script: './workers/image-worker.ts',
|
||||||
|
interpreter: 'bun',
|
||||||
|
instances: 1,
|
||||||
|
autorestart: true,
|
||||||
|
watch: false,
|
||||||
|
max_memory_restart: '1G',
|
||||||
|
env: {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
},
|
||||||
|
error_file: './logs/worker-error.log',
|
||||||
|
out_file: './logs/worker-out.log',
|
||||||
|
log_date_format: 'YYYY-MM-DD HH:mm:ss Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -5,23 +5,35 @@
|
|||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"perfect": "bun test/generate-perfect.ts",
|
"perfect": "bun test/generate-perfect.ts",
|
||||||
"pony": "bun test/generate-pony.ts"
|
"pony": "bun test/generate-pony.ts",
|
||||||
|
"worker": "bun src/workers/index.ts"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"packageManager": "pnpm@10.26.0",
|
"packageManager": "pnpm@10.27.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@kevisual/ai": "^0.0.19",
|
"@kevisual/ai": "^0.0.19",
|
||||||
|
"@kevisual/context": "^0.0.4",
|
||||||
|
"@kevisual/oss": "^0.0.16",
|
||||||
|
"@kevisual/query": "^0.0.35",
|
||||||
"@kevisual/types": "^0.0.10",
|
"@kevisual/types": "^0.0.10",
|
||||||
"@kevisual/use-config": "^1.0.21",
|
"@kevisual/use-config": "^1.0.21",
|
||||||
"@types/bun": "^1.3.5",
|
"@types/bun": "^1.3.5",
|
||||||
"@types/node": "^25.0.3"
|
"@types/node": "^25.0.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.965.0",
|
||||||
|
"@kevisual/logger": "^0.0.4",
|
||||||
|
"@kevisual/notifier": "^0.0.2",
|
||||||
|
"@kevisual/router": "^0.0.52",
|
||||||
|
"bullmq": "^5.66.4",
|
||||||
"es-toolkit": "^1.43.0",
|
"es-toolkit": "^1.43.0",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"ioredis": "^5.9.0",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
"pocketbase": "^0.26.5",
|
||||||
"unstorage": "^1.17.3"
|
"unstorage": "^1.17.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
prompts/scripts/show-download-error.ts
Normal file
28
prompts/scripts/show-download-error.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { IMAGE_DOWNLOAD_JOB } from '../src/task/image-creator.job'
|
||||||
|
import { Worker, Queue, Job } from 'bullmq';
|
||||||
|
import { getRedisConnection } from '../src/module/redis.ts';
|
||||||
|
import { pbService, jimengService, ossService } from '../src/index.ts';
|
||||||
|
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
const queue = new Queue(IMAGE_DOWNLOAD_JOB, { connection });
|
||||||
|
|
||||||
|
// 显示错误的尝试任务, queue列出来
|
||||||
|
async function showFailedJobs() {
|
||||||
|
const failedJobs = await queue.getFailed();
|
||||||
|
if (failedJobs.length === 0) {
|
||||||
|
console.log('No failed jobs found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${failedJobs.length} failed jobs:`);
|
||||||
|
for (const job of failedJobs) {
|
||||||
|
console.log(`- Job ID: ${job.id}, Attempts Made: ${job.attemptsMade}, Data: ${JSON.stringify(job.data)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
showFailedJobs().then(() => {
|
||||||
|
process.exit(0);
|
||||||
|
}).catch((error) => {
|
||||||
|
console.error('Error showing failed jobs:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
35
prompts/src/app.ts
Normal file
35
prompts/src/app.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { JimengService } from './services/jimeng.service.ts';
|
||||||
|
import { OSSService } from './services/oss.service.ts';
|
||||||
|
import { PBService } from './services/pb.service.ts';
|
||||||
|
import { useConfig } from '@kevisual/use-config';
|
||||||
|
|
||||||
|
import { App } from '@kevisual/router'
|
||||||
|
import { useContextKey } from '@kevisual/context';
|
||||||
|
import { getRedisConnection } from './module/redis.ts';
|
||||||
|
import { Kevisual } from '@kevisual/ai';
|
||||||
|
export const config = useConfig();
|
||||||
|
export const redis = useContextKey('redis', () => getRedisConnection());
|
||||||
|
export const jimengService = useContextKey('jimeng', new JimengService({
|
||||||
|
apiKey: config.JIMENG_API_KEY,
|
||||||
|
baseUrl: config.JIMENG_API_URL,
|
||||||
|
timeout: parseInt(config.JIMENG_TIMEOUT || '300000'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const ossService = useContextKey('oss', new OSSService({
|
||||||
|
accessKeyId: config.S3_ACCESS_KEY_ID,
|
||||||
|
accessKeySecret: config.S3_ACCESS_KEY_SECRET,
|
||||||
|
bucketName: config.S3_BUCKET_NAME,
|
||||||
|
region: config.S3_REGION,
|
||||||
|
endpoint: config.S3_ENDPOINT,
|
||||||
|
prefix: 'projects/horse/',
|
||||||
|
}));
|
||||||
|
export const pbService = useContextKey('pb', new PBService({
|
||||||
|
url: config.POCKETBASE_URL,
|
||||||
|
token: config.POCKETBASE_TOKEN,
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const app = useContextKey('app', new App());
|
||||||
|
|
||||||
|
export const ai = useContextKey('ai', new Kevisual({
|
||||||
|
apiKey: config.KEVISUAL_NEW_API_KEY,
|
||||||
|
}));
|
||||||
@@ -11,4 +11,10 @@ async function saveToFile(data: Map<string, string>, outputPath: string): Promis
|
|||||||
console.log(`Generated ${arrayData.length} prompts and saved to ${outputPath}`);
|
console.log(`Generated ${arrayData.length} prompts and saved to ${outputPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import './routes/index.ts';
|
||||||
|
|
||||||
|
export * from './app.ts';
|
||||||
|
// list all routes and import
|
||||||
|
|
||||||
|
|
||||||
export { PromptGenerator, PromptGeneratorOptions, saveToFile, Prompt };
|
export { PromptGenerator, PromptGeneratorOptions, saveToFile, Prompt };
|
||||||
15
prompts/src/module/config.ts
Normal file
15
prompts/src/module/config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { useConfig } from '@kevisual/use-config';
|
||||||
|
export const config = useConfig();
|
||||||
|
|
||||||
|
export const queueConfig = {
|
||||||
|
name: 'image-generation-queue',
|
||||||
|
concurrency: parseInt(config.QUEUE_CONCURRENCY || '1'),
|
||||||
|
maxFailed: parseInt(config.QUEUE_MAX_FAILED || '2'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const redisConfig = {
|
||||||
|
host: config.REDIS_HOST || 'localhost',
|
||||||
|
port: parseInt(config.REDIS_PORT || '6379'),
|
||||||
|
password: config.REDIS_PASSWORD || undefined,
|
||||||
|
db: parseInt(config.REDIS_DB || '0'),
|
||||||
|
};
|
||||||
10
prompts/src/module/logger.ts
Normal file
10
prompts/src/module/logger.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Logger } from "@kevisual/logger";
|
||||||
|
import { config } from '@/app.ts'
|
||||||
|
import { FeishuNotifier } from "@kevisual/notifier";
|
||||||
|
export const logger = new Logger({
|
||||||
|
level: config.LOG_LEVEL || 'info',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const feishuNotifier = new FeishuNotifier({
|
||||||
|
webhook: config.FEISHU_NOTIFY_WEBHOOK_URL || '',
|
||||||
|
});
|
||||||
29
prompts/src/module/redis.ts
Normal file
29
prompts/src/module/redis.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { Redis } from 'ioredis';
|
||||||
|
import { redisConfig } from './config.ts'
|
||||||
|
export interface RedisConfig {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
password?: string;
|
||||||
|
db: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let redisConnection: Redis | null = null;
|
||||||
|
|
||||||
|
export const getRedisConnection = () => {
|
||||||
|
if (!redisConnection) {
|
||||||
|
redisConnection = new Redis({
|
||||||
|
...redisConfig,
|
||||||
|
maxRetriesPerRequest: null
|
||||||
|
});
|
||||||
|
|
||||||
|
redisConnection.on('connect', () => {
|
||||||
|
console.log('Redis connected');
|
||||||
|
});
|
||||||
|
|
||||||
|
redisConnection.on('error', (err) => {
|
||||||
|
console.error('Redis connection error:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return redisConnection as any;
|
||||||
|
};
|
||||||
15
prompts/src/routes/create-task.ts
Normal file
15
prompts/src/routes/create-task.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { app, ossService, pbService, redis } from '@/app.ts'
|
||||||
|
import { addImageGenerateJob } from '@/task/image-creator.job.ts';
|
||||||
|
|
||||||
|
app.route({
|
||||||
|
path: 'image-creator',
|
||||||
|
key: 'create-task',
|
||||||
|
}).define(async (ctx) => {
|
||||||
|
const list = await pbService.collection.getFullList({
|
||||||
|
filter: 'status="计划中"',
|
||||||
|
})
|
||||||
|
for (const item of list) {
|
||||||
|
await addImageGenerateJob(item);
|
||||||
|
}
|
||||||
|
console.log(`Added ${list.length} image generate jobs to the queue.`);
|
||||||
|
}).addTo(app);
|
||||||
1
prompts/src/routes/index.ts
Normal file
1
prompts/src/routes/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
import './create-task.ts'
|
||||||
121
prompts/src/services/jimeng.service.ts
Normal file
121
prompts/src/services/jimeng.service.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { Result } from '@kevisual/query'
|
||||||
|
export interface JimengOptions {
|
||||||
|
/** API密钥,用于认证请求 */
|
||||||
|
apiKey: string;
|
||||||
|
/** API基础URL */
|
||||||
|
baseUrl: string;
|
||||||
|
/** 请求超时时间(毫秒) */
|
||||||
|
timeout: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JimengGenerateOptions {
|
||||||
|
/** 图片生成提示词 */
|
||||||
|
prompt: string;
|
||||||
|
/** 使用的模型版本,默认 jimeng-4.0 */
|
||||||
|
model?: string;
|
||||||
|
/** 图片比例,默认 1:1 */
|
||||||
|
ratio?: string;
|
||||||
|
/** 图片分辨率,默认 2k */
|
||||||
|
resolution?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JimengResponse {
|
||||||
|
/** 请求创建时间戳 */
|
||||||
|
created: number;
|
||||||
|
/** 生成的图片列表 */
|
||||||
|
data: Array<{
|
||||||
|
/** 图片URL */
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class JimengService {
|
||||||
|
private apiKey: string;
|
||||||
|
private baseUrl: string;
|
||||||
|
private timeout: number;
|
||||||
|
|
||||||
|
constructor(options: JimengOptions) {
|
||||||
|
this.apiKey = options.apiKey;
|
||||||
|
this.baseUrl = options.baseUrl || 'https://jimeng-api.kevisual.cn/v1';
|
||||||
|
this.timeout = options.timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
async generateImage(options: JimengGenerateOptions): Promise<Result<JimengResponse>> {
|
||||||
|
const {
|
||||||
|
prompt,
|
||||||
|
model = 'jimeng-4.6',
|
||||||
|
ratio = '1:1',
|
||||||
|
resolution = '2k'
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||||
|
|
||||||
|
const response = await fetch(`${this.baseUrl}/images/generations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${this.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model,
|
||||||
|
prompt,
|
||||||
|
ratio,
|
||||||
|
resolution,
|
||||||
|
}),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`jimeng API error: ${response.status} ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json() as JimengResponse;
|
||||||
|
return { code: 200, data: result };
|
||||||
|
} catch (error: any) {
|
||||||
|
return { code: 500, message: error.message || 'Unknown error' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async downloadImage(url: string): Promise<Buffer> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to download image: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
|
return Buffer.from(arrayBuffer);
|
||||||
|
} catch (error: any) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Image download timeout');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/** 获取图片过期时间 */
|
||||||
|
async getExpiredTime(url: string): Promise<{ expiredAt: number, expired: boolean }> {
|
||||||
|
// https://p3-dreamina-sign.byteimg.com/tos-cn-i-tb4s082cfz/c018e06ee6654dd78ccacb29eff4744e~tplv-tb4s082cfz-aigc_resize:0:0.png?lk3s=43402efa&x-expires=1767852000&x-signature=34yf37N955BP37eLaYEzKeLQn0Q%3D&format=.png
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
let expires = urlObj.searchParams.get('x-expires');
|
||||||
|
if (!expires) {
|
||||||
|
expires = '0';
|
||||||
|
}
|
||||||
|
const expiredAt = parseInt(expires) * 1000;
|
||||||
|
const expired = Date.now() > expiredAt;
|
||||||
|
return { expiredAt, expired };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
36
prompts/src/services/oss.service.ts
Normal file
36
prompts/src/services/oss.service.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { OssBase } from '@kevisual/oss/s3.ts';
|
||||||
|
import { S3Client } from '@aws-sdk/client-s3'
|
||||||
|
export type OSSOptions = {
|
||||||
|
accessKeyId: string;
|
||||||
|
accessKeySecret: string;
|
||||||
|
region: string;
|
||||||
|
bucketName: string;
|
||||||
|
endpoint: string;
|
||||||
|
prefix?: string;
|
||||||
|
}
|
||||||
|
export class OSSService extends OssBase {
|
||||||
|
declare client: S3Client;
|
||||||
|
endpoint: string;
|
||||||
|
constructor(options: OSSOptions) {
|
||||||
|
const client = new S3Client({
|
||||||
|
region: options.region,
|
||||||
|
endpoint: `${options.endpoint}`,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: options.accessKeyId,
|
||||||
|
secretAccessKey: options.accessKeySecret,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
super({
|
||||||
|
client,
|
||||||
|
bucketName: options.bucketName,
|
||||||
|
prefix: options.prefix || '',
|
||||||
|
});
|
||||||
|
this.endpoint = options.endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLink(objectName: string): string {
|
||||||
|
const endpoint = this.endpoint;
|
||||||
|
return `${endpoint}/${this.bucketName}/${this.prefix}${objectName}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
67
prompts/src/services/pb.service.ts
Normal file
67
prompts/src/services/pb.service.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import PocketBase from 'pocketbase';
|
||||||
|
import { EventEmitter } from 'eventemitter3'
|
||||||
|
|
||||||
|
type PBOptions = {
|
||||||
|
url: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
export class PBCore {
|
||||||
|
declare client: PocketBase;
|
||||||
|
emitter = new EventEmitter();
|
||||||
|
token?: string;
|
||||||
|
constructor(options: PBOptions) {
|
||||||
|
this.client = new PocketBase(options.url);
|
||||||
|
this.token = options.token || '';
|
||||||
|
if (this.token) {
|
||||||
|
this.client.authStore.save(this.token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loginAdmin(email: string, password: string) {
|
||||||
|
const authData = await this.client.collection("_superusers").authWithPassword(email, password);
|
||||||
|
this.emitter.emit('login', authData);
|
||||||
|
console.log('PocketBase admin logged in:', authData);
|
||||||
|
return authData;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PBService extends PBCore {
|
||||||
|
collectionName = 'images_generation_tasks';
|
||||||
|
constructor(options: PBOptions) {
|
||||||
|
super(options);
|
||||||
|
}
|
||||||
|
getCollection<T>(name: string) {
|
||||||
|
return this.client.collection<T>(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async initPbService() {
|
||||||
|
const isLogin = this.client.authStore.isValid;
|
||||||
|
console.log('PocketBase is logged in:', isLogin);
|
||||||
|
}
|
||||||
|
async importData(data: any[]) {
|
||||||
|
const collection = this.getCollection(this.collectionName);
|
||||||
|
for (const item of data) {
|
||||||
|
await collection.create(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
get collection() {
|
||||||
|
return this.client.collection<ImageCollection>(this.collectionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ImageTaskStatus = ['提示词优化中', '计划中', '生成图片中', '图片下载中', '暂停中', '已完成', '失败'] as const;
|
||||||
|
|
||||||
|
type Data = {
|
||||||
|
images: { type: 'jimeng' | 'tos', url: string }[];
|
||||||
|
}
|
||||||
|
export type ImageCollection = {
|
||||||
|
id: string;
|
||||||
|
created: string;
|
||||||
|
updated: string;
|
||||||
|
title: string;
|
||||||
|
tags: any;
|
||||||
|
summary: string;
|
||||||
|
description: string;
|
||||||
|
data: Data;
|
||||||
|
status: typeof ImageTaskStatus[number];
|
||||||
|
}
|
||||||
58
prompts/src/services/storage.service.ts
Normal file
58
prompts/src/services/storage.service.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { createStorage } from 'unstorage';
|
||||||
|
import fsDriver from 'unstorage/drivers/fs';
|
||||||
|
|
||||||
|
export interface PromptData {
|
||||||
|
value: string;
|
||||||
|
id: string;
|
||||||
|
perfect: string;
|
||||||
|
imageUrl?: string;
|
||||||
|
generatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StorageService {
|
||||||
|
private storage: ReturnType<typeof createStorage>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.storage = createStorage({
|
||||||
|
driver: fsDriver({ base: 'storage' }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async get(id: string): Promise<PromptData | null> {
|
||||||
|
const filename = id.endsWith('.json') ? id : `${id}.json`;
|
||||||
|
const data = await this.storage.getItem<PromptData>(filename);
|
||||||
|
return data || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPendingPrompts(): Promise<PromptData[]> {
|
||||||
|
const keys = await this.storage.getKeys();
|
||||||
|
const pending: PromptData[] = [];
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
if (key === 'usage.json') continue;
|
||||||
|
|
||||||
|
const data = await this.storage.getItem<PromptData>(key);
|
||||||
|
if (data && !data.imageUrl) {
|
||||||
|
pending.push(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, data: Partial<PromptData>): Promise<void> {
|
||||||
|
const filename = id.endsWith('.json') ? id : `${id}.json`;
|
||||||
|
const existing = await this.storage.getItem<PromptData>(filename);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
await this.storage.setItem(filename, { ...existing, ...data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasImage(id: string): Promise<boolean> {
|
||||||
|
const data = await this.get(id);
|
||||||
|
return !!data?.imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const storageService = new StorageService();
|
||||||
253
prompts/src/task/image-creator.job.ts
Normal file
253
prompts/src/task/image-creator.job.ts
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
import { Worker, Queue, Job } from 'bullmq';
|
||||||
|
import { getRedisConnection } from '../module/redis.ts';
|
||||||
|
import { pbService, jimengService, ossService } from '../index.ts';
|
||||||
|
import type { ImageCollection } from '../services/pb.service.ts';
|
||||||
|
|
||||||
|
export const IMAGE_CREATOR_JOB = 'image-creator';
|
||||||
|
export const IMAGE_GENERATE_JOB = 'image-generate';
|
||||||
|
export const IMAGE_DOWNLOAD_JOB = 'image-download';
|
||||||
|
|
||||||
|
// 状态常量
|
||||||
|
export const ImageTaskStatus = {
|
||||||
|
PENDING: '提示词优化中' as const,
|
||||||
|
PLANNING: '计划中' as const,
|
||||||
|
GENERATING: '生成图片中' as const,
|
||||||
|
DOWNLOADING: '图片下载中' as const,
|
||||||
|
PAUSED: '暂停中' as const,
|
||||||
|
COMPLETED: '已完成' as const,
|
||||||
|
FAILED: '失败' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 生成图片任务的节流时间(毫秒)
|
||||||
|
const JIMENG_THROTTLE_DELAY = 60 * 1000;
|
||||||
|
// 下载任务最大重试次数
|
||||||
|
const DOWNLOAD_MAX_RETRIES = 3;
|
||||||
|
// 图片生成任务最大重试次数
|
||||||
|
const GENERATE_MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export interface ImageCreatorJobData {
|
||||||
|
itemId: string;
|
||||||
|
prompt: string;
|
||||||
|
collectionName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageGenerateJobData {
|
||||||
|
itemId: string;
|
||||||
|
prompt: string;
|
||||||
|
collectionName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ImageDownloadJobData {
|
||||||
|
itemId: string;
|
||||||
|
imageUrl: string;
|
||||||
|
collectionName?: string;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新 PB 状态
|
||||||
|
async function updateItemStatus(
|
||||||
|
itemId: string,
|
||||||
|
status: string,
|
||||||
|
extraData?: Partial<ImageCollection>
|
||||||
|
): Promise<void> {
|
||||||
|
const collection = pbService.getCollection<ImageCollection>(pbService.collectionName);
|
||||||
|
if (extraData) {
|
||||||
|
const existingItem = await pbService.collection.getOne(itemId);
|
||||||
|
const data = existingItem.data;
|
||||||
|
const existingImages = data?.images || [];
|
||||||
|
const newImages = extraData.data?.images || [];
|
||||||
|
await collection.update(itemId, {
|
||||||
|
status,
|
||||||
|
...extraData,
|
||||||
|
data: {
|
||||||
|
...extraData?.data,
|
||||||
|
...data,
|
||||||
|
images: [...existingImages, ...newImages],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await collection.update(itemId, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单独添加生成图片任务
|
||||||
|
*/
|
||||||
|
export async function addImageGenerateJob(item: ImageCollection): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
const queue = new Queue(IMAGE_GENERATE_JOB, { connection });
|
||||||
|
|
||||||
|
const jobData: ImageGenerateJobData = {
|
||||||
|
itemId: item.id,
|
||||||
|
prompt: item.description || item.summary || item.title,
|
||||||
|
collectionName: pbService.collectionName,
|
||||||
|
};
|
||||||
|
|
||||||
|
await queue.add(IMAGE_GENERATE_JOB, jobData, {
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 100,
|
||||||
|
delay: JIMENG_THROTTLE_DELAY, // 任务间隔 30 秒
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateItemStatus(item.id, ImageTaskStatus.GENERATING);
|
||||||
|
// console.log(`[ImageGenerate] Job created for item: ${item.id}`);
|
||||||
|
await queue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单独添加下载图片任务
|
||||||
|
*/
|
||||||
|
export async function addImageDownloadJob(
|
||||||
|
itemId: string,
|
||||||
|
imageUrl: string,
|
||||||
|
index?: number
|
||||||
|
): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
const queue = new Queue(IMAGE_DOWNLOAD_JOB, { connection });
|
||||||
|
const jobData: ImageDownloadJobData = {
|
||||||
|
itemId,
|
||||||
|
imageUrl,
|
||||||
|
collectionName: pbService.collectionName,
|
||||||
|
index: index ?? 0
|
||||||
|
};
|
||||||
|
|
||||||
|
// 使用 bullmq 内置重试,指数退避
|
||||||
|
await queue.add(IMAGE_DOWNLOAD_JOB, jobData, {
|
||||||
|
attempts: DOWNLOAD_MAX_RETRIES,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 2000, // 初始 2 秒
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateItemStatus(itemId, ImageTaskStatus.DOWNLOADING);
|
||||||
|
// console.log(`[ImageDownload] Job created for item: ${itemId}`);
|
||||||
|
await queue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行独立的下载 worker
|
||||||
|
*/
|
||||||
|
export async function runImageDownloadWorker(): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
IMAGE_DOWNLOAD_JOB,
|
||||||
|
async (job: Job<ImageDownloadJobData>) => {
|
||||||
|
const { itemId, imageUrl, index } = job.data;
|
||||||
|
const attemptsMade = job.attemptsMade;
|
||||||
|
console.log(`[ImageDownload] Processing item: ${itemId}, attempt: ${attemptsMade + 1}/${DOWNLOAD_MAX_RETRIES}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const imageBuffer = await jimengService.downloadImage(imageUrl);
|
||||||
|
const filename = `generated_${itemId}_${index}_${Date.now()}.png`;
|
||||||
|
await ossService.putObject(filename, imageBuffer);
|
||||||
|
const ossUrl = ossService.getLink(filename)
|
||||||
|
console.log(`[ImageDownload] Image uploaded to OSS: ${ossUrl}`);
|
||||||
|
|
||||||
|
const imageData = { type: 'tos' as const, url: ossUrl };
|
||||||
|
await updateItemStatus(itemId, ImageTaskStatus.COMPLETED, {
|
||||||
|
data: {
|
||||||
|
images: [imageData],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, ossUrl };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[ImageDownload] Error: ${error.message}`);
|
||||||
|
|
||||||
|
// 重试次数用尽,暂停任务
|
||||||
|
if (job.attemptsMade >= DOWNLOAD_MAX_RETRIES - 1) {
|
||||||
|
await updateItemStatus(itemId, ImageTaskStatus.PAUSED);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
concurrency: 3,
|
||||||
|
} as any
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job) => {
|
||||||
|
console.log(`[ImageDownload] Job completed: ${job.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
console.error(`[ImageDownload] Job failed: ${job?.id}, error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[ImageDownload] Worker started');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行图片生成 worker(使用 jimeng API)
|
||||||
|
*/
|
||||||
|
export async function runImageGenerateWorker(): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
|
||||||
|
const worker = new Worker(
|
||||||
|
IMAGE_GENERATE_JOB,
|
||||||
|
async (job: Job<ImageGenerateJobData>) => {
|
||||||
|
const { itemId, prompt } = job.data;
|
||||||
|
const attemptsMade = job.attemptsMade;
|
||||||
|
console.log(`[ImageGenerate] Processing item: ${itemId}, attempt: ${attemptsMade + 1}/${GENERATE_MAX_RETRIES}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 调用 jimeng API 生成图片
|
||||||
|
const result = await jimengService.generateImage({ prompt });
|
||||||
|
|
||||||
|
if (result.code !== 200 || !result.data?.data?.length) {
|
||||||
|
throw new Error(result.message || 'Failed to generate image');
|
||||||
|
}
|
||||||
|
const images = result.data.data;
|
||||||
|
for (const [index, img] of images.entries()) {
|
||||||
|
console.log(`[ImageGenerate] Image generated: ${img.url}`);
|
||||||
|
// 生成成功后,添加下载任务
|
||||||
|
await addImageDownloadJob(itemId, img.url, index);
|
||||||
|
}
|
||||||
|
// 更新状态为下载中
|
||||||
|
await updateItemStatus(itemId, ImageTaskStatus.DOWNLOADING, {
|
||||||
|
data: { images: images.map(img => ({ type: 'jimeng' as const, url: img.url })) },
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, images };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[ImageGenerate] Error: ${error.message}`);
|
||||||
|
|
||||||
|
// 重试次数用尽,暂停任务并停止当前 worker
|
||||||
|
if (job.attemptsMade >= GENERATE_MAX_RETRIES - 1) {
|
||||||
|
await updateItemStatus(itemId, ImageTaskStatus.PAUSED);
|
||||||
|
console.error(`[ImageGenerate] Max retries exceeded. Stopping worker...`);
|
||||||
|
await worker.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
concurrency: 1, // jimeng API 有节流限制,设置为 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job) => {
|
||||||
|
console.log(`[ImageGenerate] Job completed: ${job.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
console.error(`[ImageGenerate] Job failed: ${job?.id}, error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[ImageGenerate] Worker started');
|
||||||
|
}
|
||||||
|
|
||||||
|
export { updateItemStatus };
|
||||||
153
prompts/src/task/perfect-prompt.job.ts
Normal file
153
prompts/src/task/perfect-prompt.job.ts
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { Worker, Job } from 'bullmq';
|
||||||
|
import { getRedisConnection } from '../module/redis.ts';
|
||||||
|
import { Prompt, pbService, ai } from '../index.ts';
|
||||||
|
import type { ImageCollection } from '../services/pb.service.ts';
|
||||||
|
// 重新导出 Queue,因为需要在 addPerfectPromptJob 中使用
|
||||||
|
import { Queue } from 'bullmq';
|
||||||
|
|
||||||
|
export const PERFECT_PROMPT_JOB = 'perfect-prompt';
|
||||||
|
|
||||||
|
// 状态常量
|
||||||
|
export const PerfectPromptStatus = {
|
||||||
|
PENDING: '提示词优化中' as const,
|
||||||
|
COMPLETED: '已完成' as const,
|
||||||
|
FAILED: '失败' as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 最大重试次数
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
export interface PerfectPromptJobData {
|
||||||
|
itemId: string;
|
||||||
|
prompt: string;
|
||||||
|
collectionName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优化提示词的模板
|
||||||
|
const DEFAULT_PERFECT_PROMPT = `请你将以下提示词进行完善,使其更加详细和具体,适合用于生成高质量的像素艺术图像。要求如下:
|
||||||
|
1. 只返回完善后的提示词,不要包含任何多余的内容或解释。
|
||||||
|
2. 确保提示词专注于像素艺术风格,包括但不限于像素化角色、场景和物体的描述。
|
||||||
|
3. 使用具体的细节来增强提示词的表现力,例如颜色、构图、光影效果等。
|
||||||
|
4. 避免使用与像素艺术无关的术语或描述。
|
||||||
|
5. 保持提示词的简洁性,避免过于冗长,但要确保信息量充足。
|
||||||
|
6. 如果需要颜色,需要整个图像的颜色更少的描述,而不是复杂的颜色细节, 背景默认纯蓝色。
|
||||||
|
7. 使用中文进行描述。
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 更新 PB 状态
|
||||||
|
async function updateItemStatus(
|
||||||
|
itemId: string,
|
||||||
|
status: string,
|
||||||
|
extraData?: Partial<ImageCollection>
|
||||||
|
): Promise<void> {
|
||||||
|
const collection = pbService.getCollection<ImageCollection>(pbService.collectionName);
|
||||||
|
if (extraData) {
|
||||||
|
const existingItem = await pbService.collection.getOne(itemId);
|
||||||
|
const data = existingItem.data;
|
||||||
|
await collection.update(itemId, {
|
||||||
|
status,
|
||||||
|
...extraData,
|
||||||
|
data: {
|
||||||
|
...extraData?.data,
|
||||||
|
...data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await collection.update(itemId, {
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 单独添加优化提示词任务
|
||||||
|
*/
|
||||||
|
export async function addPerfectPromptJob(item: ImageCollection): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
const queue = new Queue(PERFECT_PROMPT_JOB, { connection });
|
||||||
|
|
||||||
|
const jobData: PerfectPromptJobData = {
|
||||||
|
itemId: item.id,
|
||||||
|
prompt: item.description || item.summary || item.title || '',
|
||||||
|
collectionName: pbService.collectionName,
|
||||||
|
};
|
||||||
|
|
||||||
|
await queue.add(PERFECT_PROMPT_JOB, jobData, {
|
||||||
|
attempts: MAX_RETRIES,
|
||||||
|
backoff: {
|
||||||
|
type: 'exponential',
|
||||||
|
delay: 2000,
|
||||||
|
},
|
||||||
|
removeOnComplete: 100,
|
||||||
|
removeOnFail: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
await updateItemStatus(item.id, PerfectPromptStatus.PENDING);
|
||||||
|
await queue.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 运行优化提示词 worker
|
||||||
|
*/
|
||||||
|
export async function runPerfectPromptWorker(): Promise<void> {
|
||||||
|
const connection = getRedisConnection();
|
||||||
|
// 获取环境变量中的 API key
|
||||||
|
const worker = new Worker(
|
||||||
|
PERFECT_PROMPT_JOB,
|
||||||
|
async (job: Job<PerfectPromptJobData>) => {
|
||||||
|
const { itemId, prompt } = job.data;
|
||||||
|
const attemptsMade = job.attemptsMade;
|
||||||
|
console.log(`[PerfectPrompt] Processing item: ${itemId}, attempt: ${attemptsMade + 1}/${MAX_RETRIES}`);
|
||||||
|
try {
|
||||||
|
if (!prompt) {
|
||||||
|
throw new Error('Prompt is empty');
|
||||||
|
}
|
||||||
|
const promptTool = new Prompt({ perfectPrompt: DEFAULT_PERFECT_PROMPT });
|
||||||
|
await ai.chat([
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: promptTool.perfect(prompt),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const perfectText = promptTool.clearPerfectTags(ai.responseText);
|
||||||
|
|
||||||
|
if (!perfectText) {
|
||||||
|
throw new Error('Generated perfect prompt is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[PerfectPrompt] Perfect prompt generated for item: ${itemId}`);
|
||||||
|
|
||||||
|
// 更新状态为已完成,并保存优化后的提示词
|
||||||
|
await updateItemStatus(itemId, PerfectPromptStatus.COMPLETED, {
|
||||||
|
description: perfectText,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, perfectPrompt: perfectText };
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error(`[PerfectPrompt] Error: ${error.message}`);
|
||||||
|
|
||||||
|
// 重试次数用尽,标记为失败
|
||||||
|
if (job.attemptsMade >= MAX_RETRIES - 1) {
|
||||||
|
await updateItemStatus(itemId, PerfectPromptStatus.FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
connection,
|
||||||
|
concurrency: 2,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
worker.on('completed', (job) => {
|
||||||
|
console.log(`[PerfectPrompt] Job completed: ${job.id}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
worker.on('failed', (job, err) => {
|
||||||
|
console.error(`[PerfectPrompt] Job failed: ${job?.id}, error: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[PerfectPrompt] Worker started');
|
||||||
|
}
|
||||||
|
|
||||||
10
prompts/src/workers/index.ts
Normal file
10
prompts/src/workers/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { runImageDownloadWorker, runImageGenerateWorker } from '../task/image-creator.job.ts';
|
||||||
|
|
||||||
|
runImageDownloadWorker();
|
||||||
|
runImageGenerateWorker();
|
||||||
|
// 运行半小时后停止
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('Stop timeed', new Date().toISOString());
|
||||||
|
process.exit(0);
|
||||||
|
}, 60 * 60 * 1000); // 60 minutes in milliseconds
|
||||||
12
prompts/test/common.ts
Normal file
12
prompts/test/common.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { app } from '../src/index.ts'
|
||||||
|
|
||||||
|
export {
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await app.run({
|
||||||
|
path: 'image-creator',
|
||||||
|
key: 'create-task',
|
||||||
|
})
|
||||||
|
|
||||||
|
console.log('Route run result:', res)
|
||||||
18
prompts/test/jimeng.ts
Normal file
18
prompts/test/jimeng.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { JimengService } from '../src/services/jimeng.service.js';
|
||||||
|
import { useConfig } from '@kevisual/use-config';
|
||||||
|
const config = useConfig();
|
||||||
|
|
||||||
|
export const jimengService = new JimengService({
|
||||||
|
apiKey: config.JIMENG_API_KEY,
|
||||||
|
baseUrl: config.JIMENG_API_URL,
|
||||||
|
timeout: parseInt(config.JIMENG_TIMEOUT || '30000'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createImage = async () => {
|
||||||
|
const response = await jimengService.generateImage({
|
||||||
|
prompt: 'A beautiful landscape with mountains and a river, in the style of a watercolor painting',
|
||||||
|
});
|
||||||
|
console.log('Generated Image URL:', response);
|
||||||
|
};
|
||||||
|
|
||||||
|
createImage();
|
||||||
43
prompts/test/pb.ts
Normal file
43
prompts/test/pb.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { createStorage } from 'unstorage';
|
||||||
|
import { config } from '../src/app.ts'
|
||||||
|
|
||||||
|
import { PBService } from '../src/services/pb.service.ts'
|
||||||
|
import path from "node:path";
|
||||||
|
import fsDriver from "unstorage/drivers/fs";
|
||||||
|
|
||||||
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
const storage = createStorage({
|
||||||
|
driver: fsDriver({ base: 'storage' }),
|
||||||
|
});
|
||||||
|
const pbService = new PBService({
|
||||||
|
url: config.POCKETBASE_URL,
|
||||||
|
token: config.POCKETBASE_TOKEN,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
// const listStorage = await storage.getKeys();
|
||||||
|
// const keys = listStorage.filter(key => key !== 'usage.json');
|
||||||
|
// for (const key of keys) {
|
||||||
|
// const value = await storage.getItem<any>(key);
|
||||||
|
// console.log(`Generating PB record for key: ${value}`, value);
|
||||||
|
// const { id, perfect: description, value: summary } = value;
|
||||||
|
// pbService.collection.create({
|
||||||
|
// title: '',
|
||||||
|
// summary,
|
||||||
|
// description,
|
||||||
|
// tags: [],
|
||||||
|
// data: {},
|
||||||
|
// status: '计划中',
|
||||||
|
// });
|
||||||
|
// console.log(`Created record for prompt ID: ${id}`);
|
||||||
|
// await sleep(100); // To avoid hitting rate limits
|
||||||
|
// }
|
||||||
|
|
||||||
|
const list = await pbService.collection.getFullList({
|
||||||
|
sort: '-created',
|
||||||
|
fields: 'id,title,summary,description,tags,status',
|
||||||
|
})
|
||||||
|
console.log('PocketBase Records:', list.length);
|
||||||
|
}
|
||||||
|
main();
|
||||||
15
prompts/test/upload-image.ts
Normal file
15
prompts/test/upload-image.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { jimengService, ossService } from '../src/index.ts'
|
||||||
|
const url = 'https://p3-dreamina-sign.byteimg.com/tos-cn-i-tb4s082cfz/4947076125c64a999e4c392de03048f8~tplv-tb4s082cfz-aigc_resize:360:360.webp?lk3s=43402efa&x-expires=1770336000&x-signature=Fjeeb3qloxxzxmHJpmqu6v8fwrM%3D&format=.webp'
|
||||||
|
|
||||||
|
|
||||||
|
const uploadImage = async () => {
|
||||||
|
const response = await jimengService.downloadImage(url);
|
||||||
|
const filename = `uploaded_image_${Date.now()}.png`;
|
||||||
|
await ossService.putObject(filename, response);
|
||||||
|
const ossUrl = ossService.getLink(filename);
|
||||||
|
// console.log('Uploaded Image URL:', response)
|
||||||
|
// const uploadJons = await ossService.putObject('a1.json', { b: '123' })
|
||||||
|
// console.log('Upload JSON Result:', uploadJons)
|
||||||
|
return ossUrl;
|
||||||
|
}
|
||||||
|
uploadImage();
|
||||||
24
prompts/tsconfig.json
Normal file
24
prompts/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"extends": "@kevisual/types/json/backend.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"module": "NodeNext",
|
||||||
|
"target": "esnext",
|
||||||
|
"baseUrl": ".",
|
||||||
|
"typeRoots": [
|
||||||
|
"./node_modules/@types",
|
||||||
|
"./node_modules/@kevisual/types/index.d.ts"
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": [
|
||||||
|
"src/*"
|
||||||
|
],
|
||||||
|
"@agent/*": [
|
||||||
|
"agent/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*",
|
||||||
|
"agent/**/*",
|
||||||
|
],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user