新增app/ai.py,按config/ai_models.json读取默认文本和图片模型,提供gen_title与gen_cover通用HTTP接口。 支持chat JSON与images_edits multipart、失败重试、错误脱敏、图片URL/base64解析、resolution resize和jpg_quality保存。 新增tests/test_ai.py覆盖文本重试、缺字段错误不泄露Key、封面JPEG保存;同步任务看板、API、技术栈、架构、current-state和progress,并记录Tab①真机冒烟结果。
417 lines
13 KiB
Python
417 lines
13 KiB
Python
"""AI generation helpers backed by configurable HTTP model endpoints."""
|
|
|
|
import base64
|
|
import copy
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
import uuid
|
|
|
|
from . import appconfig
|
|
|
|
|
|
class AIError(RuntimeError):
|
|
"""Raised when AI generation cannot complete."""
|
|
|
|
|
|
_RESOLUTION_SIZES = {
|
|
"512": (512, 512),
|
|
"1k": (1024, 1024),
|
|
"2k": (2048, 2048),
|
|
"4k": (4096, 4096),
|
|
}
|
|
|
|
|
|
def gen_title(
|
|
title_prompt,
|
|
old_title,
|
|
retry=None,
|
|
config=None,
|
|
models_path=appconfig.AI_MODELS_PATH,
|
|
):
|
|
"""Generate a new product title from a prompt and the old title."""
|
|
|
|
cfg = appconfig.load_config() if config is None else config
|
|
ai_cfg = appconfig.ai_config(cfg)
|
|
model = _role_model("text", ai_cfg.get("default_text_model"), models_path)
|
|
payload = _chat_payload(
|
|
model,
|
|
[
|
|
{"role": "system", "content": str(title_prompt or "").strip()},
|
|
{
|
|
"role": "user",
|
|
"content": "旧标题:\n%s\n\n请只返回新标题,不要解释。" % str(old_title or ""),
|
|
},
|
|
],
|
|
)
|
|
attempts = _attempt_count(ai_cfg, retry)
|
|
data = _call_with_retry(
|
|
model,
|
|
payload,
|
|
cfg,
|
|
attempts,
|
|
request_kind="json",
|
|
)
|
|
text = _extract_text(data).strip()
|
|
if not text:
|
|
raise AIError("AI 返回为空标题")
|
|
return text
|
|
|
|
|
|
def gen_cover(
|
|
cover_prompt,
|
|
old_cover_path,
|
|
out_path,
|
|
resolution=None,
|
|
jpg_quality=None,
|
|
retry=None,
|
|
config=None,
|
|
models_path=appconfig.AI_MODELS_PATH,
|
|
):
|
|
"""Generate a new cover image and save it as a JPEG file."""
|
|
|
|
old_cover_path = os.path.abspath(str(old_cover_path))
|
|
if not os.path.exists(old_cover_path):
|
|
raise FileNotFoundError("旧封面图片不存在: %s" % old_cover_path)
|
|
if not out_path:
|
|
raise AIError("缺少新封面输出路径")
|
|
|
|
cfg = appconfig.load_config() if config is None else config
|
|
ai_cfg = appconfig.ai_config(cfg)
|
|
model = _role_model("image", ai_cfg.get("default_image_model"), models_path)
|
|
resolution = str(resolution or ai_cfg.get("resolution", "1k"))
|
|
quality = _jpg_quality(jpg_quality if jpg_quality is not None else ai_cfg.get("jpg_quality", 90))
|
|
attempts = _attempt_count(ai_cfg, retry)
|
|
|
|
api_type = model.get("api_type", "auto")
|
|
if api_type == "images_edits":
|
|
body, content_type = _image_edit_body(model, cover_prompt, old_cover_path, resolution)
|
|
data = _call_with_retry(
|
|
model,
|
|
body,
|
|
cfg,
|
|
attempts,
|
|
request_kind="multipart",
|
|
content_type=content_type,
|
|
)
|
|
else:
|
|
payload = _image_chat_payload(model, cover_prompt, old_cover_path, resolution)
|
|
data = _call_with_retry(model, payload, cfg, attempts, request_kind="json")
|
|
|
|
image_bytes = _extract_image_bytes(data, model, cfg)
|
|
return _save_jpeg(image_bytes, out_path, resolution, quality)
|
|
|
|
|
|
def _role_model(category, name, models_path):
|
|
if not name:
|
|
raise AIError("未配置默认 %s 模型" % category)
|
|
model = appconfig.get_model(name, path=models_path)
|
|
if model.get("category") != category:
|
|
raise AIError("模型 %s 不是 %s 类别" % (name, category))
|
|
if not model.get("enabled", True):
|
|
raise AIError("模型已禁用: %s" % name)
|
|
missing = [
|
|
field
|
|
for field in ("url", "model", "api_key")
|
|
if not str(model.get(field, "")).strip()
|
|
]
|
|
if missing:
|
|
raise AIError("模型 %s 缺少字段: %s" % (name, ", ".join(missing)))
|
|
return model
|
|
|
|
|
|
def _attempt_count(ai_cfg, retry):
|
|
retry_count = ai_cfg.get("retry", 2) if retry is None else retry
|
|
return max(1, int(retry_count) + 1)
|
|
|
|
|
|
def _read_timeout(model, config):
|
|
return int(model.get("timeout_seconds") or appconfig.response_timeout(config))
|
|
|
|
|
|
def _connect_timeout(model):
|
|
return int(model.get("connect_timeout_seconds") or 30)
|
|
|
|
|
|
def _headers(model, content_type):
|
|
return {
|
|
"Authorization": "Bearer " + model["api_key"],
|
|
"Content-Type": content_type,
|
|
}
|
|
|
|
|
|
def _call_with_retry(model, body, config, attempts, request_kind, content_type=None):
|
|
last_exc = None
|
|
for index in range(attempts):
|
|
try:
|
|
return _call_once(model, body, config, request_kind, content_type=content_type)
|
|
except Exception as exc:
|
|
last_exc = exc
|
|
if index + 1 >= attempts:
|
|
break
|
|
time.sleep(min(2.0, 0.4 * (index + 1)))
|
|
raise AIError(
|
|
"AI 调用失败(已尝试 %s 次): %s"
|
|
% (attempts, _redact(str(last_exc), model))
|
|
) from last_exc
|
|
|
|
|
|
def _call_once(model, body, config, request_kind, content_type=None):
|
|
if request_kind == "json":
|
|
data = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
|
content_type = "application/json"
|
|
else:
|
|
data = body
|
|
request = urllib.request.Request(
|
|
model["url"],
|
|
data=data,
|
|
headers=_headers(model, content_type),
|
|
method="POST",
|
|
)
|
|
timeout = max(_connect_timeout(model), _read_timeout(model, config))
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=timeout) as response:
|
|
raw = response.read()
|
|
except urllib.error.HTTPError as exc:
|
|
detail = _safe_http_error(exc, model)
|
|
raise AIError("HTTP %s: %s" % (exc.code, detail)) from exc
|
|
except urllib.error.URLError as exc:
|
|
raise AIError(_redact(str(exc.reason), model)) from exc
|
|
try:
|
|
return json.loads(raw.decode("utf-8"))
|
|
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
raise AIError("AI 返回不是有效 JSON") from exc
|
|
|
|
|
|
def _safe_http_error(exc, model):
|
|
try:
|
|
body = exc.read(2048).decode("utf-8", errors="replace")
|
|
except Exception:
|
|
body = ""
|
|
return _redact(body or str(exc), model)
|
|
|
|
|
|
def _chat_payload(model, messages):
|
|
payload = {
|
|
"model": model["model"],
|
|
"messages": messages,
|
|
}
|
|
payload.update(copy.deepcopy(model.get("extra_body", {})))
|
|
return payload
|
|
|
|
|
|
def _image_chat_payload(model, cover_prompt, old_cover_path, resolution):
|
|
prompt = "%s\n\n目标分辨率:%s。" % (str(cover_prompt or "").strip(), resolution)
|
|
content = [
|
|
{"type": "text", "text": prompt.strip()},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {"url": _image_data_url(old_cover_path)},
|
|
},
|
|
]
|
|
return _chat_payload(model, [{"role": "user", "content": content}])
|
|
|
|
|
|
def _image_edit_body(model, cover_prompt, old_cover_path, resolution):
|
|
fields = {
|
|
"model": model["model"],
|
|
"prompt": str(cover_prompt or ""),
|
|
"size": _resolution_size_text(resolution),
|
|
}
|
|
fields.update(copy.deepcopy(model.get("extra_body", {})))
|
|
files = {
|
|
"image": (
|
|
os.path.basename(old_cover_path),
|
|
open(old_cover_path, "rb").read(),
|
|
mimetypes.guess_type(old_cover_path)[0] or "application/octet-stream",
|
|
)
|
|
}
|
|
return _multipart_body(fields, files)
|
|
|
|
|
|
def _multipart_body(fields, files):
|
|
boundary = "----cmshopee-%s" % uuid.uuid4().hex
|
|
chunks = []
|
|
for name, value in fields.items():
|
|
chunks.extend(
|
|
[
|
|
("--%s\r\n" % boundary).encode("utf-8"),
|
|
('Content-Disposition: form-data; name="%s"\r\n\r\n' % name).encode("utf-8"),
|
|
str(value).encode("utf-8"),
|
|
b"\r\n",
|
|
]
|
|
)
|
|
for name, file_info in files.items():
|
|
filename, data, content_type = file_info
|
|
chunks.extend(
|
|
[
|
|
("--%s\r\n" % boundary).encode("utf-8"),
|
|
(
|
|
'Content-Disposition: form-data; name="%s"; filename="%s"\r\n'
|
|
% (name, filename)
|
|
).encode("utf-8"),
|
|
("Content-Type: %s\r\n\r\n" % content_type).encode("utf-8"),
|
|
data,
|
|
b"\r\n",
|
|
]
|
|
)
|
|
chunks.append(("--%s--\r\n" % boundary).encode("utf-8"))
|
|
return b"".join(chunks), "multipart/form-data; boundary=%s" % boundary
|
|
|
|
|
|
def _image_data_url(path):
|
|
mime = mimetypes.guess_type(path)[0] or "image/jpeg"
|
|
with open(path, "rb") as fh:
|
|
encoded = base64.b64encode(fh.read()).decode("ascii")
|
|
return "data:%s;base64,%s" % (mime, encoded)
|
|
|
|
|
|
def _extract_text(data):
|
|
if isinstance(data, dict):
|
|
for key in ("output_text", "text", "content"):
|
|
value = data.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
choices = data.get("choices")
|
|
if isinstance(choices, list) and choices:
|
|
first = choices[0]
|
|
if isinstance(first, dict):
|
|
if isinstance(first.get("text"), str):
|
|
return first["text"]
|
|
message = first.get("message") or {}
|
|
content = message.get("content")
|
|
return _content_text(content)
|
|
return ""
|
|
|
|
|
|
def _content_text(content):
|
|
if isinstance(content, str):
|
|
return content
|
|
if isinstance(content, list):
|
|
parts = []
|
|
for item in content:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
value = item.get("text") or item.get("content")
|
|
if isinstance(value, str):
|
|
parts.append(value)
|
|
return "".join(parts)
|
|
return ""
|
|
|
|
|
|
def _extract_image_bytes(data, model, config):
|
|
image_ref = _find_image_ref(data)
|
|
if not image_ref:
|
|
raise AIError("AI 返回中没有图片数据")
|
|
if image_ref.startswith("data:"):
|
|
return _decode_data_url(image_ref)
|
|
if _looks_base64(image_ref):
|
|
return base64.b64decode(image_ref)
|
|
return _download_image(image_ref, model, config)
|
|
|
|
|
|
def _find_image_ref(value):
|
|
if isinstance(value, dict):
|
|
for key in ("b64_json", "base64", "image_base64", "image", "url"):
|
|
candidate = value.get(key)
|
|
if isinstance(candidate, str) and candidate.strip():
|
|
return candidate.strip()
|
|
image_url = value.get("image_url")
|
|
if isinstance(image_url, str):
|
|
return image_url
|
|
if isinstance(image_url, dict):
|
|
candidate = image_url.get("url")
|
|
if isinstance(candidate, str):
|
|
return candidate
|
|
for key in ("data", "choices", "output", "content", "images"):
|
|
candidate = _find_image_ref(value.get(key))
|
|
if candidate:
|
|
return candidate
|
|
message = value.get("message")
|
|
if message is not None:
|
|
candidate = _find_image_ref(message)
|
|
if candidate:
|
|
return candidate
|
|
elif isinstance(value, list):
|
|
for item in value:
|
|
candidate = _find_image_ref(item)
|
|
if candidate:
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _decode_data_url(value):
|
|
if "," not in value:
|
|
raise AIError("图片 data URL 格式错误")
|
|
return base64.b64decode(value.split(",", 1)[1])
|
|
|
|
|
|
def _looks_base64(value):
|
|
compact = value.strip()
|
|
if compact.startswith(("http://", "https://")):
|
|
return False
|
|
if len(compact) < 32:
|
|
return False
|
|
allowed = set("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=\n\r")
|
|
return all(char in allowed for char in compact)
|
|
|
|
|
|
def _download_image(url, model, config):
|
|
request = urllib.request.Request(url, method="GET")
|
|
try:
|
|
with urllib.request.urlopen(request, timeout=_read_timeout(model, config)) as response:
|
|
return response.read()
|
|
except Exception as exc:
|
|
raise AIError("下载 AI 图片失败: %s" % _redact(str(exc), model)) from exc
|
|
|
|
|
|
def _save_jpeg(image_bytes, out_path, resolution, jpg_quality):
|
|
try:
|
|
from PIL import Image
|
|
except ImportError as exc:
|
|
raise AIError("缺少 Pillow,无法保存 AI 图片") from exc
|
|
|
|
import io
|
|
|
|
out_path = os.path.abspath(str(out_path))
|
|
directory = os.path.dirname(out_path)
|
|
if directory:
|
|
os.makedirs(directory, exist_ok=True)
|
|
size = _resolution_size(resolution)
|
|
try:
|
|
with Image.open(io.BytesIO(image_bytes)) as image:
|
|
image = image.convert("RGB")
|
|
if size:
|
|
image = image.resize(size, Image.LANCZOS)
|
|
image.save(out_path, "JPEG", quality=jpg_quality, optimize=True)
|
|
except Exception as exc:
|
|
raise AIError("AI 图片保存失败: %s" % exc) from exc
|
|
return out_path
|
|
|
|
|
|
def _resolution_size(resolution):
|
|
return _RESOLUTION_SIZES.get(str(resolution))
|
|
|
|
|
|
def _resolution_size_text(resolution):
|
|
size = _resolution_size(resolution)
|
|
if not size:
|
|
return str(resolution)
|
|
return "%sx%s" % size
|
|
|
|
|
|
def _jpg_quality(value):
|
|
value = int(value)
|
|
return min(100, max(1, value))
|
|
|
|
|
|
def _redact(text, model):
|
|
result = str(text)
|
|
for secret in (model.get("api_key"),):
|
|
if secret:
|
|
result = result.replace(str(secret), "***")
|
|
return result
|