添加AI图像生成接口、修改相关界面
This commit is contained in:
@@ -1,6 +1,10 @@
|
|||||||
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
"""AI 图片生成服务:后端请求 + 本地缓存管理。
|
||||||
|
|
||||||
API 端点待接入,当前通过 ``set_api_caller`` 注入具体实现。
|
后端接口(测试环境):
|
||||||
|
POST {API_BASE_URL}{API_PATH}
|
||||||
|
body: {"user_message": str, "session_id": str}
|
||||||
|
resp: {"code": 200, "message": "", "data": {"imageUrl": "..."}}
|
||||||
|
|
||||||
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
缓存目录:``settings/ai_image_cache/``,每张图片有同名的 ``.json`` 侧车记录。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -9,15 +13,24 @@ from __future__ import annotations
|
|||||||
import datetime as _dt
|
import datetime as _dt
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
import mimetypes
|
import mimetypes
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
from io import BytesIO
|
||||||
from dataclasses import dataclass, asdict
|
from dataclasses import dataclass, asdict
|
||||||
from typing import Callable, List, Optional
|
from typing import Callable, List, Optional
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# ---------- 常量 ----------
|
# ---------- 常量 ----------
|
||||||
|
|
||||||
@@ -25,41 +38,166 @@ _CACHE_DIRNAME = os.path.join("settings", "ai_image_cache")
|
|||||||
_META_SUFFIX = ".json"
|
_META_SUFFIX = ".json"
|
||||||
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
_SUPPORTED_IMG_EXT = (".png", ".jpg", ".jpeg", ".bmp", ".webp")
|
||||||
|
|
||||||
|
# 测试环境后端
|
||||||
|
API_BASE_URL = "http://10.201.44.70:9018/ai-agent/"
|
||||||
|
API_PATH = "api/v1/pqtest/generate"
|
||||||
|
API_TIMEOUT = 90.0 # 后端最长 60s,留余量
|
||||||
|
|
||||||
|
# 进程级会话 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:
|
||||||
|
"""切换到指定会话。空值会抛错。"""
|
||||||
|
global _session_id
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
raise ValueError("session_id 不能为空")
|
||||||
|
with _session_lock:
|
||||||
|
old = _session_id
|
||||||
|
_session_id = sid
|
||||||
|
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
|
||||||
|
return _session_id
|
||||||
|
|
||||||
|
|
||||||
|
def reset_session() -> str:
|
||||||
|
"""开启新一轮会话,返回新的 session_id。"""
|
||||||
|
global _session_id
|
||||||
|
with _session_lock:
|
||||||
|
old = _session_id
|
||||||
|
_session_id = str(uuid.uuid4())
|
||||||
|
logger.info("[AIImage] 会话切换 %s -> %s", _mask_sid(old), _mask_sid(_session_id))
|
||||||
|
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] + "…"
|
||||||
|
|
||||||
|
|
||||||
# ---------- 数据结构 ----------
|
# ---------- 数据结构 ----------
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AIImageRecord:
|
class AIImageRecord:
|
||||||
"""一条缓存记录。"""
|
"""一条缓存记录。
|
||||||
|
|
||||||
|
字段说明:
|
||||||
|
- ``id``: 唯一 id,等同于磁盘文件名(不含扩展名),格式 ``{时间戳}_{md5前8}``。
|
||||||
|
- ``prompt``: 用户原始输入(完整保留,用于回溯/调试,不应被改写)。
|
||||||
|
- ``title``: 用户自定义展示标题(重命名时写入),UI 优先使用,留空则回退 prompt 第一行截断。
|
||||||
|
- ``image_path``: 图片在缓存目录中的绝对路径。
|
||||||
|
- ``created_at``: ISO8601 时间字符串。
|
||||||
|
- ``extra``: 其它元数据,至少包含 ``source`` 与 ``session_id``(标识属于哪一轮对话)。
|
||||||
|
"""
|
||||||
|
|
||||||
id: str
|
id: str
|
||||||
prompt: str
|
prompt: str
|
||||||
image_path: str
|
image_path: str
|
||||||
created_at: str # ISO8601
|
created_at: str # ISO8601
|
||||||
extra: Optional[dict] = None
|
extra: Optional[dict] = None
|
||||||
|
title: Optional[str] = None
|
||||||
|
|
||||||
def to_json(self) -> str:
|
def to_json(self) -> str:
|
||||||
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
|
return json.dumps(asdict(self), ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
@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 "(未命名)"
|
||||||
|
|
||||||
# ---------- API 注入 ----------
|
@property
|
||||||
|
def session_id(self) -> str:
|
||||||
# 调用签名: ``fn(prompt: str) -> (image_bytes: bytes, image_ext: str, extra: dict|None)``
|
if isinstance(self.extra, dict):
|
||||||
# ``image_ext`` 例如 ``".png"``;``extra`` 可为 None。
|
return str(self.extra.get("session_id") or "")
|
||||||
_ApiCaller = Callable[[str], tuple]
|
return ""
|
||||||
|
|
||||||
_api_caller: Optional[_ApiCaller] = None
|
|
||||||
|
|
||||||
|
|
||||||
def set_api_caller(fn: Optional[_ApiCaller]) -> None:
|
# ---------- 后端 API ----------
|
||||||
"""注入真实的后端 API 调用函数。在 API 就绪前可保持为 None。"""
|
|
||||||
global _api_caller
|
|
||||||
_api_caller = fn
|
|
||||||
|
|
||||||
|
|
||||||
def has_api() -> bool:
|
def _api_endpoint() -> str:
|
||||||
return _api_caller is not None
|
base = API_BASE_URL if API_BASE_URL.endswith("/") else API_BASE_URL + "/"
|
||||||
|
return base + API_PATH.lstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def _call_pqtest_generate(user_message: str, session_id: str, timeout: float = API_TIMEOUT) -> str:
|
||||||
|
"""调用后端 ``api/v1/pqtest/generate``,返回 imageUrl。失败抛异常。"""
|
||||||
|
payload = json.dumps(
|
||||||
|
{"user_message": user_message,
|
||||||
|
"session_id": session_id},
|
||||||
|
ensure_ascii=False,
|
||||||
|
).encode("utf-8")
|
||||||
|
endpoint = _api_endpoint()
|
||||||
|
logger.info(
|
||||||
|
"[AIImage] 请求生成 sid=%s prompt_len=%d prompt=%r",
|
||||||
|
_mask_sid(session_id), len(user_message or ""), _truncate(user_message),
|
||||||
|
)
|
||||||
|
logger.debug("[AIImage] POST %s timeout=%.1fs", endpoint, timeout)
|
||||||
|
request = Request(
|
||||||
|
endpoint,
|
||||||
|
data=payload,
|
||||||
|
method="POST",
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/json; charset=utf-8",
|
||||||
|
"Accept": "application/json",
|
||||||
|
"User-Agent": "pqAutomationApp/1.0",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
t0 = time.monotonic()
|
||||||
|
try:
|
||||||
|
with urlopen(request, timeout=timeout) as response:
|
||||||
|
raw = response.read()
|
||||||
|
http_status = response.status
|
||||||
|
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
|
||||||
|
logger.debug(
|
||||||
|
"[AIImage] HTTP %s 收到 %d bytes elapsed=%.2fs",
|
||||||
|
http_status, len(raw), elapsed,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = json.loads(raw.decode("utf-8"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[AIImage] 响应解析失败 sid=%s raw=%r", _mask_sid(session_id), raw[:200])
|
||||||
|
raise RuntimeError(f"AI 接口返回非 JSON:{raw[:200]!r}") from exc
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------- 缓存路径工具 ----------
|
# ---------- 缓存路径工具 ----------
|
||||||
@@ -83,6 +221,28 @@ def _meta_path_for(image_path: str) -> str:
|
|||||||
return os.path.splitext(image_path)[0] + _META_SUFFIX
|
return os.path.splitext(image_path)[0] + _META_SUFFIX
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------- 读写 ----------
|
# ---------- 读写 ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -98,6 +258,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
|||||||
prompt = ""
|
prompt = ""
|
||||||
created_at = ""
|
created_at = ""
|
||||||
extra = None
|
extra = None
|
||||||
|
title = None
|
||||||
rec_id = os.path.splitext(name)[0]
|
rec_id = os.path.splitext(name)[0]
|
||||||
if os.path.isfile(meta_path):
|
if os.path.isfile(meta_path):
|
||||||
try:
|
try:
|
||||||
@@ -106,6 +267,7 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
|||||||
prompt = data.get("prompt", "")
|
prompt = data.get("prompt", "")
|
||||||
created_at = data.get("created_at", "")
|
created_at = data.get("created_at", "")
|
||||||
extra = data.get("extra")
|
extra = data.get("extra")
|
||||||
|
title = data.get("title")
|
||||||
rec_id = data.get("id", rec_id)
|
rec_id = data.get("id", rec_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
@@ -123,54 +285,27 @@ def list_records(base_dir: Optional[str] = None) -> List[AIImageRecord]:
|
|||||||
image_path=full,
|
image_path=full,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
extra=extra,
|
extra=extra,
|
||||||
|
title=title,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if not records:
|
|
||||||
seeded = _seed_placeholder_record(cache_dir)
|
|
||||||
if seeded is not None:
|
|
||||||
records.append(seeded)
|
|
||||||
records.sort(key=lambda r: r.created_at, reverse=True)
|
records.sort(key=lambda r: r.created_at, reverse=True)
|
||||||
return records
|
return records
|
||||||
|
|
||||||
|
|
||||||
def _seed_placeholder_record(cache_dir: str) -> Optional[AIImageRecord]:
|
|
||||||
"""当缓存为空时,写入一张本地占位图,便于前端联调。"""
|
|
||||||
try:
|
|
||||||
repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
||||||
src = os.path.join(repo_root, "assets", "entry_1.png")
|
|
||||||
if not os.path.isfile(src):
|
|
||||||
return None
|
|
||||||
|
|
||||||
rec_id = f"{_dt.datetime.now().strftime('%Y%m%d_%H%M%S')}_placeholder"
|
|
||||||
image_path = os.path.join(cache_dir, f"{rec_id}.png")
|
|
||||||
shutil.copyfile(src, image_path)
|
|
||||||
|
|
||||||
record = AIImageRecord(
|
|
||||||
id=rec_id,
|
|
||||||
prompt="本地测试占位图(后端未接入)",
|
|
||||||
image_path=image_path,
|
|
||||||
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
|
||||||
extra={"source": "local-placeholder"},
|
|
||||||
)
|
|
||||||
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
|
||||||
f.write(record.to_json())
|
|
||||||
return record
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def save_image_to_cache(
|
def save_image_to_cache(
|
||||||
prompt: str,
|
prompt: str,
|
||||||
image_bytes: bytes,
|
image_bytes: bytes,
|
||||||
image_ext: str = ".png",
|
image_ext: str = ".png",
|
||||||
extra: Optional[dict] = None,
|
extra: Optional[dict] = None,
|
||||||
base_dir: Optional[str] = None,
|
base_dir: Optional[str] = None,
|
||||||
|
title: Optional[str] = None,
|
||||||
) -> AIImageRecord:
|
) -> AIImageRecord:
|
||||||
"""把生成的图片字节写入缓存,返回记录。"""
|
"""把生成的图片字节写入缓存,返回记录。"""
|
||||||
if not image_ext.startswith("."):
|
if not image_ext.startswith("."):
|
||||||
image_ext = "." + image_ext
|
image_ext = "." + image_ext
|
||||||
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
|
if image_ext.lower() not in _SUPPORTED_IMG_EXT:
|
||||||
image_ext = ".png"
|
image_ext = ".png"
|
||||||
|
image_bytes = _sanitize_image_bytes(image_bytes, image_ext)
|
||||||
cache_dir = get_cache_dir(base_dir)
|
cache_dir = get_cache_dir(base_dir)
|
||||||
rec_id = _make_id(prompt)
|
rec_id = _make_id(prompt)
|
||||||
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
|
image_path = os.path.join(cache_dir, f"{rec_id}{image_ext}")
|
||||||
@@ -183,6 +318,7 @@ def save_image_to_cache(
|
|||||||
image_path=image_path,
|
image_path=image_path,
|
||||||
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
created_at=_dt.datetime.now().isoformat(timespec="seconds"),
|
||||||
extra=extra,
|
extra=extra,
|
||||||
|
title=title,
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
with open(_meta_path_for(image_path), "w", encoding="utf-8") as f:
|
||||||
@@ -256,6 +392,53 @@ def export_record(record: AIImageRecord, dest_path: str) -> None:
|
|||||||
shutil.copyfile(record.image_path, dest_path)
|
shutil.copyfile(record.image_path, dest_path)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ---------- 异步请求 ----------
|
# ---------- 异步请求 ----------
|
||||||
|
|
||||||
|
|
||||||
@@ -264,27 +447,37 @@ def request_image_async(
|
|||||||
on_success: Callable[[AIImageRecord], None],
|
on_success: Callable[[AIImageRecord], None],
|
||||||
on_error: Callable[[Exception], None],
|
on_error: Callable[[Exception], None],
|
||||||
base_dir: Optional[str] = None,
|
base_dir: Optional[str] = None,
|
||||||
|
session_id: Optional[str] = None,
|
||||||
) -> threading.Thread:
|
) -> threading.Thread:
|
||||||
"""在后台线程请求 API → 写入缓存 → 回调。
|
"""在后台线程调用后端 API → 下载图片 → 写入缓存 → 回调。
|
||||||
|
|
||||||
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用;UI 侧若需
|
``on_success`` / ``on_error`` 会在 **工作线程** 中被调用;UI 侧若需
|
||||||
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
切回主线程,请在回调内部自行用 ``root.after(0, ...)``。
|
||||||
|
|
||||||
|
``session_id`` 留空则使用进程级会话 id(保证多轮对话上下文)。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
sid = session_id or get_session_id()
|
||||||
|
|
||||||
def _worker():
|
def _worker():
|
||||||
try:
|
try:
|
||||||
if _api_caller is None:
|
image_url = _call_pqtest_generate(prompt, sid)
|
||||||
raise RuntimeError("AI 图片 API 尚未接入,请调用 set_api_caller 注入")
|
record = import_image_from_url(
|
||||||
image_bytes, image_ext, extra = _normalize_api_result(_api_caller(prompt))
|
image_url=image_url,
|
||||||
record = save_image_to_cache(
|
|
||||||
prompt=prompt,
|
prompt=prompt,
|
||||||
image_bytes=image_bytes,
|
extra={"source": "ai-api", "session_id": sid},
|
||||||
image_ext=image_ext,
|
|
||||||
extra=extra,
|
|
||||||
base_dir=base_dir,
|
base_dir=base_dir,
|
||||||
)
|
)
|
||||||
|
logger.info(
|
||||||
|
"[AIImage] 已写入缓存 sid=%s id=%s path=%s",
|
||||||
|
_mask_sid(sid), record.id, record.image_path,
|
||||||
|
)
|
||||||
on_success(record)
|
on_success(record)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
logger.error(
|
||||||
|
"[AIImage] 生成流程失败 sid=%s %s: %s",
|
||||||
|
_mask_sid(sid), type(exc).__name__, exc,
|
||||||
|
)
|
||||||
on_error(exc)
|
on_error(exc)
|
||||||
|
|
||||||
t = threading.Thread(target=_worker, daemon=True)
|
t = threading.Thread(target=_worker, daemon=True)
|
||||||
@@ -321,18 +514,6 @@ def import_image_from_url_async(
|
|||||||
return t
|
return t
|
||||||
|
|
||||||
|
|
||||||
def _normalize_api_result(result):
|
|
||||||
"""允许 API 返回 ``bytes`` 或 ``(bytes, ext)`` 或 ``(bytes, ext, extra)``。"""
|
|
||||||
if isinstance(result, (bytes, bytearray)):
|
|
||||||
return bytes(result), ".png", None
|
|
||||||
if isinstance(result, tuple):
|
|
||||||
if len(result) == 2:
|
|
||||||
return bytes(result[0]), str(result[1]), None
|
|
||||||
if len(result) == 3:
|
|
||||||
return bytes(result[0]), str(result[1]), result[2]
|
|
||||||
raise ValueError("API 返回格式不支持,需为 bytes 或 (bytes, ext[, extra])")
|
|
||||||
|
|
||||||
|
|
||||||
def is_remote_image_url(value: str) -> bool:
|
def is_remote_image_url(value: str) -> bool:
|
||||||
"""判断输入是否为 http/https 图片地址。"""
|
"""判断输入是否为 http/https 图片地址。"""
|
||||||
url = (value or "").strip()
|
url = (value or "").strip()
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ def create_ai_image_panel(self):
|
|||||||
self.ai_image_current = None # AIImageRecord | None
|
self.ai_image_current = None # AIImageRecord | None
|
||||||
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
||||||
self._ai_image_requesting = False
|
self._ai_image_requesting = False
|
||||||
|
self._ai_image_progress_job = None
|
||||||
|
self._ai_image_progress_phase = 0
|
||||||
|
|
||||||
container = ttk.Frame(frame, padding=10)
|
container = ttk.Frame(frame, padding=10)
|
||||||
container.pack(fill=tk.BOTH, expand=True)
|
container.pack(fill=tk.BOTH, expand=True)
|
||||||
@@ -150,11 +152,24 @@ def create_ai_image_panel(self):
|
|||||||
send_row, textvariable=self.ai_image_status_var,
|
send_row, textvariable=self.ai_image_status_var,
|
||||||
foreground="#888", font=("微软雅黑", 9),
|
foreground="#888", font=("微软雅黑", 9),
|
||||||
).pack(side=tk.LEFT)
|
).pack(side=tk.LEFT)
|
||||||
|
self.ai_image_progress = ttk.Progressbar(
|
||||||
|
send_row,
|
||||||
|
mode="indeterminate",
|
||||||
|
length=120,
|
||||||
|
bootstyle="info-striped",
|
||||||
|
)
|
||||||
|
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
|
||||||
|
self.ai_image_progress.pack_forget()
|
||||||
self.ai_image_send_btn = ttk.Button(
|
self.ai_image_send_btn = ttk.Button(
|
||||||
send_row, text="发送", bootstyle="primary", width=10,
|
send_row, text="发送", bootstyle="primary", width=10,
|
||||||
command=lambda: _send_prompt(self),
|
command=lambda: _send_prompt(self),
|
||||||
)
|
)
|
||||||
self.ai_image_send_btn.pack(side=tk.RIGHT)
|
self.ai_image_send_btn.pack(side=tk.RIGHT)
|
||||||
|
self.ai_image_new_session_btn = ttk.Button(
|
||||||
|
send_row, text="新对话", bootstyle="secondary-outline", width=10,
|
||||||
|
command=lambda: _start_new_session(self),
|
||||||
|
)
|
||||||
|
self.ai_image_new_session_btn.pack(side=tk.RIGHT, padx=(0, 6))
|
||||||
|
|
||||||
# 注册面板
|
# 注册面板
|
||||||
self.register_panel("ai_image", frame, None, "ai_image_visible")
|
self.register_panel("ai_image", frame, None, "ai_image_visible")
|
||||||
@@ -173,17 +188,49 @@ def toggle_ai_image_panel(self):
|
|||||||
|
|
||||||
|
|
||||||
def reload_ai_image_list(self):
|
def reload_ai_image_list(self):
|
||||||
"""重新扫描缓存并刷新列表。"""
|
"""重新扫描缓存并刷新列表。
|
||||||
|
|
||||||
|
按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``),
|
||||||
|
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
|
||||||
|
"""
|
||||||
self.ai_image_records = _svc.list_records()
|
self.ai_image_records = _svc.list_records()
|
||||||
self.ai_image_listbox.delete(0, tk.END)
|
self.ai_image_listbox.delete(0, tk.END)
|
||||||
for rec in self.ai_image_records:
|
# 维护行号 → 记录索引的映射;分隔头处为 None
|
||||||
label = _format_list_label(rec)
|
self._ai_image_row_map = []
|
||||||
|
self._ai_image_row_session_map = []
|
||||||
|
sessions = _svc.group_records_by_session(self.ai_image_records)
|
||||||
|
flat = []
|
||||||
|
current_sid = _svc.get_session_id()
|
||||||
|
for idx, sess in enumerate(sessions, start=1):
|
||||||
|
sid = sess["session_id"]
|
||||||
|
is_current = sid and sid == current_sid
|
||||||
|
header = _format_session_header(idx, sess, is_current=is_current)
|
||||||
|
self.ai_image_listbox.insert(tk.END, header)
|
||||||
|
# 头部行:禁用选中(视觉上变灰)
|
||||||
|
last = self.ai_image_listbox.size() - 1
|
||||||
|
self.ai_image_listbox.itemconfig(
|
||||||
|
last, foreground="#888", selectforeground="#888",
|
||||||
|
background="#f5f5f5", selectbackground="#f5f5f5",
|
||||||
|
)
|
||||||
|
self._ai_image_row_map.append(None)
|
||||||
|
self._ai_image_row_session_map.append(sid)
|
||||||
|
for rec in sess["records"]:
|
||||||
|
label = " " + _format_list_label(rec)
|
||||||
self.ai_image_listbox.insert(tk.END, label)
|
self.ai_image_listbox.insert(tk.END, label)
|
||||||
|
self._ai_image_row_map.append(len(flat))
|
||||||
|
self._ai_image_row_session_map.append(rec.session_id)
|
||||||
|
flat.append(rec)
|
||||||
|
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
|
||||||
|
self.ai_image_records = flat
|
||||||
if self.ai_image_records:
|
if self.ai_image_records:
|
||||||
|
# 选中第一张实际记录
|
||||||
|
for row, ridx in enumerate(self._ai_image_row_map):
|
||||||
|
if ridx is not None:
|
||||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||||
self.ai_image_listbox.selection_set(0)
|
self.ai_image_listbox.selection_set(row)
|
||||||
self.ai_image_listbox.activate(0)
|
self.ai_image_listbox.activate(row)
|
||||||
_select_record(self, self.ai_image_records[0])
|
_select_record(self, self.ai_image_records[ridx])
|
||||||
|
break
|
||||||
else:
|
else:
|
||||||
self.ai_image_current = None
|
self.ai_image_current = None
|
||||||
self.ai_image_photo = None
|
self.ai_image_photo = None
|
||||||
@@ -191,6 +238,14 @@ def reload_ai_image_list(self):
|
|||||||
self.ai_image_meta_var.set("暂无缓存图片")
|
self.ai_image_meta_var.set("暂无缓存图片")
|
||||||
|
|
||||||
|
|
||||||
|
def _format_session_header(index: int, sess: dict, is_current: bool) -> str:
|
||||||
|
started = (sess.get("started_at") or "").replace("T", " ")[:16]
|
||||||
|
tag = "(当前)" if is_current else ""
|
||||||
|
if sess.get("session_id"):
|
||||||
|
return f"── 会话 #{index} · {started} {tag}──"
|
||||||
|
return f"── 未归类 · {started} ──"
|
||||||
|
|
||||||
|
|
||||||
def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
||||||
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
|
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
|
||||||
size_tag = ""
|
size_tag = ""
|
||||||
@@ -205,21 +260,34 @@ def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
prompt_line = (rec.prompt or "(无提示)").strip().splitlines()[0]
|
name_line = rec.display_name.splitlines()[0] if rec.display_name else "(未命名)"
|
||||||
# 剩余可用宽度(width=34)去掉 size_tag
|
# 列表宽度 width=34,需要扣除两格缩进 + size_tag
|
||||||
max_prompt = 34 - len(size_tag) - 2
|
max_name = 34 - 2 - len(size_tag) - 2
|
||||||
if max_prompt > 4 and len(prompt_line) > max_prompt:
|
if max_name > 4 and len(name_line) > max_name:
|
||||||
prompt_line = prompt_line[:max_prompt] + "…"
|
name_line = name_line[:max_name] + "…"
|
||||||
return f"{size_tag}{prompt_line}"
|
return f"{size_tag}{name_line}"
|
||||||
|
|
||||||
|
|
||||||
def _on_list_select(self):
|
def _on_list_select(self):
|
||||||
sel = self.ai_image_listbox.curselection()
|
sel = self.ai_image_listbox.curselection()
|
||||||
if not sel:
|
if not sel:
|
||||||
return
|
return
|
||||||
idx = sel[0]
|
row = sel[0]
|
||||||
if 0 <= idx < len(self.ai_image_records):
|
row_map = getattr(self, "_ai_image_row_map", None) or []
|
||||||
_select_record(self, self.ai_image_records[idx])
|
if row >= len(row_map):
|
||||||
|
return
|
||||||
|
ridx = row_map[row]
|
||||||
|
if ridx is None:
|
||||||
|
session_id = _session_id_for_row(self, row)
|
||||||
|
if session_id:
|
||||||
|
_switch_to_session(self, session_id, show_message=False)
|
||||||
|
self.ai_image_listbox.selection_clear(row)
|
||||||
|
return
|
||||||
|
if 0 <= ridx < len(self.ai_image_records):
|
||||||
|
rec = self.ai_image_records[ridx]
|
||||||
|
if rec.session_id:
|
||||||
|
_switch_to_session(self, rec.session_id, show_message=False, target_record_id=rec.id)
|
||||||
|
_select_record(self, rec)
|
||||||
|
|
||||||
|
|
||||||
def _select_record(self, rec: _svc.AIImageRecord):
|
def _select_record(self, rec: _svc.AIImageRecord):
|
||||||
@@ -243,6 +311,7 @@ def _redraw_preview(self):
|
|||||||
ch = canvas.winfo_height() or 1
|
ch = canvas.winfo_height() or 1
|
||||||
try:
|
try:
|
||||||
img = Image.open(rec.image_path)
|
img = Image.open(rec.image_path)
|
||||||
|
img.load()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
|
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
|
||||||
return
|
return
|
||||||
@@ -257,6 +326,58 @@ def _redraw_preview(self):
|
|||||||
# ---------------- 发送 / 保存 / 删除 ----------------
|
# ---------------- 发送 / 保存 / 删除 ----------------
|
||||||
|
|
||||||
|
|
||||||
|
def _start_new_session(self):
|
||||||
|
"""开启新的对话会话,后续生成将使用新的 session_id。"""
|
||||||
|
if getattr(self, "_ai_image_requesting", False):
|
||||||
|
messagebox.showinfo("提示", "请等待当前请求完成")
|
||||||
|
return
|
||||||
|
_svc.reset_session()
|
||||||
|
self.ai_image_status_var.set("已开启新对话")
|
||||||
|
reload_ai_image_list(self)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_id_for_row(self, row: int) -> str:
|
||||||
|
session_map = getattr(self, "_ai_image_row_session_map", None) or []
|
||||||
|
if row < 0 or row >= len(session_map):
|
||||||
|
return ""
|
||||||
|
return session_map[row] or ""
|
||||||
|
|
||||||
|
|
||||||
|
def _switch_to_session(self, session_id: str, show_message: bool = True, target_record_id: str = ""):
|
||||||
|
sid = (session_id or "").strip()
|
||||||
|
if not sid:
|
||||||
|
return
|
||||||
|
if sid == _svc.get_session_id():
|
||||||
|
return
|
||||||
|
_svc.set_session_id(sid)
|
||||||
|
reload_ai_image_list(self)
|
||||||
|
if target_record_id:
|
||||||
|
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||||
|
if ridx is None:
|
||||||
|
continue
|
||||||
|
rec = self.ai_image_records[ridx]
|
||||||
|
if rec.id == target_record_id:
|
||||||
|
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||||
|
self.ai_image_listbox.selection_set(row)
|
||||||
|
self.ai_image_listbox.activate(row)
|
||||||
|
self.ai_image_listbox.see(row)
|
||||||
|
_select_record(self, rec)
|
||||||
|
break
|
||||||
|
self.ai_image_status_var.set("已切换到历史对话")
|
||||||
|
if show_message:
|
||||||
|
messagebox.showinfo("提示", "已切换到所选历史对话")
|
||||||
|
|
||||||
|
|
||||||
|
def _update_request_progress(self):
|
||||||
|
if not getattr(self, "_ai_image_requesting", False):
|
||||||
|
self._ai_image_progress_job = None
|
||||||
|
return
|
||||||
|
phases = ["后端处理中…", "正在生成图片…", "正在下载结果…", "即将完成…"]
|
||||||
|
self.ai_image_status_var.set(phases[self._ai_image_progress_phase % len(phases)])
|
||||||
|
self._ai_image_progress_phase += 1
|
||||||
|
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))
|
||||||
|
|
||||||
|
|
||||||
def _send_prompt(self):
|
def _send_prompt(self):
|
||||||
if getattr(self, "_ai_image_requesting", False):
|
if getattr(self, "_ai_image_requesting", False):
|
||||||
return
|
return
|
||||||
@@ -267,7 +388,7 @@ def _send_prompt(self):
|
|||||||
|
|
||||||
_set_requesting(self, True)
|
_set_requesting(self, True)
|
||||||
is_remote_url = _svc.is_remote_image_url(prompt)
|
is_remote_url = _svc.is_remote_image_url(prompt)
|
||||||
self.ai_image_status_var.set("下载中…" if is_remote_url else "请求中…")
|
self.ai_image_status_var.set("下载中…" if is_remote_url else "后端处理中…")
|
||||||
|
|
||||||
def _success(record):
|
def _success(record):
|
||||||
self.root.after(0, lambda: _on_request_done(self, record, None))
|
self.root.after(0, lambda: _on_request_done(self, record, None))
|
||||||
@@ -283,17 +404,6 @@ def _send_prompt(self):
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not _svc.has_api():
|
|
||||||
_set_requesting(self, False)
|
|
||||||
self.ai_image_status_var.set("就绪")
|
|
||||||
messagebox.showerror(
|
|
||||||
"API 未配置",
|
|
||||||
"AI 图片 API 尚未接入。\n"
|
|
||||||
"可直接输入图片 URL 导入,或在启动时通过 "
|
|
||||||
"app.services.ai_image.set_api_caller(...) 注入真实实现。",
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
|
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
|
||||||
|
|
||||||
|
|
||||||
@@ -301,6 +411,23 @@ def _set_requesting(self, flag: bool):
|
|||||||
self._ai_image_requesting = flag
|
self._ai_image_requesting = flag
|
||||||
try:
|
try:
|
||||||
self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
|
self.ai_image_send_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
|
||||||
|
self.ai_image_new_session_btn.configure(state=tk.DISABLED if flag else tk.NORMAL)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if flag:
|
||||||
|
self._ai_image_progress_phase = 0
|
||||||
|
self.ai_image_progress.pack(side=tk.LEFT, padx=(8, 0))
|
||||||
|
self.ai_image_progress.start(10)
|
||||||
|
if self._ai_image_progress_job is not None:
|
||||||
|
self.root.after_cancel(self._ai_image_progress_job)
|
||||||
|
self._ai_image_progress_job = self.root.after(0, lambda: _update_request_progress(self))
|
||||||
|
else:
|
||||||
|
if self._ai_image_progress_job is not None:
|
||||||
|
self.root.after_cancel(self._ai_image_progress_job)
|
||||||
|
self._ai_image_progress_job = None
|
||||||
|
try:
|
||||||
|
self.ai_image_progress.stop()
|
||||||
|
self.ai_image_progress.pack_forget()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -314,13 +441,16 @@ def _on_request_done(self, record, exc):
|
|||||||
self.ai_image_status_var.set("完成")
|
self.ai_image_status_var.set("完成")
|
||||||
self.ai_image_input.delete("1.0", tk.END)
|
self.ai_image_input.delete("1.0", tk.END)
|
||||||
reload_ai_image_list(self)
|
reload_ai_image_list(self)
|
||||||
# 定位到新生成项(最新在前)
|
|
||||||
if record is not None and self.ai_image_records:
|
if record is not None and self.ai_image_records:
|
||||||
for i, r in enumerate(self.ai_image_records):
|
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||||
|
if ridx is None:
|
||||||
|
continue
|
||||||
|
r = self.ai_image_records[ridx]
|
||||||
if r.id == record.id:
|
if r.id == record.id:
|
||||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||||
self.ai_image_listbox.selection_set(i)
|
self.ai_image_listbox.selection_set(row)
|
||||||
self.ai_image_listbox.activate(i)
|
self.ai_image_listbox.activate(row)
|
||||||
|
self.ai_image_listbox.see(row)
|
||||||
_select_record(self, r)
|
_select_record(self, r)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -358,53 +488,41 @@ def _delete_current(self):
|
|||||||
|
|
||||||
|
|
||||||
def _rename_current(self):
|
def _rename_current(self):
|
||||||
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。"""
|
"""弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。"""
|
||||||
rec = getattr(self, "ai_image_current", None)
|
rec = getattr(self, "ai_image_current", None)
|
||||||
if rec is None:
|
if rec is None:
|
||||||
messagebox.showinfo("提示", "请先选择一张图片")
|
messagebox.showinfo("提示", "请先选择一张图片")
|
||||||
return
|
return
|
||||||
|
|
||||||
current_name = rec.prompt or ""
|
current = rec.title or rec.display_name
|
||||||
new_name = simpledialog.askstring(
|
new_name = simpledialog.askstring(
|
||||||
"重命名",
|
"重命名",
|
||||||
"修改备注名称(显示在分辨率标签后面):",
|
"修改显示标题(留空可恢复使用原始提示词):",
|
||||||
initialvalue=current_name,
|
initialvalue=current,
|
||||||
parent=self.root,
|
parent=self.root,
|
||||||
)
|
)
|
||||||
if new_name is None: # 用户点了取消
|
if new_name is None: # 取消
|
||||||
return
|
return
|
||||||
new_name = new_name.strip()
|
new_name = new_name.strip()
|
||||||
if not new_name:
|
if new_name == (rec.title or ""):
|
||||||
messagebox.showwarning("提示", "备注名称不能为空")
|
|
||||||
return
|
|
||||||
if new_name == current_name:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 写回 JSON 元数据
|
if not _svc.update_record_title(rec, new_name):
|
||||||
try:
|
messagebox.showerror("保存失败", "无法更新元数据,请检查文件权限。")
|
||||||
import json
|
|
||||||
meta_path = os.path.splitext(rec.image_path)[0] + ".json"
|
|
||||||
meta = {}
|
|
||||||
if os.path.isfile(meta_path):
|
|
||||||
with open(meta_path, "r", encoding="utf-8") as f:
|
|
||||||
meta = json.load(f)
|
|
||||||
meta["prompt"] = new_name
|
|
||||||
with open(meta_path, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|
||||||
except Exception as exc:
|
|
||||||
messagebox.showerror("保存失败", f"无法更新元数据:\n{exc}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# 同步内存中的记录并刷新列表
|
target_id = rec.id
|
||||||
rec.prompt = new_name
|
|
||||||
reload_ai_image_list(self)
|
reload_ai_image_list(self)
|
||||||
# 重新定位到刚才被重命名的图片
|
# 重新定位
|
||||||
for i, r in enumerate(self.ai_image_records):
|
for row, ridx in enumerate(getattr(self, "_ai_image_row_map", []) or []):
|
||||||
if r.id == rec.id:
|
if ridx is None:
|
||||||
|
continue
|
||||||
|
r = self.ai_image_records[ridx]
|
||||||
|
if r.id == target_id:
|
||||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||||
self.ai_image_listbox.selection_set(i)
|
self.ai_image_listbox.selection_set(row)
|
||||||
self.ai_image_listbox.activate(i)
|
self.ai_image_listbox.activate(row)
|
||||||
self.ai_image_listbox.see(i)
|
self.ai_image_listbox.see(row)
|
||||||
_select_record(self, r)
|
_select_record(self, r)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -415,14 +533,16 @@ def _rename_current(self):
|
|||||||
def _show_list_context_menu(self, event):
|
def _show_list_context_menu(self, event):
|
||||||
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
|
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
|
||||||
try:
|
try:
|
||||||
idx = self.ai_image_listbox.nearest(event.y)
|
row = self.ai_image_listbox.nearest(event.y)
|
||||||
except Exception:
|
except Exception:
|
||||||
idx = -1
|
row = -1
|
||||||
if 0 <= idx < len(self.ai_image_records):
|
row_map = getattr(self, "_ai_image_row_map", None) or []
|
||||||
|
ridx = row_map[row] if 0 <= row < len(row_map) else None
|
||||||
|
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
|
||||||
self.ai_image_listbox.selection_clear(0, tk.END)
|
self.ai_image_listbox.selection_clear(0, tk.END)
|
||||||
self.ai_image_listbox.selection_set(idx)
|
self.ai_image_listbox.selection_set(row)
|
||||||
self.ai_image_listbox.activate(idx)
|
self.ai_image_listbox.activate(row)
|
||||||
_select_record(self, self.ai_image_records[idx])
|
_select_record(self, self.ai_image_records[ridx])
|
||||||
|
|
||||||
has_selection = self.ai_image_current is not None
|
has_selection = self.ai_image_current is not None
|
||||||
ucd = getattr(self, "ucd", None)
|
ucd = getattr(self, "ucd", None)
|
||||||
|
|||||||
BIN
docs/接口文档-测试环境.pdf
Normal file
BIN
docs/接口文档-测试环境.pdf
Normal file
Binary file not shown.
@@ -877,6 +877,14 @@ class PQAutomationApp:
|
|||||||
|
|
||||||
def main():
|
def main():
|
||||||
try:
|
try:
|
||||||
|
# 全局日志:默认 INFO 输出到 stderr,便于排查 AI 接口等关键事件
|
||||||
|
import logging as _logging
|
||||||
|
if not _logging.getLogger().handlers:
|
||||||
|
_logging.basicConfig(
|
||||||
|
level=_logging.INFO,
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
# root = tk.Tk()
|
# root = tk.Tk()
|
||||||
root = ttk.Window(themename="yeti")
|
root = ttk.Window(themename="yeti")
|
||||||
app = PQAutomationApp(root)
|
app = PQAutomationApp(root)
|
||||||
|
|||||||
Reference in New Issue
Block a user