```
feat(api): 新增gpt-image-2编辑模型支持 - 添加gpt-image-2模型的专门接口配置和API密钥 - 实现gpt-image-2编辑模式的完整处理流程,包括Base64图片解码、 图片验证和MinIO上传功能 - 更新前端界面以支持gpt-image-2的特定参数(如尺寸选项) - 修复prompt不能为空的校验逻辑 - 优化图片上传处理,支持编辑模式下的参考图必需校验 - 更新视频生成任务状态消息文案 ```
This commit is contained in:
parent
fec3426bad
commit
0adabaa235
@ -5,280 +5,311 @@ from middlewares.auth import login_required
|
|||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
import json
|
import json
|
||||||
|
|
||||||
# Import Services
|
from services.system_service import (
|
||||||
from services.system_service import get_system_config_data, get_user_latest_notification, mark_notification_as_read
|
get_system_config_data,
|
||||||
|
get_user_latest_notification,
|
||||||
|
mark_notification_as_read,
|
||||||
|
)
|
||||||
from services.history_service import get_user_history_data
|
from services.history_service import get_user_history_data
|
||||||
from services.file_service import handle_file_uploads, get_remote_file_stream
|
from services.file_service import handle_file_uploads, get_remote_file_stream
|
||||||
from services.generation_service import (
|
from services.generation_service import (
|
||||||
validate_generation_request, deduct_points, handle_chat_generation_sync,
|
validate_generation_request,
|
||||||
start_async_image_task, validate_video_request, start_async_video_task
|
deduct_points,
|
||||||
|
handle_chat_generation_sync,
|
||||||
|
start_async_image_task,
|
||||||
|
validate_video_request,
|
||||||
|
start_async_video_task,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_bp = Blueprint('api', __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
@api_bp.route('/api/task_status/<task_id>')
|
|
||||||
|
@api_bp.route("/api/task_status/<task_id>")
|
||||||
@login_required
|
@login_required
|
||||||
def get_task_status(task_id):
|
def get_task_status(task_id):
|
||||||
"""查询异步任务状态"""
|
|
||||||
try:
|
try:
|
||||||
data = redis_client.get(f"task:{task_id}")
|
data = redis_client.get(f"task:{task_id}")
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({"status": "pending"})
|
return jsonify({"status": "pending"})
|
||||||
|
|
||||||
if isinstance(data, bytes):
|
if isinstance(data, bytes):
|
||||||
data = data.decode('utf-8')
|
data = data.decode("utf-8")
|
||||||
|
|
||||||
return jsonify(json.loads(data))
|
return jsonify(json.loads(data))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
system_logger.error(f"查询任务状态异常: {str(e)}")
|
system_logger.error(f"查询任务状态异常: {str(e)}")
|
||||||
return jsonify({"status": "error", "message": "状态查询失败"})
|
return jsonify({"status": "error", "message": "状态查询失败"})
|
||||||
|
|
||||||
@api_bp.route('/api/config')
|
|
||||||
|
@api_bp.route("/api/config")
|
||||||
def get_config():
|
def get_config():
|
||||||
"""从本地数据库字典获取配置"""
|
|
||||||
try:
|
try:
|
||||||
return jsonify(get_system_config_data())
|
return jsonify(get_system_config_data())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/upload', methods=['POST'])
|
|
||||||
|
@api_bp.route("/api/upload", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def upload():
|
def upload():
|
||||||
try:
|
try:
|
||||||
files = request.files.getlist('images')
|
files = request.files.getlist("images")
|
||||||
img_urls = handle_file_uploads(files)
|
img_urls = handle_file_uploads(files)
|
||||||
system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id'))
|
system_logger.info(f"用户上传文件: {len(files)} 张", user_id=session.get("user_id"))
|
||||||
return jsonify({"urls": img_urls})
|
return jsonify({"urls": img_urls})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/generate', methods=['POST'])
|
|
||||||
|
@api_bp.route("/api/generate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def generate():
|
def generate():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
data = request.json if request.is_json else request.form
|
data = request.json if request.is_json else request.form
|
||||||
|
|
||||||
# 1. 验证请求与权限
|
|
||||||
api_key, target_api, cost, use_trial, error = validate_generation_request(user, data)
|
api_key, target_api, cost, use_trial, error = validate_generation_request(user, data)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({"error": error}), 400
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
# 2. 扣除积分 (如果是试用模式)
|
|
||||||
if use_trial:
|
if use_trial:
|
||||||
deduct_points(user_id, cost)
|
deduct_points(user_id, cost)
|
||||||
|
|
||||||
model_value = data.get('model')
|
model_value = data.get("model")
|
||||||
prompt = data.get('prompt')
|
prompt = (data.get("prompt") or "").strip()
|
||||||
|
if not prompt:
|
||||||
|
return jsonify({"error": "提示词不能为空"}), 400
|
||||||
model_lower = model_value.lower()
|
model_lower = model_value.lower()
|
||||||
is_chat_model = ("gemini" in model_lower or "gpt" in model_lower) and "image" not in model_lower
|
is_chat_model = ("gemini" in model_lower or "gpt" in model_lower) and "image" not in model_lower
|
||||||
|
|
||||||
# 3. 处理聊天模型 (同步)
|
|
||||||
if is_chat_model:
|
if is_chat_model:
|
||||||
result, status_code = handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost)
|
result, status_code = handle_chat_generation_sync(
|
||||||
|
user_id, api_key, model_value, prompt, use_trial, cost
|
||||||
|
)
|
||||||
return jsonify(result), status_code
|
return jsonify(result), status_code
|
||||||
|
|
||||||
# 4. 构造生图 Payload
|
image_data = data.get("image_data", [])
|
||||||
payload = {
|
if model_value == "gpt-image-2":
|
||||||
"prompt": prompt,
|
if not image_data:
|
||||||
"model": model_value,
|
return jsonify({"error": "gpt-image-2 编辑模式至少需要上传 1 张参考图"}), 400
|
||||||
"response_format": "url",
|
|
||||||
"aspect_ratio": data.get('ratio')
|
payload = {
|
||||||
}
|
"prompt": prompt,
|
||||||
image_data = data.get('image_data', [])
|
"model": model_value,
|
||||||
if image_data:
|
"images": [{"image_url": img} for img in image_data],
|
||||||
payload["image"] = [img.split(',', 1)[1] if ',' in img else img for img in image_data]
|
"size": data.get("size") or "2160x3840",
|
||||||
|
"quality": "high",
|
||||||
if model_value in ("nano-banana-2", "gemini-3.1-flash-image-preview") and data.get('size'):
|
"output_format": "png",
|
||||||
payload["image_size"] = data.get('size')
|
}
|
||||||
|
else:
|
||||||
|
payload = {
|
||||||
|
"prompt": prompt,
|
||||||
|
"model": model_value,
|
||||||
|
"response_format": "url",
|
||||||
|
"aspect_ratio": data.get("ratio"),
|
||||||
|
}
|
||||||
|
if image_data:
|
||||||
|
payload["image"] = [img.split(",", 1)[1] if "," in img else img for img in image_data]
|
||||||
|
|
||||||
|
if model_value in ("nano-banana-2", "gemini-3.1-flash-image-preview") and data.get("size"):
|
||||||
|
payload["image_size"] = data.get("size")
|
||||||
|
|
||||||
# 5. 启动异步生图任务
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
task_id = start_async_image_task(app, user_id, payload, api_key, target_api, cost, data.get('mode'), model_value, use_trial)
|
task_id = start_async_image_task(
|
||||||
|
app,
|
||||||
|
user_id,
|
||||||
|
payload,
|
||||||
|
api_key,
|
||||||
|
target_api,
|
||||||
|
cost,
|
||||||
|
data.get("mode"),
|
||||||
|
model_value,
|
||||||
|
use_trial,
|
||||||
|
)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"message": "已开启异步生成任务"
|
"message": "已开启异步生成任务",
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/video/generate', methods=['POST'])
|
|
||||||
|
@api_bp.route("/api/video/generate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def video_generate():
|
def video_generate():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
user = db.session.get(User, user_id)
|
user = db.session.get(User, user_id)
|
||||||
|
|
||||||
data = request.json
|
data = request.json
|
||||||
|
|
||||||
# 1. 验证请求
|
|
||||||
model_value, cost, error = validate_video_request(user, data)
|
model_value, cost, error = validate_video_request(user, data)
|
||||||
if error:
|
if error:
|
||||||
return jsonify({"error": error}), 400
|
return jsonify({"error": error}), 400
|
||||||
|
|
||||||
# 2. 扣除积分
|
|
||||||
deduct_points(user_id, cost)
|
deduct_points(user_id, cost)
|
||||||
|
|
||||||
# 3. 构造 Payload
|
|
||||||
payload = {
|
payload = {
|
||||||
"model": model_value,
|
"model": model_value,
|
||||||
"prompt": data.get('prompt'),
|
"prompt": data.get("prompt"),
|
||||||
"enhance_prompt": data.get('enhance_prompt', False),
|
"enhance_prompt": data.get("enhance_prompt", False),
|
||||||
"images": data.get('images', []),
|
"images": data.get("images", []),
|
||||||
"aspect_ratio": data.get('aspect_ratio', '9:16')
|
"aspect_ratio": data.get("aspect_ratio", "9:16"),
|
||||||
}
|
}
|
||||||
|
|
||||||
# 处理 Base64 图片 (去掉 header)
|
|
||||||
if payload.get("images"):
|
|
||||||
payload["images"] = [
|
|
||||||
img.split(',', 1)[1] if ',' in img else img
|
|
||||||
for img in payload["images"]
|
|
||||||
]
|
|
||||||
|
|
||||||
# 4. 启动异步视频任务
|
if payload.get("images"):
|
||||||
|
payload["images"] = [img.split(",", 1)[1] if "," in img else img for img in payload["images"]]
|
||||||
|
|
||||||
app = current_app._get_current_object()
|
app = current_app._get_current_object()
|
||||||
task_id = start_async_video_task(app, user_id, payload, cost, model_value)
|
task_id = start_async_video_task(app, user_id, payload, cost, model_value)
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
"task_id": task_id,
|
"task_id": task_id,
|
||||||
"message": "视频生成任务已提交,系统正在导演中..."
|
"message": "视频生成任务已提交,系统正在处理中",
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/notifications/latest', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/notifications/latest", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def get_latest_notification():
|
def get_latest_notification():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
data = get_user_latest_notification(user_id)
|
data = get_user_latest_notification(user_id)
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/notifications/read', methods=['POST'])
|
|
||||||
|
@api_bp.route("/api/notifications/read", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def mark_notif_read():
|
def mark_notif_read():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
data = request.json
|
data = request.json
|
||||||
notif_id = data.get('id')
|
notif_id = data.get("id")
|
||||||
if not notif_id:
|
if not notif_id:
|
||||||
return jsonify({"error": "缺少通知 ID"}), 400
|
return jsonify({"error": "缺少通知 ID"}), 400
|
||||||
|
|
||||||
mark_notification_as_read(user_id, notif_id)
|
mark_notification_as_read(user_id, notif_id)
|
||||||
return jsonify({"status": "ok"})
|
return jsonify({"status": "ok"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/history', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/history", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def get_history():
|
def get_history():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
page = request.args.get('page', 1, type=int)
|
page = request.args.get("page", 1, type=int)
|
||||||
per_page = request.args.get('per_page', 10, type=int)
|
per_page = request.args.get("per_page", 10, type=int)
|
||||||
filter_type = request.args.get('filter_type', 'all')
|
filter_type = request.args.get("filter_type", "all")
|
||||||
|
|
||||||
data = get_user_history_data(user_id, page, per_page, filter_type)
|
data = get_user_history_data(user_id, page, per_page, filter_type)
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/stats/points', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/stats/points", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def get_user_point_stats():
|
def get_user_point_stats():
|
||||||
"""获取积分统计图表数据"""
|
|
||||||
from services.stats_service import get_point_stats
|
from services.stats_service import get_point_stats
|
||||||
user_id = session.get('user_id')
|
|
||||||
days = request.args.get('days', 7, type=int)
|
user_id = session.get("user_id")
|
||||||
|
days = request.args.get("days", 7, type=int)
|
||||||
return jsonify(get_point_stats(user_id, days))
|
return jsonify(get_point_stats(user_id, days))
|
||||||
|
|
||||||
@api_bp.route('/api/stats/details', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/stats/details", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def get_user_point_details():
|
def get_user_point_details():
|
||||||
"""获取积分消耗明细"""
|
|
||||||
from services.stats_service import get_point_details
|
from services.stats_service import get_point_details
|
||||||
user_id = session.get('user_id')
|
|
||||||
page = request.args.get('page', 1, type=int)
|
user_id = session.get("user_id")
|
||||||
|
page = request.args.get("page", 1, type=int)
|
||||||
return jsonify(get_point_details(user_id, page))
|
return jsonify(get_point_details(user_id, page))
|
||||||
|
|
||||||
@api_bp.route('/api/download_proxy', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/download_proxy", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def download_proxy():
|
def download_proxy():
|
||||||
import time
|
import time
|
||||||
url = request.args.get('url')
|
|
||||||
# 默认文件名逻辑
|
url = request.args.get("url")
|
||||||
default_name = f"video-{int(time.time())}.mp4"
|
default_name = f"video-{int(time.time())}.mp4"
|
||||||
filename = request.args.get('filename') or default_name
|
filename = request.args.get("filename") or default_name
|
||||||
|
|
||||||
if not url:
|
if not url:
|
||||||
return jsonify({"error": "缺少 URL 参数"}), 400
|
return jsonify({"error": "缺少 URL 参数"}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
req, headers = get_remote_file_stream(url)
|
req, headers = get_remote_file_stream(url)
|
||||||
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
|
headers["Content-Disposition"] = f'attachment; filename="{filename}"'
|
||||||
|
|
||||||
def generate():
|
def generate_stream():
|
||||||
for chunk in req.iter_content(chunk_size=4096):
|
for chunk in req.iter_content(chunk_size=4096):
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|
||||||
return current_app.response_class(generate(), headers=headers)
|
return current_app.response_class(generate_stream(), headers=headers)
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
system_logger.error(f"代理下载失败: {str(e)}")
|
system_logger.error(f"代理下载失败: {str(e)}")
|
||||||
return jsonify({"error": "下载失败"}), 500
|
return jsonify({"error": "下载失败"}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/prompts', methods=['GET'])
|
|
||||||
|
@api_bp.route("/api/prompts", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def list_prompts():
|
def list_prompts():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
prompts = SavedPrompt.query.filter_by(user_id=user_id).order_by(SavedPrompt.created_at.desc()).all()
|
prompts = SavedPrompt.query.filter_by(user_id=user_id).order_by(SavedPrompt.created_at.desc()).all()
|
||||||
return jsonify([{
|
return jsonify([
|
||||||
"id": p.id,
|
{
|
||||||
"label": p.title, # Use label/value structure for frontend select
|
"id": p.id,
|
||||||
"value": p.prompt
|
"label": p.title,
|
||||||
} for p in prompts])
|
"value": p.prompt,
|
||||||
|
}
|
||||||
|
for p in prompts
|
||||||
|
])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/prompts', methods=['POST'])
|
|
||||||
|
@api_bp.route("/api/prompts", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def save_prompt():
|
def save_prompt():
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
data = request.json
|
data = request.json
|
||||||
title = data.get('title')
|
title = data.get("title")
|
||||||
prompt_text = data.get('prompt')
|
prompt_text = data.get("prompt")
|
||||||
|
|
||||||
if not title or not prompt_text:
|
if not title or not prompt_text:
|
||||||
return jsonify({"error": "标题和内容不能为空"}), 400
|
return jsonify({"error": "标题和内容不能为空"}), 400
|
||||||
|
|
||||||
# Limit to 50 saved prompts
|
|
||||||
count = SavedPrompt.query.filter_by(user_id=user_id).count()
|
count = SavedPrompt.query.filter_by(user_id=user_id).count()
|
||||||
if count >= 50:
|
if count >= 50:
|
||||||
return jsonify({"error": "收藏数量已达上限 (50)"}), 400
|
return jsonify({"error": "收藏数量已达上限 (50)"}), 400
|
||||||
|
|
||||||
p = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text)
|
prompt = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text)
|
||||||
db.session.add(p)
|
db.session.add(prompt)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return jsonify({"message": "保存成功", "id": p.id})
|
return jsonify({"message": "保存成功", "id": prompt.id})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
@api_bp.route('/api/prompts/<int:id>', methods=['DELETE'])
|
|
||||||
|
@api_bp.route("/api/prompts/<int:id>", methods=["DELETE"])
|
||||||
@login_required
|
@login_required
|
||||||
def delete_prompt(id):
|
def delete_prompt(id):
|
||||||
try:
|
try:
|
||||||
user_id = session.get('user_id')
|
user_id = session.get("user_id")
|
||||||
p = SavedPrompt.query.filter_by(id=id, user_id=user_id).first()
|
prompt = SavedPrompt.query.filter_by(id=id, user_id=user_id).first()
|
||||||
if p:
|
if prompt:
|
||||||
db.session.delete(p)
|
db.session.delete(prompt)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return jsonify({"message": "删除成功"})
|
return jsonify({"message": "删除成功"})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@ -37,6 +37,8 @@ class Config:
|
|||||||
CHAT_API = f"{AI_BASE_URL}/v1/chat/completions"
|
CHAT_API = f"{AI_BASE_URL}/v1/chat/completions"
|
||||||
VIDEO_GEN_API = f"{AI_BASE_URL}/v2/videos/generations"
|
VIDEO_GEN_API = f"{AI_BASE_URL}/v2/videos/generations"
|
||||||
VIDEO_POLL_API = f"{AI_BASE_URL}/v2/videos/generations/{{task_id}}"
|
VIDEO_POLL_API = f"{AI_BASE_URL}/v2/videos/generations/{{task_id}}"
|
||||||
|
GPT_IMAGE_EDIT_API = "https://nas.4x4g.com:8317/v1/images/edits"
|
||||||
|
GPT_IMAGE_EDIT_KEY = "sk-vDmmYNzoe7UjWskKx"
|
||||||
|
|
||||||
# 试用模式配置
|
# 试用模式配置
|
||||||
TRIAL_API = f"{AI_BASE_URL}/v1/images/generations"
|
TRIAL_API = f"{AI_BASE_URL}/v1/images/generations"
|
||||||
|
|||||||
@ -1,100 +1,114 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
from config import Config
|
from config import Config
|
||||||
from models import SystemDict, GenerationRecord, User, db
|
from extensions import db, redis_client
|
||||||
from extensions import redis_client
|
from models import GenerationRecord, SystemDict, User
|
||||||
from services.logger import system_logger
|
from services.logger import system_logger
|
||||||
from services.task_service import process_image_generation, process_video_generation
|
from services.task_service import process_image_generation, process_video_generation
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
import uuid
|
|
||||||
import threading
|
|
||||||
from flask import current_app
|
|
||||||
from utils import get_api_candidates, get_proxied_url, should_switch_to_backup
|
from utils import get_api_candidates, get_proxied_url, should_switch_to_backup
|
||||||
|
|
||||||
|
|
||||||
def get_model_cost(model_value, is_video=False):
|
def get_model_cost(model_value, is_video=False):
|
||||||
"""获取模型消耗积分"""
|
"""获取模型消耗积分。"""
|
||||||
dict_type = 'video_model' if is_video else 'ai_model'
|
dict_type = "video_model" if is_video else "ai_model"
|
||||||
model_dict = SystemDict.query.filter_by(dict_type=dict_type, value=model_value).first()
|
model_dict = SystemDict.query.filter_by(dict_type=dict_type, value=model_value).first()
|
||||||
|
|
||||||
if model_dict:
|
if model_dict:
|
||||||
return model_dict.cost
|
return model_dict.cost
|
||||||
|
|
||||||
# Default costs
|
# 默认计费兜底
|
||||||
if is_video:
|
if is_video:
|
||||||
return 15 if "pro" in model_value.lower() or "3.1" in model_value else 10
|
return 15 if "pro" in model_value.lower() or "3.1" in model_value else 10
|
||||||
else:
|
return 1
|
||||||
return 1
|
|
||||||
|
|
||||||
def validate_generation_request(user, data):
|
def validate_generation_request(user, data):
|
||||||
"""验证生图请求并返回配置 (api_key, target_api, cost, use_trial)"""
|
"""校验生图请求并返回 (api_key, target_api, cost, use_trial, error)。"""
|
||||||
mode = data.get('mode', 'trial')
|
mode = data.get("mode", "trial")
|
||||||
is_premium = data.get('is_premium', False)
|
is_premium = data.get("is_premium", False)
|
||||||
input_key = data.get('apiKey')
|
input_key = data.get("apiKey")
|
||||||
model_value = data.get('model')
|
model_value = data.get("model")
|
||||||
is_gemini_flash_image_preview = model_value == 'gemini-3.1-flash-image-preview'
|
is_gemini_flash_image_preview = model_value == "gemini-3.1-flash-image-preview"
|
||||||
|
is_gpt_image_edit = model_value == "gpt-image-2"
|
||||||
|
|
||||||
if isinstance(is_premium, str):
|
if isinstance(is_premium, str):
|
||||||
is_premium = is_premium.lower() in ('1', 'true', 'yes', 'on')
|
is_premium = is_premium.lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
target_api = Config.AI_API
|
# gpt-image-2 走独立的 edits 接口,其余模型沿用现有 generations 接口
|
||||||
|
target_api = Config.GPT_IMAGE_EDIT_API if is_gpt_image_edit else Config.AI_API
|
||||||
api_key = None
|
api_key = None
|
||||||
use_trial = False
|
use_trial = False
|
||||||
|
|
||||||
if mode == 'key':
|
if mode == "key":
|
||||||
api_key = input_key or user.api_key
|
api_key = input_key or user.api_key
|
||||||
if not api_key:
|
if not api_key:
|
||||||
return None, None, 0, False, "请先输入您的 API 密钥"
|
return None, None, 0, False, "请先输入您的 API 密钥"
|
||||||
|
|
||||||
# Update user key if changed
|
# 用户手动输入了新 key 时,同步保存到账号
|
||||||
if input_key and input_key != user.api_key:
|
if input_key and input_key != user.api_key:
|
||||||
user.api_key = input_key
|
user.api_key = input_key
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
else:
|
else:
|
||||||
if user.points > 0:
|
if user.points <= 0:
|
||||||
if is_premium:
|
|
||||||
api_key = Config.GEMINI_FLASH_IMAGE_PREMIUM_KEY if is_gemini_flash_image_preview else Config.PREMIUM_KEY
|
|
||||||
elif is_gemini_flash_image_preview:
|
|
||||||
api_key = Config.PREMIUM_KEY
|
|
||||||
else:
|
|
||||||
api_key = Config.TRIAL_KEY
|
|
||||||
target_api = Config.TRIAL_API
|
|
||||||
use_trial = True
|
|
||||||
else:
|
|
||||||
return None, None, 0, False, "可用积分已耗尽,请充值或切换至自定义 Key 模式"
|
return None, None, 0, False, "可用积分已耗尽,请充值或切换至自定义 Key 模式"
|
||||||
|
|
||||||
# 计算单价
|
# 积分模式下,gpt-image-2 使用固定的专用 key
|
||||||
|
if is_gpt_image_edit:
|
||||||
|
api_key = Config.GPT_IMAGE_EDIT_KEY
|
||||||
|
elif is_premium:
|
||||||
|
api_key = (
|
||||||
|
Config.GEMINI_FLASH_IMAGE_PREMIUM_KEY
|
||||||
|
if is_gemini_flash_image_preview
|
||||||
|
else Config.PREMIUM_KEY
|
||||||
|
)
|
||||||
|
elif is_gemini_flash_image_preview:
|
||||||
|
api_key = Config.PREMIUM_KEY
|
||||||
|
else:
|
||||||
|
api_key = Config.TRIAL_KEY
|
||||||
|
|
||||||
|
target_api = Config.GPT_IMAGE_EDIT_API if is_gpt_image_edit else Config.TRIAL_API
|
||||||
|
use_trial = True
|
||||||
|
|
||||||
|
# 计算本次任务消耗
|
||||||
cost = get_model_cost(model_value, is_video=False)
|
cost = get_model_cost(model_value, is_video=False)
|
||||||
if use_trial and is_premium:
|
if use_trial and is_premium:
|
||||||
cost *= 2
|
cost *= 2
|
||||||
|
|
||||||
if use_trial:
|
if use_trial and user.points < cost:
|
||||||
if user.points < cost:
|
return None, None, cost, True, f"可用积分不足,本次需要 {cost} 积分"
|
||||||
return None, None, cost, True, f"可用积分不足(本次需要 {cost} 积分)"
|
|
||||||
|
|
||||||
return api_key, target_api, cost, use_trial, None
|
return api_key, target_api, cost, use_trial, None
|
||||||
|
|
||||||
|
|
||||||
def deduct_points(user_id, cost):
|
def deduct_points(user_id, cost):
|
||||||
"""原子扣除积分"""
|
"""原子扣减积分。"""
|
||||||
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
|
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
|
||||||
if user:
|
if user:
|
||||||
user.points -= cost
|
user.points -= cost
|
||||||
user.has_used_points = True
|
user.has_used_points = True
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
|
|
||||||
def refund_points(user_id, cost):
|
def refund_points(user_id, cost):
|
||||||
"""原子退还积分"""
|
"""原子退回积分。"""
|
||||||
try:
|
try:
|
||||||
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
|
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
|
||||||
if user:
|
if user:
|
||||||
user.points += cost
|
user.points += cost
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
except:
|
except Exception:
|
||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
|
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
|
||||||
"""同步处理对话类模型"""
|
"""同步处理聊天类模型。"""
|
||||||
chat_payload = {
|
chat_payload = {
|
||||||
"model": model_value,
|
"model": model_value,
|
||||||
"messages": [{"role": "user", "content": prompt}]
|
"messages": [{"role": "user", "content": prompt}],
|
||||||
}
|
}
|
||||||
try:
|
try:
|
||||||
resp = None
|
resp = None
|
||||||
@ -102,13 +116,16 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
|
|||||||
candidates = get_api_candidates(Config.CHAT_API, api_key)
|
candidates = get_api_candidates(Config.CHAT_API, api_key)
|
||||||
|
|
||||||
for idx, candidate in enumerate(candidates):
|
for idx, candidate in enumerate(candidates):
|
||||||
headers = {"Authorization": f"Bearer {candidate['api_key']}", "Content-Type": "application/json"}
|
headers = {
|
||||||
|
"Authorization": f"Bearer {candidate['api_key']}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
try:
|
try:
|
||||||
resp = requests.post(
|
resp = requests.post(
|
||||||
get_proxied_url(candidate['url']),
|
get_proxied_url(candidate["url"]),
|
||||||
json=chat_payload,
|
json=chat_payload,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
timeout=Config.PROXY_TIMEOUT_LONG
|
timeout=Config.PROXY_TIMEOUT_LONG,
|
||||||
)
|
)
|
||||||
if resp.status_code == 200:
|
if resp.status_code == 200:
|
||||||
break
|
break
|
||||||
@ -120,7 +137,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
|
|||||||
"聊天主线路失败,切换备用线路",
|
"聊天主线路失败,切换备用线路",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
model=model_value,
|
model=model_value,
|
||||||
status_code=resp.status_code
|
status_code=resp.status_code,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
break
|
break
|
||||||
@ -131,7 +148,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
|
|||||||
"聊天请求异常,切换备用线路",
|
"聊天请求异常,切换备用线路",
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
model=model_value,
|
model=model_value,
|
||||||
error=last_error
|
error=last_error,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
raise
|
raise
|
||||||
@ -140,72 +157,83 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
|
|||||||
if use_trial:
|
if use_trial:
|
||||||
refund_points(user_id, cost)
|
refund_points(user_id, cost)
|
||||||
return {"error": last_error or "聊天请求失败"}, resp.status_code if resp else 500
|
return {"error": last_error or "聊天请求失败"}, resp.status_code if resp else 500
|
||||||
|
|
||||||
api_result = resp.json()
|
api_result = resp.json()
|
||||||
content = api_result['choices'][0]['message']['content']
|
content = api_result["choices"][0]["message"]["content"]
|
||||||
|
|
||||||
# 记录聊天历史
|
# 非验光单解读的文本生成写入历史
|
||||||
if prompt != "解读验光单":
|
if prompt != "解读验光单":
|
||||||
new_record = GenerationRecord(
|
new_record = GenerationRecord(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
model=model_value,
|
model=model_value,
|
||||||
cost=cost,
|
cost=cost,
|
||||||
image_urls=json.dumps([{"type": "text", "content": content}])
|
image_urls=json.dumps([{"type": "text", "content": content}]),
|
||||||
)
|
)
|
||||||
db.session.add(new_record)
|
db.session.add(new_record)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"data": [{"content": content, "type": "text"}],
|
"data": [{"content": content, "type": "text"}],
|
||||||
"message": "生成成功!"
|
"message": "生成成功",
|
||||||
}, 200
|
}, 200
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if use_trial:
|
if use_trial:
|
||||||
refund_points(user_id, cost)
|
refund_points(user_id, cost)
|
||||||
return {"error": str(e)}, 500
|
return {"error": str(e)}, 500
|
||||||
|
|
||||||
|
|
||||||
def start_async_image_task(app, user_id, payload, api_key, target_api, cost, mode, model_value, use_trial=False):
|
def start_async_image_task(app, user_id, payload, api_key, target_api, cost, mode, model_value, use_trial=False):
|
||||||
"""启动异步生图任务"""
|
"""启动异步生图任务。"""
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
log_msg = "用户发起验光单解读" if payload.get('prompt') == "解读验光单" else "用户发起生图任务"
|
log_msg = "用户发起验光单解读" if payload.get("prompt") == "解读验光单" else "用户发起生图任务"
|
||||||
system_logger.info(log_msg, model=model_value, mode=mode)
|
system_logger.info(log_msg, model=model_value, mode=mode)
|
||||||
|
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "queued", "message": "任务已提交,等待处理..."}))
|
redis_client.setex(
|
||||||
|
f"task:{task_id}",
|
||||||
|
3600,
|
||||||
|
json.dumps({"status": "queued", "message": "任务已提交,等待处理..."}),
|
||||||
|
)
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=process_image_generation,
|
target=process_image_generation,
|
||||||
args=(app, user_id, task_id, payload, api_key, target_api, cost, use_trial)
|
args=(app, user_id, task_id, payload, api_key, target_api, cost, use_trial),
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
return task_id
|
return task_id
|
||||||
|
|
||||||
|
|
||||||
def validate_video_request(user, data):
|
def validate_video_request(user, data):
|
||||||
"""验证视频生成请求"""
|
"""校验视频生成请求。"""
|
||||||
if user.points <= 0:
|
if user.points <= 0:
|
||||||
return None, 0, "可用积分不足,请先充值"
|
return None, 0, "可用积分不足,请先充值"
|
||||||
|
|
||||||
model_value = data.get('model', 'veo3.1')
|
model_value = data.get("model", "veo3.1")
|
||||||
cost = get_model_cost(model_value, is_video=True)
|
cost = get_model_cost(model_value, is_video=True)
|
||||||
|
|
||||||
if user.points < cost:
|
if user.points < cost:
|
||||||
return None, cost, f"积分不足,生成该视频需要 {cost} 积分"
|
return None, cost, f"积分不足,生成该视频需要 {cost} 积分"
|
||||||
|
|
||||||
return model_value, cost, None
|
return model_value, cost, None
|
||||||
|
|
||||||
|
|
||||||
def start_async_video_task(app, user_id, payload, cost, model_value):
|
def start_async_video_task(app, user_id, payload, cost, model_value):
|
||||||
"""启动异步视频任务"""
|
"""启动异步视频任务。"""
|
||||||
api_key = Config.VIDEO_KEY
|
api_key = Config.VIDEO_KEY
|
||||||
task_id = str(uuid.uuid4())
|
task_id = str(uuid.uuid4())
|
||||||
|
|
||||||
system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost)
|
system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost)
|
||||||
|
|
||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "queued", "message": "视频任务已提交,准备开始导演..."}))
|
redis_client.setex(
|
||||||
|
f"task:{task_id}",
|
||||||
|
3600,
|
||||||
|
json.dumps({"status": "queued", "message": "视频任务已提交,准备开始处理..."}),
|
||||||
|
)
|
||||||
|
|
||||||
threading.Thread(
|
threading.Thread(
|
||||||
target=process_video_generation,
|
target=process_video_generation,
|
||||||
args=(app, user_id, task_id, payload, api_key, cost, True) # 视频目前默认为积分模式
|
args=(app, user_id, task_id, payload, api_key, cost, True),
|
||||||
).start()
|
).start()
|
||||||
|
|
||||||
return task_id
|
return task_id
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import requests
|
|||||||
import io
|
import io
|
||||||
import time
|
import time
|
||||||
import base64
|
import base64
|
||||||
|
import binascii
|
||||||
import threading
|
import threading
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from requests.exceptions import ConnectionError, ConnectTimeout
|
from requests.exceptions import ConnectionError, ConnectTimeout
|
||||||
@ -46,6 +47,171 @@ def _extract_error_detail(data, default="未知错误"):
|
|||||||
|
|
||||||
return default
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_image_extension(content_type, fallback=".png"):
|
||||||
|
mapping = {
|
||||||
|
"image/png": ".png",
|
||||||
|
"image/jpeg": ".jpg",
|
||||||
|
"image/jpg": ".jpg",
|
||||||
|
"image/webp": ".webp",
|
||||||
|
}
|
||||||
|
return mapping.get((content_type or "").lower(), fallback)
|
||||||
|
|
||||||
|
|
||||||
|
def _put_object_bytes(object_name, content, content_type):
|
||||||
|
s3_client.put_object(
|
||||||
|
Bucket=Config.MINIO["bucket"],
|
||||||
|
Key=object_name,
|
||||||
|
Body=content,
|
||||||
|
ContentType=content_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _upload_image_bytes_to_minio(content, content_type="image/png"):
|
||||||
|
ext = _guess_image_extension(content_type)
|
||||||
|
base_filename = f"gen-{uuid.uuid4().hex}"
|
||||||
|
full_filename = f"{base_filename}{ext}"
|
||||||
|
thumb_filename = f"{base_filename}-thumb.jpg"
|
||||||
|
|
||||||
|
try:
|
||||||
|
_put_object_bytes(full_filename, content, content_type)
|
||||||
|
except Exception as upload_e:
|
||||||
|
raise Exception(f"MinIO 上传原图失败: {upload_e}")
|
||||||
|
|
||||||
|
full_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
|
||||||
|
thumb_url = full_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(content))
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
w, h = img.size
|
||||||
|
if w > 400:
|
||||||
|
ratio = 400 / float(w)
|
||||||
|
img.thumbnail((400, int(h * ratio)), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
thumb_io = io.BytesIO()
|
||||||
|
img.save(thumb_io, format="JPEG", quality=80, optimize=True)
|
||||||
|
thumb_io.seek(0)
|
||||||
|
|
||||||
|
_put_object_bytes(thumb_filename, thumb_io.getvalue(), "image/jpeg")
|
||||||
|
thumb_url = f"{Config.MINIO['public_url']}{quote(thumb_filename)}"
|
||||||
|
except Exception as thumb_e:
|
||||||
|
system_logger.warning(f"缩略图生成失败: {thumb_e}")
|
||||||
|
|
||||||
|
return {"url": full_url, "thumb": thumb_url}
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_base64_image(image_value):
|
||||||
|
raw_value = image_value or ""
|
||||||
|
content_type = "image/png"
|
||||||
|
if raw_value.startswith("data:") and "," in raw_value:
|
||||||
|
header, raw_value = raw_value.split(",", 1)
|
||||||
|
if ";" in header:
|
||||||
|
content_type = header[5:].split(";", 1)[0] or content_type
|
||||||
|
|
||||||
|
# 清理换行和空白,兼容部分服务端返回的分段 Base64
|
||||||
|
raw_value = "".join(raw_value.strip().split())
|
||||||
|
padding = (-len(raw_value)) % 4
|
||||||
|
if padding:
|
||||||
|
raw_value += "=" * padding
|
||||||
|
|
||||||
|
try:
|
||||||
|
decoded = base64.b64decode(raw_value, validate=True)
|
||||||
|
except binascii.Error:
|
||||||
|
try:
|
||||||
|
decoded = base64.urlsafe_b64decode(raw_value)
|
||||||
|
except Exception as decode_e:
|
||||||
|
raise Exception(f"Base64 解码失败: {decode_e}")
|
||||||
|
|
||||||
|
if not decoded:
|
||||||
|
raise Exception("Base64 解码失败: 结果为空")
|
||||||
|
|
||||||
|
# 提前验证图片内容,避免把损坏字节流误判成 MinIO 上传问题
|
||||||
|
try:
|
||||||
|
img = Image.open(io.BytesIO(decoded))
|
||||||
|
img.verify()
|
||||||
|
except Exception as verify_e:
|
||||||
|
raise Exception(f"Base64 已解码,但图片数据无效: {verify_e}")
|
||||||
|
|
||||||
|
return decoded, content_type
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_b64_images(result):
|
||||||
|
images = []
|
||||||
|
for item in result.get("data", []) or []:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if item.get("b64_json"):
|
||||||
|
images.append(item["b64_json"])
|
||||||
|
elif item.get("image_base64"):
|
||||||
|
images.append(item["image_base64"])
|
||||||
|
return images
|
||||||
|
|
||||||
|
|
||||||
|
def _process_gpt_image_edit(app, user_id, task_id, payload, api_key, target_api, cost, use_trial):
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
# gpt-image-2 的 edits 接口走站内地址,直连比经过公共代理更稳定
|
||||||
|
response = requests.post(
|
||||||
|
target_api,
|
||||||
|
json=payload,
|
||||||
|
headers=headers,
|
||||||
|
timeout=Config.PROXY_TIMEOUT_GENERATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
if response.status_code != 200:
|
||||||
|
try:
|
||||||
|
error_data = response.json()
|
||||||
|
except Exception:
|
||||||
|
error_data = {"message": response.text}
|
||||||
|
raise Exception(f"gpt-image-2 请求失败: {_extract_error_detail(error_data)}")
|
||||||
|
|
||||||
|
result = response.json()
|
||||||
|
b64_images = _extract_b64_images(result)
|
||||||
|
if not b64_images:
|
||||||
|
raise Exception("gpt-image-2 未返回可用图片数据")
|
||||||
|
|
||||||
|
processed_data = []
|
||||||
|
final_urls = []
|
||||||
|
output_format = (payload.get("output_format") or "png").lower()
|
||||||
|
default_content_type = f"image/{output_format}" if output_format in ("png", "jpeg", "jpg", "webp") else "image/png"
|
||||||
|
|
||||||
|
for b64_image in b64_images:
|
||||||
|
try:
|
||||||
|
image_bytes, content_type = _decode_base64_image(b64_image)
|
||||||
|
except Exception as decode_e:
|
||||||
|
raise Exception(f"gpt-image-2 返回的 Base64 图片无效: {decode_e}")
|
||||||
|
if content_type == "image/png" and default_content_type != "image/png":
|
||||||
|
content_type = default_content_type
|
||||||
|
try:
|
||||||
|
uploaded = _upload_image_bytes_to_minio(image_bytes, content_type)
|
||||||
|
except Exception as upload_e:
|
||||||
|
raise Exception(f"gpt-image-2 结果已生成,但同步到 MinIO 失败: {upload_e}")
|
||||||
|
processed_data.append(uploaded)
|
||||||
|
final_urls.append(uploaded["url"])
|
||||||
|
|
||||||
|
new_record = GenerationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
prompt=payload.get("prompt"),
|
||||||
|
model=payload.get("model"),
|
||||||
|
cost=cost,
|
||||||
|
image_urls=json.dumps(processed_data),
|
||||||
|
)
|
||||||
|
db.session.add(new_record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
system_logger.info("gpt-image-2 生图任务完成", user_id=user_id, task_id=task_id, model=payload.get("model"))
|
||||||
|
redis_client.setex(
|
||||||
|
f"task:{task_id}",
|
||||||
|
3600,
|
||||||
|
json.dumps({"status": "complete", "urls": final_urls, "record_id": new_record.id}),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def sync_images_background(app, record_id, raw_urls):
|
def sync_images_background(app, record_id, raw_urls):
|
||||||
"""后台同步图片至 MinIO,并生成缩略图,带重试机制"""
|
"""后台同步图片至 MinIO,并生成缩略图,带重试机制"""
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
@ -129,6 +295,10 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api
|
|||||||
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "processing", "message": "任务已提交,正在排队处理..."}))
|
redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "processing", "message": "任务已提交,正在排队处理..."}))
|
||||||
try:
|
try:
|
||||||
# 1. 提交异步请求 (带重试机制)
|
# 1. 提交异步请求 (带重试机制)
|
||||||
|
if payload.get('model') == 'gpt-image-2':
|
||||||
|
_process_gpt_image_edit(app, user_id, task_id, payload, api_key, target_api, cost, use_trial)
|
||||||
|
return
|
||||||
|
|
||||||
submit_resp = None
|
submit_resp = None
|
||||||
last_error = None
|
last_error = None
|
||||||
active_api = target_api
|
active_api = target_api
|
||||||
|
|||||||
@ -457,10 +457,46 @@ document.addEventListener('paste', (e) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const SIZE_MODELS = ['nano-banana-pro', 'gemini-3.1-flash-image-preview'];
|
const GPT_IMAGE_EDIT_MODEL = 'gpt-image-2';
|
||||||
|
const GPT_IMAGE_EDIT_SIZES = [
|
||||||
|
{ label: '1024x1024 (square)', value: '1024x1024' },
|
||||||
|
{ label: '1536x1024 (landscape)', value: '1536x1024' },
|
||||||
|
{ label: '1024x1536 (portrait)', value: '1024x1536' },
|
||||||
|
{ label: '2048x2048 (2K square)', value: '2048x2048' },
|
||||||
|
{ label: '2048x1152 (2K landscape)', value: '2048x1152' },
|
||||||
|
{ label: '3840x2160 (4K landscape)', value: '3840x2160' },
|
||||||
|
{ label: '2160x3840 (4K portrait)', value: '2160x3840' },
|
||||||
|
{ label: 'auto (default)', value: 'auto' }
|
||||||
|
];
|
||||||
|
const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview', GPT_IMAGE_EDIT_MODEL];
|
||||||
|
let defaultSizeOptions = [];
|
||||||
|
|
||||||
document.getElementById('modelSelect').onchange = (e) => {
|
function updateSizeOptions() {
|
||||||
document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(e.target.value));
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
|
const sizeGroup = document.getElementById('sizeGroup');
|
||||||
|
const sizeSelect = document.getElementById('sizeSelect');
|
||||||
|
const ratioGroup = document.getElementById('ratioGroup');
|
||||||
|
if (!modelSelect || !sizeGroup || !sizeSelect) return;
|
||||||
|
|
||||||
|
const selectedModel = modelSelect.value;
|
||||||
|
const previousValue = sizeSelect.value;
|
||||||
|
const nextSizes = selectedModel === GPT_IMAGE_EDIT_MODEL ? GPT_IMAGE_EDIT_SIZES : defaultSizeOptions;
|
||||||
|
fillSelect('sizeSelect', nextSizes);
|
||||||
|
|
||||||
|
if (selectedModel === GPT_IMAGE_EDIT_MODEL) {
|
||||||
|
sizeSelect.value = '2160x3840';
|
||||||
|
} else if (nextSizes.some(item => item.value === previousValue)) {
|
||||||
|
sizeSelect.value = previousValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(selectedModel));
|
||||||
|
if (ratioGroup) {
|
||||||
|
ratioGroup.classList.toggle('hidden', selectedModel === GPT_IMAGE_EDIT_MODEL);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('modelSelect').onchange = () => {
|
||||||
|
updateSizeOptions();
|
||||||
updateCostPreview(); // 切换模型时更新计费预览
|
updateCostPreview(); // 切换模型时更新计费预览
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -490,7 +526,7 @@ document.getElementById('promptTpl').onchange = (e) => {
|
|||||||
|
|
||||||
document.getElementById('submitBtn').onclick = async () => {
|
document.getElementById('submitBtn').onclick = async () => {
|
||||||
const btn = document.getElementById('submitBtn');
|
const btn = document.getElementById('submitBtn');
|
||||||
const prompt = document.getElementById('manualPrompt').value;
|
const prompt = document.getElementById('manualPrompt').value.trim();
|
||||||
const apiKey = document.getElementById('apiKey').value;
|
const apiKey = document.getElementById('apiKey').value;
|
||||||
const num = parseInt(document.getElementById('numSelect').value);
|
const num = parseInt(document.getElementById('numSelect').value);
|
||||||
|
|
||||||
@ -510,11 +546,24 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 允许文生图(不强制要求图片),但至少得有提示词或图片
|
// 允许文生图(不强制要求图片),但至少得有提示词或图片
|
||||||
if (!prompt && uploadedFiles.length === 0) {
|
if (!prompt) {
|
||||||
|
return showToast('提示词不能为空', 'warning');
|
||||||
return showToast('请至少输入提示词或上传参考图', 'warning');
|
return showToast('请至少输入提示词或上传参考图', 'warning');
|
||||||
}
|
}
|
||||||
|
|
||||||
// UI 锁定
|
// UI 锁定
|
||||||
|
if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) {
|
||||||
|
return showToast('gpt-image-2 requires a reference image', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) {
|
||||||
|
return showToast('gpt-image-2 妯″瀷蹇呴』涓婁紶鍙傝€冨浘', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) {
|
||||||
|
return showToast('gpt-image-2 requires a reference image', 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
const btnText = btn.querySelector('span');
|
const btnText = btn.querySelector('span');
|
||||||
btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎...";
|
btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎...";
|
||||||
@ -597,7 +646,7 @@ document.getElementById('submitBtn').onclick = async () => {
|
|||||||
apiKey: currentMode === 'key' ? apiKey : '',
|
apiKey: currentMode === 'key' ? apiKey : '',
|
||||||
prompt: document.getElementById('manualPrompt').value,
|
prompt: document.getElementById('manualPrompt').value,
|
||||||
model: document.getElementById('modelSelect').value,
|
model: document.getElementById('modelSelect').value,
|
||||||
ratio: document.getElementById('ratioSelect').value,
|
ratio: document.getElementById('modelSelect').value === GPT_IMAGE_EDIT_MODEL ? undefined : document.getElementById('ratioSelect').value,
|
||||||
size: document.getElementById('sizeSelect').value,
|
size: document.getElementById('sizeSelect').value,
|
||||||
image_data // 发送 Base64 数组
|
image_data // 发送 Base64 数组
|
||||||
})
|
})
|
||||||
@ -787,7 +836,7 @@ window.addEventListener('message', (e) => {
|
|||||||
if (modelSelect) {
|
if (modelSelect) {
|
||||||
modelSelect.value = 'nano-banana-2';
|
modelSelect.value = 'nano-banana-2';
|
||||||
modelSelect.disabled = true; // 锁定选择
|
modelSelect.disabled = true; // 锁定选择
|
||||||
sizeGroup.classList.remove('hidden');
|
updateSizeOptions();
|
||||||
updateCostPreview();
|
updateCostPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1036,12 +1085,13 @@ async function refreshPromptsList() {
|
|||||||
|
|
||||||
fillSelect('modelSelect', d.models);
|
fillSelect('modelSelect', d.models);
|
||||||
fillSelect('ratioSelect', d.ratios);
|
fillSelect('ratioSelect', d.ratios);
|
||||||
fillSelect('sizeSelect', d.sizes);
|
defaultSizeOptions = d.sizes || [];
|
||||||
|
updateSizeOptions();
|
||||||
|
|
||||||
// 初始化后检查默认模型是否需要显示尺寸选择器
|
// 初始化后检查默认模型是否需要显示尺寸选择器
|
||||||
const modelSelect = document.getElementById('modelSelect');
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
if (modelSelect && typeof SIZE_MODELS !== 'undefined') {
|
if (modelSelect && typeof SIZE_MODELS !== 'undefined') {
|
||||||
document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
|
updateSizeOptions();
|
||||||
}
|
}
|
||||||
|
|
||||||
const userPrompts = await loadUserPrompts();
|
const userPrompts = await loadUserPrompts();
|
||||||
|
|||||||
@ -81,7 +81,7 @@
|
|||||||
<select id="modelSelect"
|
<select id="modelSelect"
|
||||||
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
|
class="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-bold outline-none focus:border-indigo-500 transition-all"></select>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div id="ratioGroup" class="space-y-1.5">
|
||||||
<label
|
<label
|
||||||
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
|
class="text-[10px] font-bold text-slate-400 uppercase tracking-widest ml-1 flex items-center gap-1.5"><i
|
||||||
data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
|
data-lucide="layout" class="w-3 h-3"></i>画面比例</label>
|
||||||
@ -481,4 +481,4 @@
|
|||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@ -191,7 +191,7 @@
|
|||||||
|
|
||||||
<!-- 比例和数量 -->
|
<!-- 比例和数量 -->
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
<div class="space-y-2">
|
<div id="ratioGroup" class="space-y-2">
|
||||||
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
|
<label class="text-xs font-bold text-slate-500 uppercase tracking-wider flex items-center gap-2">
|
||||||
<i data-lucide="layout" class="w-4 h-4"></i>画面比例
|
<i data-lucide="layout" class="w-4 h-4"></i>画面比例
|
||||||
</label>
|
</label>
|
||||||
@ -424,7 +424,7 @@
|
|||||||
fillSelect('ratioSelect', config.ratios);
|
fillSelect('ratioSelect', config.ratios);
|
||||||
|
|
||||||
// 填充尺寸选择
|
// 填充尺寸选择
|
||||||
fillSelect('sizeSelect', config.sizes);
|
defaultSizeOptions = config.sizes || [];
|
||||||
|
|
||||||
// 加载用户收藏的提示词
|
// 加载用户收藏的提示词
|
||||||
const userPrompts = await loadUserPrompts();
|
const userPrompts = await loadUserPrompts();
|
||||||
@ -449,14 +449,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 尺寸模型常量 (与PC端一致)
|
// 尺寸模型常量 (与PC端一致)
|
||||||
const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview'];
|
const GPT_IMAGE_EDIT_MODEL = 'gpt-image-2';
|
||||||
|
const GPT_IMAGE_EDIT_SIZES = [
|
||||||
|
{ label: '1024x1024 (square)', value: '1024x1024' },
|
||||||
|
{ label: '1536x1024 (landscape)', value: '1536x1024' },
|
||||||
|
{ label: '1024x1536 (portrait)', value: '1024x1536' },
|
||||||
|
{ label: '2048x2048 (2K square)', value: '2048x2048' },
|
||||||
|
{ label: '2048x1152 (2K landscape)', value: '2048x1152' },
|
||||||
|
{ label: '3840x2160 (4K landscape)', value: '3840x2160' },
|
||||||
|
{ label: '2160x3840 (4K portrait)', value: '2160x3840' },
|
||||||
|
{ label: 'auto (default)', value: 'auto' }
|
||||||
|
];
|
||||||
|
const SIZE_MODELS = ['nano-banana-2', 'gemini-3.1-flash-image-preview', GPT_IMAGE_EDIT_MODEL];
|
||||||
|
let defaultSizeOptions = [];
|
||||||
|
|
||||||
// 更新尺寸选择器显示状态
|
// 更新尺寸选择器显示状态
|
||||||
function updateSizeVisibility() {
|
function updateSizeVisibility() {
|
||||||
const modelSelect = document.getElementById('modelSelect');
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
const sizeGroup = document.getElementById('sizeGroup');
|
const sizeGroup = document.getElementById('sizeGroup');
|
||||||
if (modelSelect && sizeGroup) {
|
const sizeSelect = document.getElementById('sizeSelect');
|
||||||
sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
|
const ratioGroup = document.getElementById('ratioGroup');
|
||||||
|
if (modelSelect && sizeGroup && sizeSelect) {
|
||||||
|
const selectedModel = modelSelect.value;
|
||||||
|
const previousValue = sizeSelect.value;
|
||||||
|
const nextSizes = selectedModel === GPT_IMAGE_EDIT_MODEL ? GPT_IMAGE_EDIT_SIZES : defaultSizeOptions;
|
||||||
|
fillSelect('sizeSelect', nextSizes);
|
||||||
|
if (selectedModel === GPT_IMAGE_EDIT_MODEL) {
|
||||||
|
sizeSelect.value = '2160x3840';
|
||||||
|
} else if (nextSizes.some(item => item.value === previousValue)) {
|
||||||
|
sizeSelect.value = previousValue;
|
||||||
|
}
|
||||||
|
sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(selectedModel));
|
||||||
|
if (ratioGroup) {
|
||||||
|
ratioGroup.classList.toggle('hidden', selectedModel === GPT_IMAGE_EDIT_MODEL);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,6 +653,7 @@
|
|||||||
|
|
||||||
const prompt = document.getElementById('manualPrompt').value.trim();
|
const prompt = document.getElementById('manualPrompt').value.trim();
|
||||||
if (!prompt) {
|
if (!prompt) {
|
||||||
|
showToast('提示词不能为空', 'warning');
|
||||||
showToast('请输入提示词', 'warning');
|
showToast('请输入提示词', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -637,8 +664,14 @@
|
|||||||
const num = parseInt(document.getElementById('numSelect').value);
|
const num = parseInt(document.getElementById('numSelect').value);
|
||||||
const isPremium = document.getElementById('isPremium').checked;
|
const isPremium = document.getElementById('isPremium').checked;
|
||||||
|
|
||||||
|
if (model === GPT_IMAGE_EDIT_MODEL && uploadedFiles.length === 0) {
|
||||||
|
showToast('gpt-image-2 模型必须上传参考图', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// 显示加载状态
|
// 显示加载状态
|
||||||
document.getElementById('placeholder').classList.add('hidden');
|
document.getElementById('placeholder').classList.add('hidden');
|
||||||
|
document.getElementById('placeholder').classList.add('hidden');
|
||||||
document.getElementById('resultGrid').classList.add('hidden');
|
document.getElementById('resultGrid').classList.add('hidden');
|
||||||
document.getElementById('loadingState').classList.remove('hidden');
|
document.getElementById('loadingState').classList.remove('hidden');
|
||||||
|
|
||||||
@ -674,7 +707,7 @@
|
|||||||
is_premium: isPremium,
|
is_premium: isPremium,
|
||||||
prompt,
|
prompt,
|
||||||
model,
|
model,
|
||||||
ratio,
|
ratio: model === GPT_IMAGE_EDIT_MODEL ? undefined : ratio,
|
||||||
size,
|
size,
|
||||||
image_data: imageData.length > 0 ? imageData : undefined
|
image_data: imageData.length > 0 ? imageData : undefined
|
||||||
};
|
};
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user