feat(api): 新增gpt-image-2编辑模型支持

- 添加gpt-image-2模型的专门接口配置和API密钥
- 实现gpt-image-2编辑模式的完整处理流程,包括Base64图片解码、
  图片验证和MinIO上传功能
- 更新前端界面以支持gpt-image-2的特定参数(如尺寸选项)
- 修复prompt不能为空的校验逻辑
- 优化图片上传处理,支持编辑模式下的参考图必需校验
- 更新视频生成任务状态消息文案
```
This commit is contained in:
24024 2026-04-24 23:17:10 +08:00
parent fec3426bad
commit 0adabaa235
7 changed files with 529 additions and 215 deletions

View File

@ -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:

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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();

View File

@ -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 %}

View File

@ -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
}; };