Files
pqAutomationApp/app/views/panels/ai_image_panel.py

1067 lines
37 KiB
Python
Raw Normal View History

2026-04-21 14:06:48 +08:00
"""AI 图片对话面板:输入提示 → 生成 → 显示 → 列表切换/保存/删除。"""
from __future__ import annotations
import os
2026-05-22 11:31:36 +08:00
import sys
import threading
2026-05-28 17:34:51 +08:00
import time
import logging
2026-04-21 14:06:48 +08:00
import tkinter as tk
from tkinter import filedialog, messagebox, simpledialog
2026-04-21 14:06:48 +08:00
import ttkbootstrap as ttk
from PIL import Image, ImageTk
from app.services import ai_image as _svc
2026-05-24 11:21:30 +08:00
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from pqAutomationApp import PQAutomationApp
2026-05-28 17:34:51 +08:00
logger = logging.getLogger(__name__)
2026-05-28 16:41:52 +08:00
def _theme_colors():
style = ttk.Style()
colors = style.colors
return {
"bg": colors.bg,
"fg": colors.fg,
"muted": colors.secondary,
"input_bg": colors.inputbg,
"input_fg": colors.inputfg,
"select_bg": colors.selectbg,
"select_fg": colors.selectfg,
"border": colors.border,
}
2026-05-28 17:34:51 +08:00
def _apply_ai_image_list_style(self: "PQAutomationApp"):
"""刷新 AI 图片列表控件样式,尽量贴合当前主题色板。"""
palette = _theme_colors()
style = ttk.Style()
style.configure(
"AIImage.Treeview",
background=palette["input_bg"],
fieldbackground=palette["input_bg"],
foreground=palette["input_fg"],
bordercolor=palette["border"],
lightcolor=palette["border"],
darkcolor=palette["border"],
rowheight=24,
font=("微软雅黑", 9),
)
style.configure(
"AIImage.Treeview.Heading",
background=palette["bg"],
foreground=palette["muted"],
bordercolor=palette["border"],
font=("微软雅黑", 9, "bold"),
)
style.map(
"AIImage.Treeview",
background=[("selected", palette["select_bg"])],
foreground=[("selected", palette["select_fg"])],
)
def _hide_tree_tooltip(self: "PQAutomationApp"):
tip = getattr(self, "_ai_image_tooltip", None)
if tip is None:
return
try:
tip.withdraw()
except Exception:
pass
self._ai_image_tooltip_item = ""
def _show_tree_tooltip(self: "PQAutomationApp", text: str, x_root: int, y_root: int, item_id: str):
if not text:
_hide_tree_tooltip(self)
return
tip = getattr(self, "_ai_image_tooltip", None)
if tip is None:
tip = tk.Toplevel(self.root)
tip.withdraw()
tip.overrideredirect(True)
tip.transient(self.root)
label = tk.Label(
tip,
text="",
justify=tk.LEFT,
anchor=tk.W,
bg="#ffffff",
fg="#1f2937",
relief=tk.SOLID,
bd=1,
padx=8,
pady=6,
font=("微软雅黑", 9),
wraplength=520,
)
label.pack(fill=tk.BOTH, expand=True)
self._ai_image_tooltip = tip
self._ai_image_tooltip_label = label
else:
label = getattr(self, "_ai_image_tooltip_label", None)
if label is None:
return
self._ai_image_tooltip_item = item_id
label.configure(text=text)
tip.geometry(f"+{x_root + 14}+{y_root + 18}")
tip.deiconify()
tip.lift()
def _on_tree_motion(self: "PQAutomationApp", event):
tree = self.ai_image_tree
item_id = tree.identify_row(event.y)
col = tree.identify_column(event.x)
region = tree.identify("region", event.x, event.y)
if not item_id or col != "#0" or region not in {"tree", "cell"}:
_hide_tree_tooltip(self)
return
node_map = getattr(self, "_ai_image_node_map", None) or {}
ridx = node_map.get(item_id)
if ridx is None or ridx < 0 or ridx >= len(self.ai_image_records):
_hide_tree_tooltip(self)
return
rec = self.ai_image_records[ridx]
full_text = (rec.display_name or "").strip()
if not full_text:
_hide_tree_tooltip(self)
return
# 悬浮到任意图片标题项时都显示完整描述,避免因截断判定误差导致无响应。
if getattr(self, "_ai_image_tooltip_item", "") == item_id:
tip = getattr(self, "_ai_image_tooltip", None)
if tip is not None:
try:
tip.geometry(f"+{event.x_root + 14}+{event.y_root + 18}")
except Exception:
pass
return
_show_tree_tooltip(self, full_text, event.x_root, event.y_root, item_id)
2026-05-24 11:21:30 +08:00
2026-04-21 14:06:48 +08:00
# ---------------- 面板创建 ----------------
def create_ai_image_panel(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
"""创建 AI 图片对话面板,并注册到面板管理。"""
frame = ttk.Frame(self.content_frame)
self.ai_image_frame = frame
# 内部状态
self.ai_image_records = [] # list[AIImageRecord]
self.ai_image_current = None # AIImageRecord | None
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
2026-04-21 14:06:48 +08:00
self._ai_image_requesting = False
self._ai_image_progress_job = None
self._ai_image_progress_phase = 0
2026-04-29 19:10:27 +08:00
self._ai_image_cancel_event = None
self._ai_image_request_seq = 0
self._ai_image_active_seq = 0
2026-05-28 17:34:51 +08:00
self._ai_image_node_map = {}
self._ai_image_session_node_map = {}
self._ai_image_all_records = []
self._ai_image_search_var = tk.StringVar(value="")
self._ai_image_count_var = tk.StringVar(value="0 张图片")
self._ai_image_reloading = False
self._ai_image_select_guard = False
self._ai_image_list_loaded = False
self._ai_image_tooltip = None
self._ai_image_tooltip_label = None
self._ai_image_tooltip_item = ""
2026-04-21 14:06:48 +08:00
container = ttk.Frame(frame, padding=10)
container.pack(fill=tk.BOTH, expand=True)
# 左列:图片列表
# 使用 grid + 权重,让右侧预览区优先占据剩余空间。
container.columnconfigure(0, weight=0)
container.columnconfigure(1, weight=1)
container.rowconfigure(0, weight=1)
2026-05-28 16:41:52 +08:00
palette = _theme_colors()
left = ttk.Frame(container, width=360)
left.grid(row=0, column=0, sticky=tk.NS, padx=(0, 10))
left.grid_propagate(False)
2026-04-21 14:06:48 +08:00
2026-05-28 17:34:51 +08:00
title_row = ttk.Frame(left)
title_row.pack(fill=tk.X, pady=(0, 4))
ttk.Label(title_row, text="历史图片", font=("微软雅黑", 10, "bold")).pack(side=tk.LEFT)
ttk.Label(
title_row,
textvariable=self._ai_image_count_var,
foreground=palette["muted"],
font=("微软雅黑", 9),
).pack(side=tk.RIGHT)
search_row = ttk.Frame(left)
search_row.pack(fill=tk.X, pady=(0, 6))
ttk.Label(
search_row,
text="搜索",
foreground=palette["muted"],
font=("微软雅黑", 9),
).pack(side=tk.LEFT, padx=(0, 6))
self.ai_image_search_entry = ttk.Entry(
search_row,
textvariable=self._ai_image_search_var,
bootstyle="secondary",
2026-04-21 14:06:48 +08:00
)
2026-05-28 17:34:51 +08:00
self.ai_image_search_entry.pack(side=tk.LEFT, fill=tk.X, expand=True)
self.ai_image_search_entry.bind("<KeyRelease>", lambda e: reload_ai_image_list(self, auto_select_first=False))
ttk.Button(
search_row,
text="清空",
width=6,
bootstyle="secondary-outline",
command=lambda: _clear_list_search(self),
).pack(side=tk.LEFT, padx=(6, 0))
2026-04-21 14:06:48 +08:00
list_wrap = ttk.Frame(left, padding=2)
list_wrap.pack(fill=tk.BOTH, expand=True)
2026-05-28 17:34:51 +08:00
list_wrap.columnconfigure(0, weight=1)
list_wrap.rowconfigure(0, weight=1)
2026-04-21 14:06:48 +08:00
2026-05-28 17:34:51 +08:00
_apply_ai_image_list_style(self)
2026-04-21 14:06:48 +08:00
scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL)
2026-05-28 17:34:51 +08:00
self.ai_image_tree = ttk.Treeview(
2026-04-21 14:06:48 +08:00
list_wrap,
2026-05-28 17:34:51 +08:00
columns=("time",),
show="tree headings",
selectmode="browse",
style="AIImage.Treeview",
2026-04-21 14:06:48 +08:00
yscrollcommand=scroll.set,
)
2026-05-28 17:34:51 +08:00
self.ai_image_tree.heading("#0", text="标题")
self.ai_image_tree.heading("time", text="时间")
self.ai_image_tree.column("#0", width=210, minwidth=140, anchor=tk.W)
self.ai_image_tree.column("time", width=105, minwidth=90, stretch=False, anchor=tk.W)
scroll.config(command=self.ai_image_tree.yview)
self.ai_image_tree.grid(row=0, column=0, sticky=tk.NSEW)
scroll.grid(row=0, column=1, sticky=tk.NS)
self.ai_image_tree.bind("<<TreeviewSelect>>", lambda e: _on_list_select(self))
self.ai_image_tree.bind("<Double-1>", lambda e: _on_tree_double_click(self, e))
self.ai_image_tree.bind("<Motion>", lambda e: _on_tree_motion(self, e))
self.ai_image_tree.bind("<Leave>", lambda e: _hide_tree_tooltip(self))
# 右键菜单:发送到 UCD / 重命名 / 另存为 / 删除
# 索引: 0=发送, 1=sep, 2=重命名, 3=另存为, 4=删除
self.ai_image_menu = tk.Menu(self.root, tearoff=0)
self.ai_image_menu.add_command(
label="发送图片",
command=lambda: _send_to_ucd(self),
)
self.ai_image_menu.add_separator()
self.ai_image_menu.add_command(
label="重命名…",
command=lambda: _rename_current(self),
)
self.ai_image_menu.add_command(
label="另存为…",
command=lambda: _save_current(self),
)
self.ai_image_menu.add_command(
label="删除",
command=lambda: _delete_current(self),
)
2026-05-28 17:34:51 +08:00
self.ai_image_tree.bind(
"<Button-3>",
lambda e: _show_list_context_menu(self, e),
)
2026-04-21 14:06:48 +08:00
btn_row = ttk.Frame(left)
btn_row.pack(fill=tk.X, pady=(6, 0))
self.ai_image_send_ucd_btn = ttk.Button(
btn_row, text="发送", bootstyle="info-outline", width=8,
command=lambda: _send_to_ucd(self),
)
self.ai_image_send_ucd_btn.pack(side=tk.LEFT, padx=(0, 4))
2026-04-21 14:06:48 +08:00
ttk.Button(
btn_row, text="保存", bootstyle="success-outline", width=8,
command=lambda: _save_current(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
btn_row, text="删除", bootstyle="danger-outline", width=8,
command=lambda: _delete_current(self),
).pack(side=tk.LEFT, padx=(0, 4))
ttk.Button(
btn_row, text="刷新", bootstyle="secondary-outline", width=8,
command=lambda: reload_ai_image_list(self),
).pack(side=tk.LEFT)
# 右列:预览 + 输入
right = ttk.Frame(container)
right.grid(row=0, column=1, sticky=tk.NSEW)
2026-04-21 14:06:48 +08:00
preview_frame = ttk.LabelFrame(right, text="图片预览", padding=6)
preview_frame.pack(fill=tk.BOTH, expand=True)
self.ai_image_canvas = tk.Canvas(
2026-05-28 16:41:52 +08:00
preview_frame, bg=palette["bg"], highlightthickness=0
2026-04-21 14:06:48 +08:00
)
self.ai_image_canvas.pack(fill=tk.BOTH, expand=True)
self.ai_image_canvas.bind("<Configure>", lambda e: _redraw_preview(self))
meta_row = ttk.Frame(right)
meta_row.pack(fill=tk.X, pady=(4, 4))
self.ai_image_meta_var = tk.StringVar(value="未选择图片")
ttk.Label(
meta_row, textvariable=self.ai_image_meta_var,
2026-05-28 16:41:52 +08:00
foreground=palette["muted"], font=("微软雅黑", 9),
2026-04-21 14:06:48 +08:00
).pack(side=tk.LEFT)
# 输入区
input_frame = ttk.LabelFrame(right, text="提示输入Ctrl+Enter 发送)", padding=6)
input_frame.pack(fill=tk.X, pady=(4, 0))
2026-05-28 16:41:52 +08:00
self.ai_image_input = tk.Text(
input_frame,
height=3,
wrap=tk.WORD,
bg=palette["input_bg"],
fg=palette["input_fg"],
insertbackground=palette["input_fg"],
)
2026-04-21 14:06:48 +08:00
self.ai_image_input.pack(fill=tk.X, side=tk.TOP)
self.ai_image_input.bind("<Control-Return>", lambda e: (_send_prompt(self), "break"))
send_row = ttk.Frame(input_frame)
send_row.pack(fill=tk.X, pady=(4, 0))
self.ai_image_status_var = tk.StringVar(value="就绪")
ttk.Label(
send_row, textvariable=self.ai_image_status_var,
2026-05-28 16:41:52 +08:00
foreground=palette["muted"], font=("微软雅黑", 9),
2026-04-21 14:06:48 +08:00
).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()
2026-04-21 14:06:48 +08:00
self.ai_image_send_btn = ttk.Button(
send_row, text="发送", bootstyle="primary", width=10,
command=lambda: _send_prompt(self),
)
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))
2026-04-29 19:10:27 +08:00
self.ai_image_stop_btn = ttk.Button(
send_row, text="停止", bootstyle="danger-outline", width=10,
command=lambda: _stop_request(self),
)
self.ai_image_stop_btn.pack(side=tk.RIGHT, padx=(0, 6))
self.ai_image_stop_btn.configure(state=tk.DISABLED)
2026-04-21 14:06:48 +08:00
# 注册面板
self.register_panel("ai_image", frame, None, "ai_image_visible")
self.ai_image_visible = False
2026-05-28 17:34:51 +08:00
self.ai_image_meta_var.set("首次打开 AI 图片面板时加载缓存")
2026-04-21 14:06:48 +08:00
def toggle_ai_image_panel(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
"""切换 AI 图片面板显隐。"""
self.show_panel("ai_image")
2026-05-28 17:34:51 +08:00
_apply_ai_image_list_style(self)
if not getattr(self, "_ai_image_list_loaded", False):
logger.info("[AIImagePanel] 首次显示面板,开始加载列表")
reload_ai_image_list(self)
self._ai_image_list_loaded = True
2026-04-21 14:06:48 +08:00
def _get_app_base_dir(self: "PQAutomationApp") -> str:
2026-05-22 11:31:36 +08:00
"""返回应用根目录settings 的上一级)。"""
if getattr(self, "config_file", None):
return os.path.dirname(os.path.dirname(self.config_file))
if getattr(sys, "frozen", False):
return os.path.dirname(sys.executable)
return os.path.dirname(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
2026-04-21 14:06:48 +08:00
# ---------------- 列表 / 选中 ----------------
def reload_ai_image_list(self: "PQAutomationApp", auto_select_first=True):
"""重新扫描缓存并刷新列表。
``session_id`` 分组每个会话用一行不可选的分隔头`` 会话 #N · 时间 ──``
其下列出该轮生成的所有图片会话按"最近使用"倒序组内按时间倒序
2026-04-29 16:43:31 +08:00
auto_select_first: 是否自动选中第一张图片默认 True
"""
2026-05-28 17:34:51 +08:00
t0 = time.monotonic()
if getattr(self, "_ai_image_reloading", False):
logger.debug("[AIImagePanel] 忽略重入 reload 请求")
return
self._ai_image_reloading = True
try:
_apply_ai_image_list_style(self)
all_records = _svc.list_records(base_dir=_get_app_base_dir(self))
self._ai_image_all_records = list(all_records)
self.ai_image_tree.delete(*self.ai_image_tree.get_children())
self._ai_image_node_map = {}
self._ai_image_session_node_map = {}
keyword = (self._ai_image_search_var.get() or "").strip().lower()
records = []
for rec in all_records:
if not keyword:
records.append(rec)
continue
hay = "\n".join([
rec.display_name or "",
rec.prompt or "",
os.path.basename(rec.image_path),
]).lower()
if keyword in hay:
records.append(rec)
self.ai_image_records = records
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)
parent_id = self.ai_image_tree.insert(
"",
tk.END,
text=header,
values=("",),
open=True,
)
self._ai_image_session_node_map[parent_id] = sid
for rec in sess["records"]:
label = _format_list_label(rec)
created = (rec.created_at or "").replace("T", " ")[:16]
node_id = self.ai_image_tree.insert(
parent_id,
tk.END,
text=label,
values=(created,),
)
self._ai_image_node_map[node_id] = len(flat)
flat.append(rec)
# 替换为按显示顺序展平后的列表,便于其它逻辑(前一张/后一张等)
self.ai_image_records = flat
total_count = len(all_records)
visible_count = len(self.ai_image_records)
if keyword:
self._ai_image_count_var.set(f"{visible_count}/{total_count}")
else:
self._ai_image_count_var.set(f"{visible_count} 张图片")
if self.ai_image_records and auto_select_first:
first_id = ""
for parent in self.ai_image_tree.get_children(""):
children = self.ai_image_tree.get_children(parent)
if children:
first_id = children[0]
break
if first_id:
_set_tree_selection(self, first_id)
else:
self.ai_image_current = None
self.ai_image_photo = None
self.ai_image_canvas.delete("all")
self.ai_image_meta_var.set("暂无缓存图片" if not keyword else "当前筛选无结果")
logger.info(
"[AIImagePanel] reload 完成 total=%d visible=%d sessions=%d keyword=%r elapsed=%.3fs",
total_count,
visible_count,
len(sessions),
keyword,
time.monotonic() - t0,
)
2026-05-28 17:34:51 +08:00
self._ai_image_list_loaded = True
finally:
self._ai_image_reloading = False
2026-04-21 14:06:48 +08:00
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 ""
2026-05-28 17:34:51 +08:00
count = len(sess.get("records") or [])
if sess.get("session_id"):
2026-05-28 17:34:51 +08:00
return f"会话 #{index} · {started} · {count}{tag}".strip()
return f"未归类 · {started} · {count}"
2026-04-21 14:06:48 +08:00
def _format_list_label(rec: _svc.AIImageRecord) -> str:
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
size_tag = ""
extra = rec.extra or {}
if isinstance(extra, dict) and extra.get("size"):
size_tag = f"[{extra['size']}] "
name_line = rec.display_name.splitlines()[0] if rec.display_name else "(未命名)"
2026-05-28 17:34:51 +08:00
# Treeview 标题列可变宽,给展示名预留较长空间
max_name = 40 - len(size_tag)
if max_name > 4 and len(name_line) > max_name:
name_line = name_line[:max_name] + ""
return f"{size_tag}{name_line}"
2026-04-21 14:06:48 +08:00
2026-05-28 17:34:51 +08:00
def _clear_list_search(self: "PQAutomationApp"):
if (self._ai_image_search_var.get() or "").strip() == "":
return
self._ai_image_search_var.set("")
reload_ai_image_list(self, auto_select_first=False)
_hide_tree_tooltip(self)
def _set_tree_selection(self: "PQAutomationApp", item_id: str):
if not item_id:
return
_hide_tree_tooltip(self)
try:
current = self.ai_image_tree.selection()
if current and current[0] == item_id:
node_map = getattr(self, "_ai_image_node_map", None) or {}
ridx = node_map.get(item_id)
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
_select_record(self, self.ai_image_records[ridx])
return
self.ai_image_tree.selection_set(item_id)
self.ai_image_tree.focus(item_id)
self.ai_image_tree.see(item_id)
except Exception:
return
node_map = getattr(self, "_ai_image_node_map", None) or {}
ridx = node_map.get(item_id)
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
_select_record(self, self.ai_image_records[ridx])
def _find_tree_item_by_record_id(self: "PQAutomationApp", record_id: str) -> str:
if not record_id:
return ""
node_map = getattr(self, "_ai_image_node_map", None) or {}
for item_id, ridx in node_map.items():
if ridx is None or ridx >= len(self.ai_image_records):
continue
rec = self.ai_image_records[ridx]
if rec.id == record_id:
return item_id
return ""
def _on_list_select(self: "PQAutomationApp"):
2026-05-28 17:34:51 +08:00
if getattr(self, "_ai_image_reloading", False):
return
if getattr(self, "_ai_image_select_guard", False):
logger.debug("[AIImagePanel] 忽略重入选择事件")
return
sel = self.ai_image_tree.selection()
2026-04-21 14:06:48 +08:00
if not sel:
return
2026-05-28 17:34:51 +08:00
self._ai_image_select_guard = True
try:
item_id = sel[0]
node_map = getattr(self, "_ai_image_node_map", None) or {}
ridx = node_map.get(item_id)
if ridx is None:
session_id = _session_id_for_item(self, item_id)
if session_id:
logger.info("[AIImagePanel] 选中会话头 sid=%s", session_id[:8])
_switch_to_session(self, session_id, show_message=False, refresh_list=False)
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,
refresh_list=False,
)
_select_record(self, rec)
logger.debug("[AIImagePanel] 选中图片 id=%s sid=%s", rec.id, (rec.session_id or "")[:8])
finally:
self._ai_image_select_guard = False
def _on_tree_double_click(self: "PQAutomationApp", event):
"""双击会话头时只切换会话并展开;双击记录保持默认行为。"""
item_id = self.ai_image_tree.identify_row(event.y)
if not item_id:
return
2026-05-28 17:34:51 +08:00
node_map = getattr(self, "_ai_image_node_map", None) or {}
if item_id in node_map:
return
2026-05-28 17:34:51 +08:00
sid = _session_id_for_item(self, item_id)
if sid:
_switch_to_session(self, sid, show_message=False, refresh_list=False)
2026-04-21 14:06:48 +08:00
def _select_record(self: "PQAutomationApp", rec: _svc.AIImageRecord):
2026-04-21 14:06:48 +08:00
self.ai_image_current = rec
self.ai_image_meta_var.set(
f"{os.path.basename(rec.image_path)} | {rec.created_at}"
)
_redraw_preview(self)
# ---------------- 预览绘制 ----------------
def _redraw_preview(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
rec = getattr(self, "ai_image_current", None)
canvas = self.ai_image_canvas
2026-05-28 16:41:52 +08:00
palette = _theme_colors()
2026-04-21 14:06:48 +08:00
canvas.delete("all")
if rec is None or not os.path.isfile(rec.image_path):
return
cw = canvas.winfo_width() or 1
ch = canvas.winfo_height() or 1
try:
img = Image.open(rec.image_path)
img.load()
2026-04-21 14:06:48 +08:00
except Exception as exc:
2026-05-28 16:41:52 +08:00
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill=palette["select_bg"])
2026-04-21 14:06:48 +08:00
return
iw, ih = img.size
scale = min(cw / iw, ch / ih, 1.0)
nw, nh = max(1, int(iw * scale)), max(1, int(ih * scale))
img_resized = img.resize((nw, nh), Image.LANCZOS)
self.ai_image_photo = ImageTk.PhotoImage(img_resized)
canvas.create_image(cw // 2, ch // 2, image=self.ai_image_photo, anchor="center")
# ---------------- 发送 / 保存 / 删除 ----------------
def _start_new_session(self: "PQAutomationApp"):
"""开启新的对话会话,后续生成将使用新的 session_id。"""
if getattr(self, "_ai_image_requesting", False):
messagebox.showinfo("提示", "请等待当前请求完成")
return
_svc.reset_session()
self.ai_image_status_var.set("已开启新对话")
2026-05-28 17:34:51 +08:00
# 新对话创建后不要自动选中历史图片,否则会立即把 session 切回旧会话。
2026-04-29 16:43:31 +08:00
reload_ai_image_list(self, auto_select_first=False)
2026-05-28 17:34:51 +08:00
try:
self.ai_image_tree.selection_remove(self.ai_image_tree.selection())
except Exception:
pass
self.ai_image_current = None
self.ai_image_photo = None
self.ai_image_canvas.delete("all")
self.ai_image_meta_var.set("新对话已开启,等待生成图片")
2026-05-28 17:34:51 +08:00
def _session_id_for_item(self: "PQAutomationApp", item_id: str) -> str:
session_map = getattr(self, "_ai_image_session_node_map", None) or {}
parent = item_id
if parent not in session_map:
try:
parent = self.ai_image_tree.parent(item_id)
except Exception:
parent = ""
return (session_map.get(parent) or "") if parent else ""
2026-05-28 17:34:51 +08:00
def _switch_to_session(
self: "PQAutomationApp",
session_id: str,
show_message: bool = True,
target_record_id: str = "",
refresh_list: bool = True,
):
sid = (session_id or "").strip()
if not sid:
return
if sid == _svc.get_session_id():
return
_svc.set_session_id(sid)
2026-05-28 17:34:51 +08:00
logger.info(
"[AIImagePanel] 切换会话 sid=%s refresh=%s target=%s",
sid[:8],
refresh_list,
target_record_id[:8] if target_record_id else "",
)
if refresh_list:
reload_ai_image_list(self)
if target_record_id:
2026-05-28 17:34:51 +08:00
item_id = _find_tree_item_by_record_id(self, target_record_id)
if item_id:
_set_tree_selection(self, item_id)
self.ai_image_status_var.set("已切换到历史对话")
if show_message:
messagebox.showinfo("提示", "已切换到所选历史对话")
def _update_request_progress(self: "PQAutomationApp"):
if not getattr(self, "_ai_image_requesting", False):
self._ai_image_progress_job = None
return
2026-04-29 16:43:31 +08:00
self.ai_image_status_var.set("正在生成图片…")
self._ai_image_progress_phase += 1
self._ai_image_progress_job = self.root.after(900, lambda: _update_request_progress(self))
def _send_prompt(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
if getattr(self, "_ai_image_requesting", False):
return
prompt = self.ai_image_input.get("1.0", tk.END).strip()
if not prompt:
messagebox.showinfo("提示", "请输入内容")
return
2026-04-29 19:10:27 +08:00
self._ai_image_request_seq += 1
req_seq = self._ai_image_request_seq
self._ai_image_active_seq = req_seq
self._ai_image_cancel_event = threading.Event()
2026-04-21 14:06:48 +08:00
_set_requesting(self, True)
is_remote_url = _svc.is_remote_image_url(prompt)
self.ai_image_status_var.set("下载中…" if is_remote_url else "后端处理中…")
2026-04-21 14:06:48 +08:00
def _success(record):
2026-04-29 19:10:27 +08:00
self.root.after(0, lambda: _on_request_done(self, record, None, req_seq))
2026-04-21 14:06:48 +08:00
def _error(exc):
2026-04-29 19:10:27 +08:00
self.root.after(0, lambda: _on_request_done(self, None, exc, req_seq))
2026-04-21 14:06:48 +08:00
if is_remote_url:
_svc.import_image_from_url_async(
prompt,
on_success=_success,
on_error=_error,
2026-05-22 11:31:36 +08:00
base_dir=_get_app_base_dir(self),
2026-04-29 19:10:27 +08:00
cancel_event=self._ai_image_cancel_event,
)
return
2026-04-29 19:10:27 +08:00
_svc.request_image_async(
prompt,
on_success=_success,
on_error=_error,
2026-05-22 11:31:36 +08:00
base_dir=_get_app_base_dir(self),
2026-04-29 19:10:27 +08:00
cancel_event=self._ai_image_cancel_event,
)
2026-04-21 14:06:48 +08:00
def _set_requesting(self: "PQAutomationApp", flag: bool):
2026-04-21 14:06:48 +08:00
self._ai_image_requesting = flag
try:
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)
2026-04-29 19:10:27 +08:00
self.ai_image_stop_btn.configure(state=tk.NORMAL if flag else tk.DISABLED)
2026-04-21 14:06:48 +08:00
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:
pass
2026-04-21 14:06:48 +08:00
def _on_request_done(self: "PQAutomationApp", record, exc, req_seq):
2026-04-29 19:10:27 +08:00
# 旧请求回调(例如用户已点击停止后)直接忽略
if req_seq != getattr(self, "_ai_image_active_seq", 0):
return
self._ai_image_active_seq = 0
self._ai_image_cancel_event = None
2026-04-21 14:06:48 +08:00
_set_requesting(self, False)
if exc is not None:
self.ai_image_status_var.set(f"失败: {exc}")
messagebox.showerror("生成失败", str(exc))
return
self.ai_image_status_var.set("完成")
self.ai_image_input.delete("1.0", tk.END)
reload_ai_image_list(self)
2026-05-28 17:34:51 +08:00
if record is not None:
logger.info("[AIImagePanel] 生成完成并入列 id=%s sid=%s", record.id, (record.session_id or "")[:8])
2026-04-21 14:06:48 +08:00
if record is not None and self.ai_image_records:
2026-05-28 17:34:51 +08:00
item_id = _find_tree_item_by_record_id(self, record.id)
if item_id:
_set_tree_selection(self, item_id)
2026-04-21 14:06:48 +08:00
def _stop_request(self: "PQAutomationApp"):
2026-04-29 19:10:27 +08:00
"""停止当前生成任务(协作取消:屏蔽后续回调并恢复 UI"""
if not getattr(self, "_ai_image_requesting", False):
return
event = getattr(self, "_ai_image_cancel_event", None)
if event is not None:
event.set()
self._ai_image_active_seq = 0
_set_requesting(self, False)
self.ai_image_status_var.set("已停止生成")
def _save_current(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
ext = os.path.splitext(rec.image_path)[1] or ".png"
dest = filedialog.asksaveasfilename(
title="另存为",
defaultextension=ext,
initialfile=os.path.basename(rec.image_path),
filetypes=[("图片", "*.png;*.jpg;*.jpeg;*.bmp;*.webp"), ("所有文件", "*.*")],
)
if not dest:
return
try:
_svc.export_record(rec, dest)
messagebox.showinfo("成功", f"已保存到:\n{dest}")
except Exception as exc:
messagebox.showerror("保存失败", str(exc))
def _delete_current(self: "PQAutomationApp"):
2026-04-21 14:06:48 +08:00
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
if not messagebox.askyesno("确认删除", f"确定删除这张缓存图片吗?\n{os.path.basename(rec.image_path)}"):
return
_svc.delete_record(rec)
reload_ai_image_list(self)
def _rename_current(self: "PQAutomationApp"):
"""弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
current = rec.title or rec.display_name
new_name = simpledialog.askstring(
"重命名",
"修改显示标题(留空可恢复使用原始提示词):",
initialvalue=current,
parent=self.root,
)
if new_name is None: # 取消
return
new_name = new_name.strip()
if new_name == (rec.title or ""):
return
if not _svc.update_record_title(rec, new_name):
messagebox.showerror("保存失败", "无法更新元数据,请检查文件权限。")
return
target_id = rec.id
reload_ai_image_list(self)
2026-05-28 17:34:51 +08:00
item_id = _find_tree_item_by_record_id(self, target_id)
if item_id:
_set_tree_selection(self, item_id)
# ---------------- 发送到 UCD ----------------
def _show_list_context_menu(self: "PQAutomationApp", event):
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
2026-05-28 17:34:51 +08:00
_hide_tree_tooltip(self)
try:
2026-05-28 17:34:51 +08:00
item_id = self.ai_image_tree.identify_row(event.y)
except Exception:
2026-05-28 17:34:51 +08:00
item_id = ""
node_map = getattr(self, "_ai_image_node_map", None) or {}
ridx = node_map.get(item_id)
if ridx is not None and 0 <= ridx < len(self.ai_image_records):
2026-05-28 17:34:51 +08:00
_set_tree_selection(self, item_id)
_select_record(self, self.ai_image_records[ridx])
2026-05-28 17:34:51 +08:00
elif item_id:
sid = _session_id_for_item(self, item_id)
if sid:
_switch_to_session(self, sid, show_message=False, refresh_list=False)
has_selection = self.ai_image_current is not None
ucd = getattr(self, "ucd", None)
can_send = (
has_selection
and ucd is not None
and getattr(ucd, "status", False)
)
try:
self.ai_image_menu.entryconfigure(
0, state=("normal" if can_send else "disabled")
)
self.ai_image_menu.entryconfigure(
2, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
3, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.entryconfigure(
4, state=("normal" if has_selection else "disabled")
)
self.ai_image_menu.tk_popup(event.x_root, event.y_root)
finally:
self.ai_image_menu.grab_release()
def _send_to_ucd(self: "PQAutomationApp"):
"""把当前选中的 AI 图片通过 UCD 发送到显示设备。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
if not os.path.isfile(rec.image_path):
messagebox.showerror("错误", f"图片文件不存在:\n{rec.image_path}")
return
ucd = getattr(self, "ucd", None)
if ucd is None or not getattr(ucd, "status", False):
messagebox.showwarning("警告", "请先连接 UCD323 设备")
return
image_path = rec.image_path
send_path = image_path
log = getattr(self, "log_gui", None)
# 发送前检查分辨率:建议与当前 UCD 输出分辨率一致。
try:
2026-05-24 11:21:30 +08:00
target_w, target_h = self.signal_service.current_resolution()
except Exception:
target_w, target_h = 3840, 2160
try:
with Image.open(image_path) as _img:
src_w, src_h = _img.size
except Exception as exc:
messagebox.showerror("错误", f"无法读取图片尺寸:\n{exc}")
return
if log is not None:
log.log(
f"UCD分辨率: {target_w}x{target_h} | 图片分辨率: {src_w}x{src_h}",
level="info",
)
if (src_w, src_h) != (target_w, target_h):
if not messagebox.askyesno(
"分辨率不匹配",
(
f"当前 UCD 分辨率: {target_w}x{target_h}\n"
f"图片分辨率: {src_w}x{src_h}\n\n"
"是否自动缩放后再发送?"
),
):
if log is not None:
log.log("用户取消发送:图片分辨率与 UCD 不一致", level="warning")
return
try:
send_path = _build_ucd_resized_image(image_path, target_w, target_h)
if log is not None:
log.log(
f"已生成匹配分辨率副本: {os.path.basename(send_path)}",
level="info",
)
except Exception as exc:
messagebox.showerror("错误", f"自动缩放失败:\n{exc}")
return
if log is not None:
log.log(
f"发送 AI 图片到 UCD: {os.path.basename(send_path)}",
level="info",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("发送中…")
def _worker():
err = None
try:
2026-05-24 11:02:37 +08:00
self.signal_service.send_image(send_path)
ok = True
except Exception as exc:
ok = False
err = str(exc)
def _done():
if ok:
if log is not None:
log.log(
f"图片已发送到 UCD: {os.path.basename(send_path)}",
level="success",
)
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("已发送到 UCD")
else:
msg = f"UCD 发送失败: {err}" if err else "UCD 发送失败"
if log is not None:
log.log(msg, level="error")
if hasattr(self, "ai_image_status_var"):
self.ai_image_status_var.set("UCD 发送失败")
try:
self.root.after(0, _done)
except Exception:
pass
threading.Thread(target=_worker, daemon=True).start()
def _build_ucd_resized_image(image_path: str, target_w: int, target_h: int) -> str:
"""生成与 UCD 分辨率匹配的临时副本(缓存目录内)。"""
base_dir = os.path.dirname(image_path)
base_name = os.path.splitext(os.path.basename(image_path))[0]
out_name = f"{base_name}_{target_w}x{target_h}_ucd.png"
out_path = os.path.join(base_dir, out_name)
with Image.open(image_path) as img:
resized = img.convert("RGB").resize((target_w, target_h), Image.LANCZOS)
resized.save(out_path, format="PNG")
return out_path
class AIImagePanelMixin:
"""由 tools/refactor_to_mixins.py 自动生成。
把本模块的自由函数挂到 PQAutomationApp 便于 F12 跳转与类型推断
"""
create_ai_image_panel = create_ai_image_panel
toggle_ai_image_panel = toggle_ai_image_panel
_get_app_base_dir = _get_app_base_dir
reload_ai_image_list = reload_ai_image_list
_on_list_select = _on_list_select
_select_record = _select_record
_redraw_preview = _redraw_preview
_start_new_session = _start_new_session
2026-05-28 17:34:51 +08:00
_session_id_for_item = _session_id_for_item
_switch_to_session = _switch_to_session
_update_request_progress = _update_request_progress
_send_prompt = _send_prompt
_set_requesting = _set_requesting
_on_request_done = _on_request_done
_stop_request = _stop_request
_save_current = _save_current
_delete_current = _delete_current
_rename_current = _rename_current
_show_list_context_menu = _show_list_context_menu
_send_to_ucd = _send_to_ucd