2026-04-21 14:06:48 +08:00
|
|
|
|
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
后端接口(生产/测试环境):
|
|
|
|
|
|
POST {API_BASE_URL}{API_GENERATE_PATH}
|
|
|
|
|
|
body: {"user_message": str, "session_id": str, "upload_image_url"?: str}
|
|
|
|
|
|
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
|
|
|
|
|
POST {API_BASE_URL}{API_UPLOAD_PATH} # multipart/form-data, field: file
|
|
|
|
|
|
resp: {"code": 200, "message": "", "data": {"upload_image_url": "..."}}
|
|
|
|
|
|
|
|
|
|
|
|
带 ``upload_image_url`` 启用"图生图"模式;多轮对话需将上一轮返回的 imageUrl
|
|
|
|
|
|
作为下一轮请求的 upload_image_url(由 panel 通过会话级缓存自动维护)。
|
2026-04-29 15:25:58 +08:00
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import datetime as _dt
|
|
|
|
|
|
import hashlib
|
|
|
|
|
|
import json
|
2026-04-29 15:25:58 +08:00
|
|
|
|
import logging
|
2026-04-23 10:07:41 +08:00
|
|
|
|
import mimetypes
|
2026-04-21 14:06:48 +08:00
|
|
|
|
import os
|
2026-05-22 11:31:36 +08:00
|
|
|
|
import sys
|
2026-04-21 14:06:48 +08:00
|
|
|
|
import shutil
|
|
|
|
|
|
import threading
|
2026-04-29 15:25:58 +08:00
|
|
|
|
import time
|
|
|
|
|
|
import uuid
|
|
|
|
|
|
from io import BytesIO
|
2026-04-21 14:06:48 +08:00
|
|
|
|
from dataclasses import dataclass, asdict
|
|
|
|
|
|
from typing import Callable, List, Optional
|
2026-04-29 16:43:31 +08:00
|
|
|
|
from urllib.error import HTTPError
|
2026-04-23 10:07:41 +08:00
|
|
|
|
from urllib.parse import urlparse
|
|
|
|
|
|
from urllib.request import Request, urlopen
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
from PIL import Image
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------- 常量 ----------
|
|
|
|
|
|
|
|
|
|
|
|
_CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
|
|
|
|
|
|
_META_SUFFIX = ".json"
|
|
|
|
|
|
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
# 测试环境后端
|
2026-05-29 14:40:39 +08:00
|
|
|
|
# API_BASE_URL = "http://10.201.44.70:9008/ai-agent/"
|
2026-05-19 10:06:02 +08:00
|
|
|
|
API_BASE_URL = "https://rd-mokadisplay.tcl.com/ai-agent/"
|
2026-05-29 14:40:39 +08:00
|
|
|
|
API_GENERATE_PATH = "api/v1/pqtest/generate"
|
|
|
|
|
|
API_UPLOAD_PATH = "api/v1/pqtest/upload"
|
|
|
|
|
|
API_TIMEOUT = 300.0 # 后端最长 120s,留余量
|
|
|
|
|
|
API_UPLOAD_TIMEOUT = 60.0
|
|
|
|
|
|
|
|
|
|
|
|
# 上传接口限制(来自接口文档)
|
|
|
|
|
|
UPLOAD_MAX_BYTES = 10 * 1024 * 1024
|
|
|
|
|
|
UPLOAD_MAX_PIXELS = 4096
|
|
|
|
|
|
UPLOAD_ALLOWED_EXT = (".png", ".jpg", ".jpeg")
|
|
|
|
|
|
|
|
|
|
|
|
# 兼容旧名(如其他模块仍引用)
|
|
|
|
|
|
API_PATH = API_GENERATE_PATH
|
2026-04-29 15:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
# 进程级会话 id(多轮对话需保持一致),可通过 ``reset_session`` 重置
|
|
|
|
|
|
_session_id: str = str(uuid.uuid4())
|
|
|
|
|
|
_session_lock = threading.Lock()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_session_id() -> str:
|
|
|
|
|
|
with _session_lock:
|
|
|
|
|
|
return _session_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def set_session_id(session_id: str) -> str:
|
2026-05-19 10:06:02 +08:00
|
|
|
|
"""切换到指定会话。空值会抛错"""
|
2026-04-29 15:25:58 +08:00
|
|
|
|
global _session_id
|
|
|
|
|
|
sid = (session_id or "").strip()
|
|
|
|
|
|
if not sid:
|
|
|
|
|
|
raise ValueError("session_id 不能为空")
|
|
|
|
|
|
with _session_lock:
|
|
|
|
|
|
old = _session_id
|
|
|
|
|
|
_session_id = sid
|
|
|
|
|
|
return _session_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def reset_session() -> str:
|
|
|
|
|
|
"""开启新一轮会话,返回新的 session_id。"""
|
|
|
|
|
|
global _session_id
|
|
|
|
|
|
with _session_lock:
|
|
|
|
|
|
old = _session_id
|
|
|
|
|
|
_session_id = str(uuid.uuid4())
|
|
|
|
|
|
return _session_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mask_sid(sid: str) -> str:
|
|
|
|
|
|
"""日志安全展示:仅保留前 8 位。"""
|
|
|
|
|
|
if not sid:
|
|
|
|
|
|
return "(none)"
|
|
|
|
|
|
return f"{sid[:8]}…"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _truncate(text: str, n: int = 80) -> str:
|
|
|
|
|
|
s = (text or "").replace("\n", " ").strip()
|
|
|
|
|
|
return s if len(s) <= n else s[:n] + "…"
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
# ---------- 数据结构 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass
|
|
|
|
|
|
class AIImageRecord:
|
2026-04-29 15:25:58 +08:00
|
|
|
|
"""一条缓存记录。
|
|
|
|
|
|
|
|
|
|
|
|
字段说明:
|
|
|
|
|
|
- ``id``: 唯一 id,等同于磁盘文件名(不含扩展名),格式 ``{时间戳}_{md5前8}``。
|
|
|
|
|
|
- ``prompt``: 用户原始输入(完整保留,用于回溯/调试,不应被改写)。
|
|
|
|
|
|
- ``title``: 用户自定义展示标题(重命名时写入),UI 优先使用,留空则回退 prompt 第一行截断。
|
|
|
|
|
|
- ``image_path``: 图片在缓存目录中的绝对路径。
|
|
|
|
|
|
- ``created_at``: ISO8601 时间字符串。
|
|
|
|
|
|
- ``extra``: 其它元数据,至少包含 ``source`` 与 ``session_id``(标识属于哪一轮对话)。
|
|
|
|
|
|
"""
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
id: str
|
|
|
|
|
|
prompt: str
|
|
|
|
|
|
image_path: str
|
|
|
|
|
|
created_at: str # ISO8601
|
|
|
|
|
|
extra: Optional[dict] = None
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title: Optional[str] = None
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
def to_json(self) -> str:
|
|
|
|
|
|
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def display_name(self) -> str:
|
|
|
|
|
|
"""UI 展示名:title 优先,否则回退 prompt 第一行。"""
|
|
|
|
|
|
if self.title:
|
|
|
|
|
|
return self.title.strip()
|
|
|
|
|
|
first = (self.prompt or "").strip().splitlines()[0] if self.prompt else ""
|
|
|
|
|
|
return first or "(未命名)"
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
@property
|
|
|
|
|
|
def session_id(self) -> str:
|
|
|
|
|
|
if isinstance(self.extra, dict):
|
|
|
|
|
|
return str(self.extra.get("session_id") or "")
|
|
|
|
|
|
return ""
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
# ---------- 后端 API ----------
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
def _api_endpoint(path: str = API_GENERATE_PATH) -> str:
|
2026-04-29 15:25:58 +08:00
|
|
|
|
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
2026-05-29 14:40:39 +08:00
|
|
|
|
return base + path.lstrip("/")
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 19:10:27 +08:00
|
|
|
|
def _pretty_json_text(value) -> str:
|
|
|
|
|
|
"""把对象或 JSON 字符串格式化为易读文本;失败则回退原始字符串。"""
|
|
|
|
|
|
try:
|
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
|
return json.dumps(value, ensure_ascii=False, indent=2)
|
|
|
|
|
|
text = "" if value is None else str(value)
|
|
|
|
|
|
parsed = json.loads(text)
|
|
|
|
|
|
return json.dumps(parsed, ensure_ascii=False, indent=2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return "" if value is None else str(value)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
def _call_pqtest_generate(
|
|
|
|
|
|
user_message: str,
|
|
|
|
|
|
session_id: str,
|
|
|
|
|
|
upload_image_url: Optional[str] = None,
|
|
|
|
|
|
timeout: float = API_TIMEOUT,
|
|
|
|
|
|
) -> str:
|
|
|
|
|
|
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。
|
|
|
|
|
|
|
|
|
|
|
|
``upload_image_url`` 传入时启用"图生图"模式。
|
|
|
|
|
|
"""
|
|
|
|
|
|
body: dict = {"user_message": user_message, "session_id": session_id}
|
|
|
|
|
|
if upload_image_url:
|
|
|
|
|
|
body["upload_image_url"] = upload_image_url
|
|
|
|
|
|
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
2026-04-29 16:43:31 +08:00
|
|
|
|
request_headers = {
|
|
|
|
|
|
"Content-Type": "application/json; charset=utf-8",
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
"User-Agent": "pqAutomationApp/1.0",
|
|
|
|
|
|
}
|
2026-05-29 14:40:39 +08:00
|
|
|
|
endpoint = _api_endpoint(API_GENERATE_PATH)
|
2026-04-29 15:25:58 +08:00
|
|
|
|
logger.info(
|
2026-05-29 14:40:39 +08:00
|
|
|
|
"[AIImage] 请求生成 sid=%s mode=%s prompt_len=%d prompt=%r ref=%s",
|
|
|
|
|
|
_mask_sid(session_id),
|
|
|
|
|
|
"img2img" if upload_image_url else "txt2img",
|
|
|
|
|
|
len(user_message or ""),
|
|
|
|
|
|
_truncate(user_message),
|
|
|
|
|
|
upload_image_url or "-",
|
2026-04-29 15:25:58 +08:00
|
|
|
|
)
|
2026-04-29 16:43:31 +08:00
|
|
|
|
logger.info(
|
2026-04-29 19:10:27 +08:00
|
|
|
|
"[AIImage][REQUEST]\nendpoint=%s\nmethod=POST\ntimeout=%.1fs\nheaders=%s\nbody=%s",
|
2026-04-29 16:43:31 +08:00
|
|
|
|
endpoint,
|
|
|
|
|
|
timeout,
|
2026-04-29 19:10:27 +08:00
|
|
|
|
_pretty_json_text(request_headers),
|
|
|
|
|
|
_pretty_json_text(payload.decode("utf-8", errors="replace")),
|
2026-04-29 16:43:31 +08:00
|
|
|
|
)
|
2026-04-29 15:25:58 +08:00
|
|
|
|
request = Request(
|
|
|
|
|
|
endpoint,
|
|
|
|
|
|
data=payload,
|
|
|
|
|
|
method="POST",
|
2026-04-29 16:43:31 +08:00
|
|
|
|
headers=request_headers,
|
2026-04-29 15:25:58 +08:00
|
|
|
|
)
|
|
|
|
|
|
t0 = time.monotonic()
|
|
|
|
|
|
try:
|
|
|
|
|
|
with urlopen(request, timeout=timeout) as response:
|
|
|
|
|
|
raw = response.read()
|
|
|
|
|
|
http_status = response.status
|
2026-04-29 16:43:31 +08:00
|
|
|
|
response_headers = dict(response.headers.items())
|
|
|
|
|
|
raw_text = raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
logger.info(
|
2026-04-29 19:10:27 +08:00
|
|
|
|
"[AIImage][RESPONSE]\nstatus=%s\nheaders=%s\nbody=%s",
|
2026-04-29 16:43:31 +08:00
|
|
|
|
http_status,
|
2026-04-29 19:10:27 +08:00
|
|
|
|
_pretty_json_text(response_headers),
|
|
|
|
|
|
_pretty_json_text(raw_text),
|
2026-04-29 16:43:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
|
elapsed = time.monotonic() - t0
|
|
|
|
|
|
err_raw = b""
|
|
|
|
|
|
try:
|
|
|
|
|
|
err_raw = exc.read() or b""
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
err_raw = b""
|
|
|
|
|
|
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
|
|
|
|
|
|
err_headers = {}
|
|
|
|
|
|
try:
|
|
|
|
|
|
if exc.headers is not None:
|
|
|
|
|
|
err_headers = dict(exc.headers.items())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
err_headers = {}
|
|
|
|
|
|
logger.error(
|
2026-04-29 19:10:27 +08:00
|
|
|
|
"[AIImage][RESPONSE_ERROR] sid=%s elapsed=%.2fs status=%s reason=%s\nheaders=%s\nbody=%s",
|
2026-04-29 16:43:31 +08:00
|
|
|
|
_mask_sid(session_id),
|
|
|
|
|
|
elapsed,
|
|
|
|
|
|
getattr(exc, "code", "?"),
|
|
|
|
|
|
str(exc),
|
2026-04-29 19:10:27 +08:00
|
|
|
|
_pretty_json_text(err_headers),
|
|
|
|
|
|
_pretty_json_text(err_text),
|
2026-04-29 16:43:31 +08:00
|
|
|
|
)
|
|
|
|
|
|
raise
|
2026-04-29 15:25:58 +08:00
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
elapsed = time.monotonic() - t0
|
|
|
|
|
|
logger.error(
|
|
|
|
|
|
"[AIImage] 请求异常 sid=%s elapsed=%.2fs %s: %s",
|
|
|
|
|
|
_mask_sid(session_id), elapsed, type(exc).__name__, exc,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise
|
|
|
|
|
|
elapsed = time.monotonic() - t0
|
2026-04-29 16:43:31 +08:00
|
|
|
|
logger.info("[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs", http_status, len(raw), elapsed)
|
2026-04-29 15:25:58 +08:00
|
|
|
|
try:
|
|
|
|
|
|
result = json.loads(raw.decode("utf-8"))
|
|
|
|
|
|
except Exception as exc:
|
2026-04-29 16:43:31 +08:00
|
|
|
|
raw_text = raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
logger.error("[AIImage] 响应解析失败 sid=%s raw=%s", _mask_sid(session_id), raw_text)
|
|
|
|
|
|
raise RuntimeError(f"AI 接口返回非 JSON:{raw_text}") from exc
|
2026-04-29 15:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
code = result.get("code")
|
|
|
|
|
|
message = result.get("message") or ""
|
|
|
|
|
|
data = result.get("data") or {}
|
|
|
|
|
|
image_url = (data.get("imageUrl") or "").strip()
|
|
|
|
|
|
if code != 200 or not image_url:
|
|
|
|
|
|
logger.warning(
|
|
|
|
|
|
"[AIImage] 接口失败 sid=%s code=%s msg=%r",
|
|
|
|
|
|
_mask_sid(session_id), code, message,
|
|
|
|
|
|
)
|
|
|
|
|
|
raise RuntimeError(f"AI 接口失败 code={code} msg={message or '生成失败'}")
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AIImage] 生成成功 sid=%s elapsed=%.2fs url=%s",
|
|
|
|
|
|
_mask_sid(session_id), elapsed, image_url,
|
|
|
|
|
|
)
|
|
|
|
|
|
return image_url
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
def _call_pqtest_upload(file_path: str, timeout: float = API_UPLOAD_TIMEOUT, auto_resize: bool = True) -> str:
|
|
|
|
|
|
"""以 multipart/form-data 上传本地图片,返回 ``upload_image_url``。失败抛异常。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
auto_resize: True 时,若图片超过 4096×4096 或 10MB 则自动缩放/重压
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not file_path or not os.path.isfile(file_path):
|
|
|
|
|
|
raise FileNotFoundError(f"图片文件不存在: {file_path}")
|
|
|
|
|
|
ext = os.path.splitext(file_path)[1].lower()
|
|
|
|
|
|
if ext not in UPLOAD_ALLOWED_EXT:
|
|
|
|
|
|
raise ValueError(f"不支持的图片格式 ({ext}),仅支持 PNG/JPG/JPEG")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
with Image.open(file_path) as img:
|
|
|
|
|
|
iw, ih = img.size
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise ValueError(f"无法读取图片: {exc}") from exc
|
2026-06-02 17:34:46 +08:00
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
size = os.path.getsize(file_path)
|
|
|
|
|
|
needs_resize = (iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS or size > UPLOAD_MAX_BYTES)
|
2026-06-02 17:34:46 +08:00
|
|
|
|
file_stem = os.path.splitext(os.path.basename(file_path))[0]
|
|
|
|
|
|
upload_ext = ext
|
|
|
|
|
|
mime = mimetypes.guess_type(file_path)[0] or "application/octet-stream"
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
if needs_resize:
|
|
|
|
|
|
if not auto_resize:
|
|
|
|
|
|
if iw > UPLOAD_MAX_PIXELS or ih > UPLOAD_MAX_PIXELS:
|
|
|
|
|
|
raise ValueError(f"分辨率超过 4096×4096(当前 {iw}×{ih})")
|
2026-06-02 17:34:46 +08:00
|
|
|
|
raise ValueError(f"图片超过 10MB 限制(当前 {size/1024/1024:.2f}MB)")
|
|
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AIImage][UPLOAD] 自动处理超限图片 %dx%d (%.2fMB)",
|
|
|
|
|
|
iw,
|
|
|
|
|
|
ih,
|
|
|
|
|
|
size / 1024 / 1024,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-29 14:40:39 +08:00
|
|
|
|
with Image.open(file_path) as img:
|
2026-06-02 17:34:46 +08:00
|
|
|
|
working = img.copy()
|
|
|
|
|
|
|
|
|
|
|
|
# 先做一次分辨率约束,避免后续压缩开销过大。
|
|
|
|
|
|
scale = min(UPLOAD_MAX_PIXELS / max(1, working.width), UPLOAD_MAX_PIXELS / max(1, working.height), 1.0)
|
|
|
|
|
|
if scale < 1.0:
|
|
|
|
|
|
working = working.resize(
|
|
|
|
|
|
(max(1, int(working.width * scale)), max(1, int(working.height * scale))),
|
|
|
|
|
|
Image.LANCZOS,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
best_bytes = b""
|
|
|
|
|
|
best_mime = mime
|
|
|
|
|
|
best_ext = upload_ext
|
|
|
|
|
|
|
|
|
|
|
|
# 第一优先:保持原格式。
|
|
|
|
|
|
try:
|
|
|
|
|
|
raw_io = BytesIO()
|
|
|
|
|
|
if ext == ".png":
|
|
|
|
|
|
working.save(raw_io, format="PNG", optimize=True)
|
|
|
|
|
|
raw_mime, raw_ext = "image/png", ".png"
|
2026-05-29 14:40:39 +08:00
|
|
|
|
else:
|
2026-06-02 17:34:46 +08:00
|
|
|
|
rgb = working.convert("RGB") if working.mode not in {"RGB", "L"} else working
|
|
|
|
|
|
rgb.save(raw_io, format="JPEG", quality=95, optimize=True)
|
|
|
|
|
|
raw_mime, raw_ext = "image/jpeg", ".jpg"
|
|
|
|
|
|
best_bytes = raw_io.getvalue()
|
|
|
|
|
|
best_mime = raw_mime
|
|
|
|
|
|
best_ext = raw_ext
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("[AIImage][UPLOAD] 原格式编码失败,准备转 JPEG: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
# 仍超限时,转 JPEG + 渐进压缩;如仍超限则继续降分辨率。
|
|
|
|
|
|
if len(best_bytes) > UPLOAD_MAX_BYTES:
|
|
|
|
|
|
if best_ext != ".jpg":
|
|
|
|
|
|
logger.info("[AIImage][UPLOAD] 原格式仍超限,切换 JPEG 压缩")
|
|
|
|
|
|
working_jpg = working.convert("RGB") if working.mode != "RGB" else working
|
|
|
|
|
|
while True:
|
|
|
|
|
|
compressed = b""
|
|
|
|
|
|
for q in (95, 90, 85, 80, 75, 70, 65, 60, 55, 50):
|
|
|
|
|
|
tmp = BytesIO()
|
|
|
|
|
|
working_jpg.save(tmp, format="JPEG", quality=q, optimize=True)
|
|
|
|
|
|
data = tmp.getvalue()
|
|
|
|
|
|
compressed = data
|
|
|
|
|
|
if len(data) <= UPLOAD_MAX_BYTES:
|
2026-05-29 14:40:39 +08:00
|
|
|
|
break
|
2026-06-02 17:34:46 +08:00
|
|
|
|
best_bytes = compressed
|
|
|
|
|
|
best_mime = "image/jpeg"
|
|
|
|
|
|
best_ext = ".jpg"
|
|
|
|
|
|
if len(best_bytes) <= UPLOAD_MAX_BYTES:
|
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
|
|
next_w = max(256, int(working_jpg.width * 0.9))
|
|
|
|
|
|
next_h = max(256, int(working_jpg.height * 0.9))
|
|
|
|
|
|
if next_w == working_jpg.width and next_h == working_jpg.height:
|
|
|
|
|
|
break
|
|
|
|
|
|
if next_w <= 256 or next_h <= 256:
|
|
|
|
|
|
break
|
|
|
|
|
|
working_jpg = working_jpg.resize((next_w, next_h), Image.LANCZOS)
|
|
|
|
|
|
|
|
|
|
|
|
if len(best_bytes) > UPLOAD_MAX_BYTES:
|
|
|
|
|
|
raise ValueError(
|
|
|
|
|
|
f"自动压缩后仍超过 10MB(当前 {len(best_bytes)/1024/1024:.2f}MB),请更换图片"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
file_bytes = best_bytes
|
|
|
|
|
|
mime = best_mime
|
|
|
|
|
|
upload_ext = best_ext
|
|
|
|
|
|
iw, ih = working.width, working.height
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AIImage][UPLOAD] 自动处理完成 %dx%d %.2fMB (%s)",
|
|
|
|
|
|
iw,
|
|
|
|
|
|
ih,
|
|
|
|
|
|
len(file_bytes) / 1024 / 1024,
|
|
|
|
|
|
mime,
|
|
|
|
|
|
)
|
2026-05-29 14:40:39 +08:00
|
|
|
|
else:
|
|
|
|
|
|
with open(file_path, "rb") as f:
|
|
|
|
|
|
file_bytes = f.read()
|
|
|
|
|
|
|
2026-06-02 17:34:46 +08:00
|
|
|
|
filename = f"{file_stem}{upload_ext}"
|
2026-05-29 14:40:39 +08:00
|
|
|
|
boundary = "----pqAuto" + uuid.uuid4().hex
|
|
|
|
|
|
crlf = b"\r\n"
|
|
|
|
|
|
body = b"".join([
|
|
|
|
|
|
b"--", boundary.encode("ascii"), crlf,
|
|
|
|
|
|
b'Content-Disposition: form-data; name="file"; filename="',
|
|
|
|
|
|
filename.encode("utf-8"), b'"', crlf,
|
|
|
|
|
|
b"Content-Type: ", mime.encode("ascii"), crlf, crlf,
|
|
|
|
|
|
file_bytes, crlf,
|
|
|
|
|
|
b"--", boundary.encode("ascii"), b"--", crlf,
|
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
|
|
endpoint = _api_endpoint(API_UPLOAD_PATH)
|
|
|
|
|
|
headers = {
|
|
|
|
|
|
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
|
|
|
|
"Accept": "application/json",
|
|
|
|
|
|
"User-Agent": "pqAutomationApp/1.0",
|
|
|
|
|
|
"Content-Length": str(len(body)),
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AIImage][UPLOAD] file=%s size=%dB mime=%s wh=%dx%d -> %s",
|
|
|
|
|
|
filename, len(file_bytes), mime, iw, ih, endpoint,
|
|
|
|
|
|
)
|
|
|
|
|
|
request = Request(endpoint, data=body, method="POST", headers=headers)
|
|
|
|
|
|
t0 = time.monotonic()
|
|
|
|
|
|
try:
|
|
|
|
|
|
with urlopen(request, timeout=timeout) as response:
|
|
|
|
|
|
raw = response.read()
|
|
|
|
|
|
http_status = response.status
|
|
|
|
|
|
raw_text = raw.decode("utf-8", errors="replace")
|
|
|
|
|
|
logger.info("[AIImage][UPLOAD_RESP]\nstatus=%s\nbody=%s",
|
|
|
|
|
|
http_status, _pretty_json_text(raw_text))
|
|
|
|
|
|
except HTTPError as exc:
|
|
|
|
|
|
err_raw = b""
|
|
|
|
|
|
try:
|
|
|
|
|
|
err_raw = exc.read() or b""
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
err_text = err_raw.decode("utf-8", errors="replace") if err_raw else ""
|
|
|
|
|
|
logger.error("[AIImage][UPLOAD_ERR] status=%s reason=%s body=%s",
|
|
|
|
|
|
getattr(exc, "code", "?"), str(exc), _pretty_json_text(err_text))
|
|
|
|
|
|
raise
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.error("[AIImage][UPLOAD_ERR] %s: %s", type(exc).__name__, exc)
|
|
|
|
|
|
raise
|
|
|
|
|
|
elapsed = time.monotonic() - t0
|
|
|
|
|
|
try:
|
|
|
|
|
|
result = json.loads(raw.decode("utf-8"))
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
raise RuntimeError(f"上传接口返回非 JSON:{raw_text}") from exc
|
|
|
|
|
|
code = result.get("code")
|
|
|
|
|
|
message = result.get("message") or ""
|
|
|
|
|
|
data = result.get("data") or {}
|
|
|
|
|
|
url = (data.get("upload_image_url") or "").strip()
|
|
|
|
|
|
if code != 200 or not url:
|
|
|
|
|
|
raise RuntimeError(f"上传失败 code={code} msg={message or '未知错误'}")
|
|
|
|
|
|
logger.info("[AIImage][UPLOAD_OK] elapsed=%.2fs url=%s", elapsed, url)
|
|
|
|
|
|
return url
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
# ---------- 缓存路径工具 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def get_cache_dir(base_dir: Optional[str] = None) -> str:
|
2026-05-22 11:31:36 +08:00
|
|
|
|
"""返回缓存目录,如不存在则创建。``base_dir`` 留空时使用应用根目录。"""
|
|
|
|
|
|
if base_dir:
|
|
|
|
|
|
root = base_dir
|
|
|
|
|
|
elif getattr(sys, "frozen", False):
|
|
|
|
|
|
root = os.path.dirname(sys.executable)
|
|
|
|
|
|
else:
|
|
|
|
|
|
root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
2026-04-21 14:06:48 +08:00
|
|
|
|
path = os.path.join(root, _CACHE_DIRNAME)
|
|
|
|
|
|
os.makedirs(path, exist_ok=True)
|
|
|
|
|
|
return path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_id(prompt: str) -> str:
|
|
|
|
|
|
stamp = _dt.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
|
|
|
|
|
|
digest = hashlib.md5(prompt.encode("utf-8")).hexdigest()[:8]
|
|
|
|
|
|
return f"{stamp}_{digest}"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _meta_path_for(image_path: str) -> str:
|
|
|
|
|
|
return os.path.splitext(image_path)[0] + _META_SUFFIX
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
def _sanitize_image_bytes(image_bytes: bytes, image_ext: str) -> bytes:
|
|
|
|
|
|
"""规范化图片字节,尽量去掉已知有问题的 PNG ICC profile。"""
|
|
|
|
|
|
ext = (image_ext or "").lower()
|
|
|
|
|
|
if ext not in {".png", ".jpg", ".jpeg", ".bmp", ".webp"}:
|
|
|
|
|
|
return image_bytes
|
|
|
|
|
|
try:
|
|
|
|
|
|
with Image.open(BytesIO(image_bytes)) as img:
|
|
|
|
|
|
img.load()
|
|
|
|
|
|
normalized = img.copy()
|
|
|
|
|
|
output = BytesIO()
|
|
|
|
|
|
save_kwargs = {}
|
|
|
|
|
|
if ext == ".png":
|
|
|
|
|
|
save_kwargs["icc_profile"] = None
|
|
|
|
|
|
normalized.save(output, format=normalized.format or ext.lstrip(".").upper(), **save_kwargs)
|
|
|
|
|
|
result = output.getvalue()
|
|
|
|
|
|
if result:
|
|
|
|
|
|
return result
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
logger.warning("[AIImage] 图片规范化失败 ext=%s %s: %s", ext, type(exc).__name__, exc)
|
|
|
|
|
|
return image_bytes
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
# ---------- 读写 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
|
|
|
|
|
"""列出缓存目录下的所有记录,按创建时间倒序(最新在前)。"""
|
|
|
|
|
|
cache_dir = get_cache_dir(base_dir)
|
|
|
|
|
|
records: List[AIImageRecord] = []
|
|
|
|
|
|
for name in os.listdir(cache_dir):
|
|
|
|
|
|
full = os.path.join(cache_dir, name)
|
|
|
|
|
|
if not (os.path.isfile(full) and name.lower().endswith(_SUPPORTED_IMG_EXT)):
|
|
|
|
|
|
continue
|
|
|
|
|
|
meta_path = _meta_path_for(full)
|
|
|
|
|
|
prompt = ""
|
|
|
|
|
|
created_at = ""
|
|
|
|
|
|
extra = None
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title = None
|
2026-04-21 14:06:48 +08:00
|
|
|
|
rec_id = os.path.splitext(name)[0]
|
|
|
|
|
|
if os.path.isfile(meta_path):
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(meta_path, "r", encoding="utf-8") as f:
|
|
|
|
|
|
data = json.load(f)
|
|
|
|
|
|
prompt = data.get("prompt", "")
|
|
|
|
|
|
created_at = data.get("created_at", "")
|
|
|
|
|
|
extra = data.get("extra")
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title = data.get("title")
|
2026-04-21 14:06:48 +08:00
|
|
|
|
rec_id = data.get("id", rec_id)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
if not created_at:
|
|
|
|
|
|
# fallback 到文件 mtime
|
|
|
|
|
|
try:
|
|
|
|
|
|
mtime = os.path.getmtime(full)
|
|
|
|
|
|
created_at = _dt.datetime.fromtimestamp(mtime).isoformat()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
created_at = ""
|
|
|
|
|
|
records.append(
|
|
|
|
|
|
AIImageRecord(
|
|
|
|
|
|
id=rec_id,
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
image_path=full,
|
|
|
|
|
|
created_at=created_at,
|
|
|
|
|
|
extra=extra,
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title=title,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
records.sort(key=lambda r: r.created_at, reverse=True)
|
|
|
|
|
|
return records
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def save_image_to_cache(
|
|
|
|
|
|
prompt: str,
|
|
|
|
|
|
image_bytes: bytes,
|
|
|
|
|
|
image_ext: str = ".png",
|
|
|
|
|
|
extra: Optional[dict] = None,
|
|
|
|
|
|
base_dir: Optional[str] = None,
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title: Optional[str] = None,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
) -> AIImageRecord:
|
|
|
|
|
|
"""把生成的图片字节写入缓存,返回记录。"""
|
|
|
|
|
|
if not image_ext.startswith("."):
|
|
|
|
|
|
image_ext = "." + image_ext
|
|
|
|
|
|
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
|
|
|
|
|
|
image_ext = ".png"
|
2026-04-29 15:25:58 +08:00
|
|
|
|
image_bytes = _sanitize_image_bytes(image_bytes, image_ext)
|
2026-04-21 14:06:48 +08:00
|
|
|
|
cache_dir = get_cache_dir(base_dir)
|
|
|
|
|
|
rec_id = _make_id(prompt)
|
|
|
|
|
|
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
|
|
|
|
|
|
with open(image_path, "wb") as f:
|
|
|
|
|
|
f.write(image_bytes)
|
|
|
|
|
|
|
|
|
|
|
|
record = AIImageRecord(
|
|
|
|
|
|
id=rec_id,
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
image_path=image_path,
|
|
|
|
|
|
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
|
|
|
|
|
extra=extra,
|
2026-04-29 15:25:58 +08:00
|
|
|
|
title=title,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
|
|
|
|
|
f.write(record.to_json())
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
return record
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 10:07:41 +08:00
|
|
|
|
def import_image_from_url(
|
|
|
|
|
|
image_url: str,
|
|
|
|
|
|
prompt: Optional[str] = None,
|
|
|
|
|
|
extra: Optional[dict] = None,
|
|
|
|
|
|
base_dir: Optional[str] = None,
|
|
|
|
|
|
timeout: float = 20.0,
|
|
|
|
|
|
) -> AIImageRecord:
|
|
|
|
|
|
"""下载远程图片并写入缓存。"""
|
|
|
|
|
|
url = (image_url or "").strip()
|
|
|
|
|
|
if not url:
|
|
|
|
|
|
raise ValueError("图片地址不能为空")
|
|
|
|
|
|
|
|
|
|
|
|
request = Request(
|
|
|
|
|
|
url,
|
|
|
|
|
|
headers={
|
|
|
|
|
|
"User-Agent": "pqAutomationApp/1.0",
|
|
|
|
|
|
"Accept": "image/*,*/*;q=0.8",
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
with urlopen(request, timeout=timeout) as response:
|
|
|
|
|
|
image_bytes = response.read()
|
|
|
|
|
|
if not image_bytes:
|
|
|
|
|
|
raise ValueError("下载结果为空")
|
|
|
|
|
|
|
|
|
|
|
|
image_ext = _guess_image_ext(
|
|
|
|
|
|
image_url=url,
|
|
|
|
|
|
content_type=response.headers.get_content_type(),
|
|
|
|
|
|
)
|
|
|
|
|
|
merged_extra = dict(extra or {})
|
|
|
|
|
|
merged_extra.update(
|
|
|
|
|
|
{
|
|
|
|
|
|
"source": "remote-url",
|
|
|
|
|
|
"source_url": url,
|
|
|
|
|
|
"content_type": response.headers.get_content_type(),
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
record_prompt = (prompt or _default_prompt_from_url(url)).strip()
|
|
|
|
|
|
return save_image_to_cache(
|
|
|
|
|
|
prompt=record_prompt,
|
|
|
|
|
|
image_bytes=image_bytes,
|
|
|
|
|
|
image_ext=image_ext,
|
|
|
|
|
|
extra=merged_extra,
|
|
|
|
|
|
base_dir=base_dir,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
def delete_record(record: AIImageRecord) -> bool:
|
|
|
|
|
|
"""删除一条缓存记录(图片 + 侧车)。返回是否成功。"""
|
|
|
|
|
|
ok = True
|
|
|
|
|
|
for p in (record.image_path, _meta_path_for(record.image_path)):
|
|
|
|
|
|
try:
|
|
|
|
|
|
if os.path.isfile(p):
|
|
|
|
|
|
os.remove(p)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
ok = False
|
|
|
|
|
|
return ok
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def export_record(record: AIImageRecord, dest_path: str) -> None:
|
|
|
|
|
|
"""把缓存中的图片另存到 ``dest_path``。"""
|
|
|
|
|
|
shutil.copyfile(record.image_path, dest_path)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
def update_record_title(record: AIImageRecord, new_title: Optional[str]) -> bool:
|
|
|
|
|
|
"""更新记录的展示标题并写回侧车 JSON。空串/None 视为清除标题。"""
|
|
|
|
|
|
title = (new_title or "").strip() or None
|
|
|
|
|
|
meta_path = _meta_path_for(record.image_path)
|
|
|
|
|
|
try:
|
|
|
|
|
|
data: dict = {}
|
|
|
|
|
|
if os.path.isfile(meta_path):
|
|
|
|
|
|
with open(meta_path, "r", encoding="utf-8") as f:
|
|
|
|
|
|
data = json.load(f) or {}
|
|
|
|
|
|
if title is None:
|
|
|
|
|
|
data.pop("title", None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
data["title"] = title
|
|
|
|
|
|
with open(meta_path, "w", encoding="utf-8") as f:
|
|
|
|
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
return False
|
|
|
|
|
|
record.title = title
|
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def group_records_by_session(records: List[AIImageRecord]) -> List[dict]:
|
|
|
|
|
|
"""按 ``session_id`` 分组。
|
|
|
|
|
|
|
|
|
|
|
|
返回元素:``{"session_id", "records", "started_at", "latest_at"}``。
|
|
|
|
|
|
会话按"最近使用时间"倒序,组内记录按时间倒序。
|
|
|
|
|
|
没有 session_id 的记录归到空串 ``""`` 组。
|
|
|
|
|
|
"""
|
|
|
|
|
|
buckets: dict = {}
|
|
|
|
|
|
for rec in records:
|
|
|
|
|
|
buckets.setdefault(rec.session_id, []).append(rec)
|
|
|
|
|
|
sessions = []
|
|
|
|
|
|
for sid, recs in buckets.items():
|
|
|
|
|
|
recs.sort(key=lambda r: r.created_at, reverse=True)
|
|
|
|
|
|
started_at = min((r.created_at for r in recs if r.created_at), default="")
|
|
|
|
|
|
sessions.append(
|
|
|
|
|
|
{
|
|
|
|
|
|
"session_id": sid,
|
|
|
|
|
|
"records": recs,
|
|
|
|
|
|
"started_at": started_at,
|
|
|
|
|
|
"latest_at": recs[0].created_at if recs else "",
|
|
|
|
|
|
}
|
|
|
|
|
|
)
|
|
|
|
|
|
sessions.sort(key=lambda s: s["latest_at"], reverse=True)
|
|
|
|
|
|
return sessions
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
# ---------- 异步请求 ----------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def request_image_async(
|
|
|
|
|
|
prompt: str,
|
|
|
|
|
|
on_success: Callable[[AIImageRecord], None],
|
|
|
|
|
|
on_error: Callable[[Exception], None],
|
|
|
|
|
|
base_dir: Optional[str] = None,
|
2026-04-29 15:25:58 +08:00
|
|
|
|
session_id: Optional[str] = None,
|
2026-04-29 19:10:27 +08:00
|
|
|
|
cancel_event: Optional[threading.Event] = None,
|
2026-05-29 14:40:39 +08:00
|
|
|
|
upload_image_url: Optional[str] = None,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
) -> threading.Thread:
|
2026-04-29 15:25:58 +08:00
|
|
|
|
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
2026-04-21 14:06:48 +08:00
|
|
|
|
|
|
|
|
|
|
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用;UI 侧若需
|
|
|
|
|
|
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
2026-04-29 15:25:58 +08:00
|
|
|
|
|
|
|
|
|
|
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
2026-05-29 14:40:39 +08:00
|
|
|
|
``upload_image_url`` 传入后启用"图生图"模式。
|
2026-04-21 14:06:48 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
2026-04-29 15:25:58 +08:00
|
|
|
|
sid = session_id or get_session_id()
|
2026-04-29 19:10:27 +08:00
|
|
|
|
cancel = cancel_event
|
2026-05-29 14:40:39 +08:00
|
|
|
|
ref_url = (upload_image_url or "").strip() or None
|
2026-04-29 15:25:58 +08:00
|
|
|
|
|
2026-04-21 14:06:48 +08:00
|
|
|
|
def _worker():
|
|
|
|
|
|
try:
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel is not None and cancel.is_set():
|
|
|
|
|
|
logger.info("[AIImage] 任务已取消(请求前) sid=%s", _mask_sid(sid))
|
|
|
|
|
|
return
|
2026-05-29 14:40:39 +08:00
|
|
|
|
image_url = _call_pqtest_generate(prompt, sid, upload_image_url=ref_url)
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel is not None and cancel.is_set():
|
|
|
|
|
|
logger.info("[AIImage] 任务已取消(生成后) sid=%s", _mask_sid(sid))
|
|
|
|
|
|
return
|
2026-05-29 14:40:39 +08:00
|
|
|
|
extra = {
|
|
|
|
|
|
"source": "ai-api",
|
|
|
|
|
|
"session_id": sid,
|
|
|
|
|
|
"mode": "img2img" if ref_url else "txt2img",
|
|
|
|
|
|
}
|
|
|
|
|
|
if ref_url:
|
|
|
|
|
|
extra["upload_image_url"] = ref_url
|
2026-04-29 15:25:58 +08:00
|
|
|
|
record = import_image_from_url(
|
|
|
|
|
|
image_url=image_url,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
prompt=prompt,
|
2026-05-29 14:40:39 +08:00
|
|
|
|
extra=extra,
|
2026-04-21 14:06:48 +08:00
|
|
|
|
base_dir=base_dir,
|
|
|
|
|
|
)
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel is not None and cancel.is_set():
|
|
|
|
|
|
logger.info("[AIImage] 任务已取消(下载后) sid=%s", _mask_sid(sid))
|
|
|
|
|
|
return
|
2026-04-29 15:25:58 +08:00
|
|
|
|
logger.info(
|
|
|
|
|
|
"[AIImage] 已写入缓存 sid=%s id=%s path=%s",
|
|
|
|
|
|
_mask_sid(sid), record.id, record.image_path,
|
|
|
|
|
|
)
|
2026-04-21 14:06:48 +08:00
|
|
|
|
on_success(record)
|
|
|
|
|
|
except Exception as exc:
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel is not None and cancel.is_set():
|
|
|
|
|
|
logger.info("[AIImage] 任务已取消(异常忽略) sid=%s", _mask_sid(sid))
|
|
|
|
|
|
return
|
2026-04-29 15:25:58 +08:00
|
|
|
|
logger.error(
|
|
|
|
|
|
"[AIImage] 生成流程失败 sid=%s %s: %s",
|
|
|
|
|
|
_mask_sid(sid), type(exc).__name__, exc,
|
|
|
|
|
|
)
|
2026-04-21 14:06:48 +08:00
|
|
|
|
on_error(exc)
|
|
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=_worker, daemon=True)
|
|
|
|
|
|
t.start()
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-04-23 10:07:41 +08:00
|
|
|
|
def import_image_from_url_async(
|
|
|
|
|
|
image_url: str,
|
|
|
|
|
|
on_success: Callable[[AIImageRecord], None],
|
|
|
|
|
|
on_error: Callable[[Exception], None],
|
|
|
|
|
|
prompt: Optional[str] = None,
|
|
|
|
|
|
extra: Optional[dict] = None,
|
|
|
|
|
|
base_dir: Optional[str] = None,
|
|
|
|
|
|
timeout: float = 20.0,
|
2026-04-29 19:10:27 +08:00
|
|
|
|
cancel_event: Optional[threading.Event] = None,
|
2026-04-23 10:07:41 +08:00
|
|
|
|
) -> threading.Thread:
|
|
|
|
|
|
"""在后台线程下载远程图片并写入缓存"""
|
|
|
|
|
|
|
|
|
|
|
|
def _worker():
|
|
|
|
|
|
try:
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
logger.info("[AIImage] URL 导入任务已取消(请求前)")
|
|
|
|
|
|
return
|
2026-04-23 10:07:41 +08:00
|
|
|
|
record = import_image_from_url(
|
|
|
|
|
|
image_url=image_url,
|
|
|
|
|
|
prompt=prompt,
|
|
|
|
|
|
extra=extra,
|
|
|
|
|
|
base_dir=base_dir,
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
logger.info("[AIImage] URL 导入任务已取消(下载后)")
|
|
|
|
|
|
return
|
2026-04-23 10:07:41 +08:00
|
|
|
|
on_success(record)
|
|
|
|
|
|
except Exception as exc:
|
2026-04-29 19:10:27 +08:00
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
logger.info("[AIImage] URL 导入任务已取消(异常忽略)")
|
|
|
|
|
|
return
|
2026-04-23 10:07:41 +08:00
|
|
|
|
on_error(exc)
|
2026-05-29 14:40:39 +08:00
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=_worker, daemon=True)
|
|
|
|
|
|
t.start()
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def upload_image_async(
|
|
|
|
|
|
file_path: str,
|
|
|
|
|
|
on_success: Callable[[str], None],
|
|
|
|
|
|
on_error: Callable[[Exception], None],
|
|
|
|
|
|
cancel_event: Optional[threading.Event] = None,
|
|
|
|
|
|
timeout: float = API_UPLOAD_TIMEOUT,
|
|
|
|
|
|
auto_resize: bool = True,
|
|
|
|
|
|
) -> threading.Thread:
|
|
|
|
|
|
"""后台上传本地图片到后端,成功回调返回 ``upload_image_url``。
|
|
|
|
|
|
|
|
|
|
|
|
参数:
|
|
|
|
|
|
auto_resize: True 时自动缩放超大图片
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
def _worker():
|
|
|
|
|
|
try:
|
|
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
return
|
|
|
|
|
|
url = _call_pqtest_upload(file_path, timeout=timeout, auto_resize=auto_resize)
|
|
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
return
|
|
|
|
|
|
on_success(url)
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
if cancel_event is not None and cancel_event.is_set():
|
|
|
|
|
|
return
|
|
|
|
|
|
on_error(exc)
|
2026-04-23 10:07:41 +08:00
|
|
|
|
|
|
|
|
|
|
t = threading.Thread(target=_worker, daemon=True)
|
|
|
|
|
|
t.start()
|
|
|
|
|
|
return t
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def is_remote_image_url(value: str) -> bool:
|
|
|
|
|
|
"""判断输入是否为 http/https 图片地址。"""
|
|
|
|
|
|
url = (value or "").strip()
|
|
|
|
|
|
if not url:
|
|
|
|
|
|
return False
|
|
|
|
|
|
parsed = urlparse(url)
|
|
|
|
|
|
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _guess_image_ext(image_url: str, content_type: Optional[str]) -> str:
|
|
|
|
|
|
if content_type:
|
|
|
|
|
|
guessed = mimetypes.guess_extension(content_type)
|
|
|
|
|
|
if guessed == ".jpe":
|
|
|
|
|
|
guessed = ".jpg"
|
|
|
|
|
|
if guessed and guessed.lower() in _SUPPORTED_IMG_EXT:
|
|
|
|
|
|
return guessed.lower()
|
|
|
|
|
|
|
|
|
|
|
|
url_path = urlparse(image_url).path
|
|
|
|
|
|
ext = os.path.splitext(url_path)[1].lower()
|
|
|
|
|
|
if ext in _SUPPORTED_IMG_EXT:
|
|
|
|
|
|
return ext
|
|
|
|
|
|
return ".png"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _default_prompt_from_url(image_url: str) -> str:
|
|
|
|
|
|
path = urlparse(image_url).path
|
|
|
|
|
|
name = os.path.splitext(os.path.basename(path))[0].strip()
|
|
|
|
|
|
return name or "远程导入图片"
|