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
import json
# Import Services
from services.system_service import get_system_config_data, get_user_latest_notification, mark_notification_as_read
from services.system_service import (
get_system_config_data,
get_user_latest_notification,
mark_notification_as_read,
)
from services.history_service import get_user_history_data
from services.file_service import handle_file_uploads, get_remote_file_stream
from services.generation_service import (
validate_generation_request, deduct_points, handle_chat_generation_sync,
start_async_image_task, validate_video_request, start_async_video_task
validate_generation_request,
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
def get_task_status(task_id):
"""查询异步任务状态"""
try:
data = redis_client.get(f"task:{task_id}")
if not data:
return jsonify({"status": "pending"})
if isinstance(data, bytes):
data = data.decode('utf-8')
data = data.decode("utf-8")
return jsonify(json.loads(data))
except Exception as e:
system_logger.error(f"查询任务状态异常: {str(e)}")
return jsonify({"status": "error", "message": "状态查询失败"})
@api_bp.route('/api/config')
@api_bp.route("/api/config")
def get_config():
"""从本地数据库字典获取配置"""
try:
return jsonify(get_system_config_data())
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/upload', methods=['POST'])
@api_bp.route("/api/upload", methods=["POST"])
@login_required
def upload():
try:
files = request.files.getlist('images')
files = request.files.getlist("images")
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})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/generate', methods=['POST'])
@api_bp.route("/api/generate", methods=["POST"])
@login_required
def generate():
try:
user_id = session.get('user_id')
user_id = session.get("user_id")
user = db.session.get(User, user_id)
data = request.json if request.is_json else request.form
# 1. 验证请求与权限
api_key, target_api, cost, use_trial, error = validate_generation_request(user, data)
if error:
return jsonify({"error": error}), 400
# 2. 扣除积分 (如果是试用模式)
if use_trial:
deduct_points(user_id, cost)
model_value = data.get('model')
prompt = data.get('prompt')
model_value = data.get("model")
prompt = (data.get("prompt") or "").strip()
if not prompt:
return jsonify({"error": "提示词不能为空"}), 400
model_lower = model_value.lower()
is_chat_model = ("gemini" in model_lower or "gpt" in model_lower) and "image" not in model_lower
# 3. 处理聊天模型 (同步)
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
# 4. 构造生图 Payload
payload = {
"prompt": prompt,
"model": model_value,
"response_format": "url",
"aspect_ratio": data.get('ratio')
}
image_data = data.get('image_data', [])
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')
image_data = data.get("image_data", [])
if model_value == "gpt-image-2":
if not image_data:
return jsonify({"error": "gpt-image-2 编辑模式至少需要上传 1 张参考图"}), 400
payload = {
"prompt": prompt,
"model": model_value,
"images": [{"image_url": img} for img in image_data],
"size": data.get("size") or "2160x3840",
"quality": "high",
"output_format": "png",
}
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()
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({
"task_id": task_id,
"message": "已开启异步生成任务"
"message": "已开启异步生成任务",
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/video/generate', methods=['POST'])
@api_bp.route("/api/video/generate", methods=["POST"])
@login_required
def video_generate():
try:
user_id = session.get('user_id')
user_id = session.get("user_id")
user = db.session.get(User, user_id)
data = request.json
# 1. 验证请求
model_value, cost, error = validate_video_request(user, data)
if error:
return jsonify({"error": error}), 400
# 2. 扣除积分
deduct_points(user_id, cost)
# 3. 构造 Payload
payload = {
"model": model_value,
"prompt": data.get('prompt'),
"enhance_prompt": data.get('enhance_prompt', False),
"images": data.get('images', []),
"aspect_ratio": data.get('aspect_ratio', '9:16')
"prompt": data.get("prompt"),
"enhance_prompt": data.get("enhance_prompt", False),
"images": data.get("images", []),
"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()
task_id = start_async_video_task(app, user_id, payload, cost, model_value)
return jsonify({
"task_id": task_id,
"message": "视频生成任务已提交,系统正在导演中..."
"message": "视频生成任务已提交,系统正在处理中",
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/notifications/latest', methods=['GET'])
@api_bp.route("/api/notifications/latest", methods=["GET"])
@login_required
def get_latest_notification():
try:
user_id = session.get('user_id')
user_id = session.get("user_id")
data = get_user_latest_notification(user_id)
return jsonify(data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/notifications/read', methods=['POST'])
@api_bp.route("/api/notifications/read", methods=["POST"])
@login_required
def mark_notif_read():
try:
user_id = session.get('user_id')
user_id = session.get("user_id")
data = request.json
notif_id = data.get('id')
notif_id = data.get("id")
if not notif_id:
return jsonify({"error": "缺少通知 ID"}), 400
mark_notification_as_read(user_id, notif_id)
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/history', methods=['GET'])
@api_bp.route("/api/history", methods=["GET"])
@login_required
def get_history():
try:
user_id = session.get('user_id')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
filter_type = request.args.get('filter_type', 'all')
user_id = session.get("user_id")
page = request.args.get("page", 1, type=int)
per_page = request.args.get("per_page", 10, type=int)
filter_type = request.args.get("filter_type", "all")
data = get_user_history_data(user_id, page, per_page, filter_type)
return jsonify(data)
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/stats/points', methods=['GET'])
@api_bp.route("/api/stats/points", methods=["GET"])
@login_required
def get_user_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))
@api_bp.route('/api/stats/details', methods=['GET'])
@api_bp.route("/api/stats/details", methods=["GET"])
@login_required
def get_user_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))
@api_bp.route('/api/download_proxy', methods=['GET'])
@api_bp.route("/api/download_proxy", methods=["GET"])
@login_required
def download_proxy():
import time
url = request.args.get('url')
# 默认文件名逻辑
url = request.args.get("url")
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:
return jsonify({"error": "缺少 URL 参数"}), 400
try:
req, headers = get_remote_file_stream(url)
headers['Content-Disposition'] = f'attachment; filename="{filename}"'
def generate():
headers["Content-Disposition"] = f'attachment; filename="{filename}"'
def generate_stream():
for chunk in req.iter_content(chunk_size=4096):
yield chunk
return current_app.response_class(generate(), headers=headers)
return current_app.response_class(generate_stream(), headers=headers)
except Exception as e:
system_logger.error(f"代理下载失败: {str(e)}")
return jsonify({"error": "下载失败"}), 500
@api_bp.route('/api/prompts', methods=['GET'])
@api_bp.route("/api/prompts", methods=["GET"])
@login_required
def list_prompts():
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()
return jsonify([{
"id": p.id,
"label": p.title, # Use label/value structure for frontend select
"value": p.prompt
} for p in prompts])
return jsonify([
{
"id": p.id,
"label": p.title,
"value": p.prompt,
}
for p in prompts
])
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/prompts', methods=['POST'])
@api_bp.route("/api/prompts", methods=["POST"])
@login_required
def save_prompt():
try:
user_id = session.get('user_id')
user_id = session.get("user_id")
data = request.json
title = data.get('title')
prompt_text = data.get('prompt')
title = data.get("title")
prompt_text = data.get("prompt")
if not title or not prompt_text:
return jsonify({"error": "标题和内容不能为空"}), 400
# Limit to 50 saved prompts
count = SavedPrompt.query.filter_by(user_id=user_id).count()
if count >= 50:
return jsonify({"error": "收藏数量已达上限 (50)"}), 400
p = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text)
db.session.add(p)
prompt = SavedPrompt(user_id=user_id, title=title, prompt=prompt_text)
db.session.add(prompt)
db.session.commit()
return jsonify({"message": "保存成功", "id": p.id})
return jsonify({"message": "保存成功", "id": prompt.id})
except Exception as e:
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
def delete_prompt(id):
try:
user_id = session.get('user_id')
p = SavedPrompt.query.filter_by(id=id, user_id=user_id).first()
if p:
db.session.delete(p)
user_id = session.get("user_id")
prompt = SavedPrompt.query.filter_by(id=id, user_id=user_id).first()
if prompt:
db.session.delete(prompt)
db.session.commit()
return jsonify({"message": "删除成功"})
except Exception as e:

View File

@ -37,6 +37,8 @@ class Config:
CHAT_API = f"{AI_BASE_URL}/v1/chat/completions"
VIDEO_GEN_API = f"{AI_BASE_URL}/v2/videos/generations"
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"

View File

@ -1,100 +1,114 @@
import json
import threading
import uuid
import requests
from config import Config
from models import SystemDict, GenerationRecord, User, db
from extensions import redis_client
from extensions import db, redis_client
from models import GenerationRecord, SystemDict, User
from services.logger import system_logger
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
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()
if model_dict:
return model_dict.cost
# Default costs
# 默认计费兜底
if is_video:
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):
"""验证生图请求并返回配置 (api_key, target_api, cost, use_trial)"""
mode = data.get('mode', 'trial')
is_premium = data.get('is_premium', False)
input_key = data.get('apiKey')
model_value = data.get('model')
is_gemini_flash_image_preview = model_value == 'gemini-3.1-flash-image-preview'
"""校验生图请求并返回 (api_key, target_api, cost, use_trial, error)。"""
mode = data.get("mode", "trial")
is_premium = data.get("is_premium", False)
input_key = data.get("apiKey")
model_value = data.get("model")
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):
is_premium = is_premium.lower() in ('1', 'true', 'yes', 'on')
target_api = Config.AI_API
is_premium = is_premium.lower() in ("1", "true", "yes", "on")
# gpt-image-2 走独立的 edits 接口,其余模型沿用现有 generations 接口
target_api = Config.GPT_IMAGE_EDIT_API if is_gpt_image_edit else Config.AI_API
api_key = None
use_trial = False
if mode == 'key':
if mode == "key":
api_key = input_key or user.api_key
if not api_key:
return None, None, 0, False, "请先输入您的 API 密钥"
# Update user key if changed
# 用户手动输入了新 key 时,同步保存到账号
if input_key and input_key != user.api_key:
user.api_key = input_key
db.session.commit()
else:
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:
if user.points <= 0:
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)
if use_trial and is_premium:
cost *= 2
if use_trial:
if user.points < cost:
return None, None, cost, True, f"可用积分不足(本次需要 {cost} 积分)"
if use_trial and user.points < cost:
return None, None, cost, True, f"可用积分不足,本次需要 {cost} 积分"
return api_key, target_api, cost, use_trial, None
def deduct_points(user_id, cost):
"""原子扣除积分"""
"""原子扣减积分。"""
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
if user:
user.points -= cost
user.has_used_points = True
db.session.commit()
def refund_points(user_id, cost):
"""原子退还积分"""
"""原子退回积分。"""
try:
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
if user:
user.points += cost
db.session.commit()
except:
except Exception:
db.session.rollback()
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
"""同步处理对话类模型"""
"""同步处理聊天类模型。"""
chat_payload = {
"model": model_value,
"messages": [{"role": "user", "content": prompt}]
"messages": [{"role": "user", "content": prompt}],
}
try:
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)
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:
resp = requests.post(
get_proxied_url(candidate['url']),
get_proxied_url(candidate["url"]),
json=chat_payload,
headers=headers,
timeout=Config.PROXY_TIMEOUT_LONG
timeout=Config.PROXY_TIMEOUT_LONG,
)
if resp.status_code == 200:
break
@ -120,7 +137,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
"聊天主线路失败,切换备用线路",
user_id=user_id,
model=model_value,
status_code=resp.status_code
status_code=resp.status_code,
)
continue
break
@ -131,7 +148,7 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
"聊天请求异常,切换备用线路",
user_id=user_id,
model=model_value,
error=last_error
error=last_error,
)
continue
raise
@ -140,72 +157,83 @@ def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial
if use_trial:
refund_points(user_id, cost)
return {"error": last_error or "聊天请求失败"}, resp.status_code if resp else 500
api_result = resp.json()
content = api_result['choices'][0]['message']['content']
# 记录聊天历史
content = api_result["choices"][0]["message"]["content"]
# 非验光单解读的文本生成写入历史
if prompt != "解读验光单":
new_record = GenerationRecord(
user_id=user_id,
prompt=prompt,
model=model_value,
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.commit()
return {
"data": [{"content": content, "type": "text"}],
"message": "生成成功"
"message": "生成成功",
}, 200
except Exception as e:
if use_trial:
refund_points(user_id, cost)
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):
"""启动异步生图任务"""
"""启动异步生图任务"""
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)
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(
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()
return task_id
def validate_video_request(user, data):
"""视频生成请求"""
"""验视频生成请求"""
if user.points <= 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)
if user.points < cost:
return None, cost, f"积分不足,生成该视频需要 {cost} 积分"
return model_value, cost, None
def start_async_video_task(app, user_id, payload, cost, model_value):
"""启动异步视频任务"""
"""启动异步视频任务"""
api_key = Config.VIDEO_KEY
task_id = str(uuid.uuid4())
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(
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()
return task_id

View File

@ -5,6 +5,7 @@ import requests
import io
import time
import base64
import binascii
import threading
from urllib.parse import quote
from requests.exceptions import ConnectionError, ConnectTimeout
@ -46,6 +47,171 @@ def _extract_error_detail(data, 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):
"""后台同步图片至 MinIO并生成缩略图带重试机制"""
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": "任务已提交,正在排队处理..."}))
try:
# 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
last_error = None
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) => {
document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(e.target.value));
function updateSizeOptions() {
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(); // 切换模型时更新计费预览
};
@ -490,7 +526,7 @@ document.getElementById('promptTpl').onchange = (e) => {
document.getElementById('submitBtn').onclick = async () => {
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 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');
}
// 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;
const btnText = btn.querySelector('span');
btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎...";
@ -597,7 +646,7 @@ document.getElementById('submitBtn').onclick = async () => {
apiKey: currentMode === 'key' ? apiKey : '',
prompt: document.getElementById('manualPrompt').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,
image_data // 发送 Base64 数组
})
@ -787,7 +836,7 @@ window.addEventListener('message', (e) => {
if (modelSelect) {
modelSelect.value = 'nano-banana-2';
modelSelect.disabled = true; // 锁定选择
sizeGroup.classList.remove('hidden');
updateSizeOptions();
updateCostPreview();
}
@ -1036,12 +1085,13 @@ async function refreshPromptsList() {
fillSelect('modelSelect', d.models);
fillSelect('ratioSelect', d.ratios);
fillSelect('sizeSelect', d.sizes);
defaultSizeOptions = d.sizes || [];
updateSizeOptions();
// 初始化后检查默认模型是否需要显示尺寸选择器
const modelSelect = document.getElementById('modelSelect');
if (modelSelect && typeof SIZE_MODELS !== 'undefined') {
document.getElementById('sizeGroup').classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
updateSizeOptions();
}
const userPrompts = await loadUserPrompts();

View File

@ -81,7 +81,7 @@
<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>
</div>
<div class="space-y-1.5">
<div id="ratioGroup" class="space-y-1.5">
<label
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>
@ -481,4 +481,4 @@
{% block scripts %}
<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="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">
<i data-lucide="layout" class="w-4 h-4"></i>画面比例
</label>
@ -424,7 +424,7 @@
fillSelect('ratioSelect', config.ratios);
// 填充尺寸选择
fillSelect('sizeSelect', config.sizes);
defaultSizeOptions = config.sizes || [];
// 加载用户收藏的提示词
const userPrompts = await loadUserPrompts();
@ -449,14 +449,40 @@
}
// 尺寸模型常量 (与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() {
const modelSelect = document.getElementById('modelSelect');
const sizeGroup = document.getElementById('sizeGroup');
if (modelSelect && sizeGroup) {
sizeGroup.classList.toggle('hidden', !SIZE_MODELS.includes(modelSelect.value));
const sizeSelect = document.getElementById('sizeSelect');
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();
if (!prompt) {
showToast('提示词不能为空', 'warning');
showToast('请输入提示词', 'warning');
return;
}
@ -637,8 +664,14 @@
const num = parseInt(document.getElementById('numSelect').value);
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('resultGrid').classList.add('hidden');
document.getElementById('loadingState').classList.remove('hidden');
@ -674,7 +707,7 @@
is_premium: isPremium,
prompt,
model,
ratio,
ratio: model === GPT_IMAGE_EDIT_MODEL ? undefined : ratio,
size,
image_data: imageData.length > 0 ? imageData : undefined
};