添加AI图像生成接口、修改相关界面

This commit is contained in:
xinzhu.yin
2026-04-29 15:25:58 +08:00
parent e243fc4e94
commit 377bba2a0b
4 changed files with 443 additions and 134 deletions

View File

@@ -28,6 +28,8 @@ def create_ai_image_panel(self):
self.ai_image_current = None # AIImageRecord | None
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
self._ai_image_requesting = False
self._ai_image_progress_job = None
self._ai_image_progress_phase = 0
container = ttk.Frame(frame, padding=10)
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,
foreground="#888", font=("微软雅黑", 9),
).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(
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))
# 注册面板
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):
"""重新扫描缓存并刷新列表。"""
"""重新扫描缓存并刷新列表。
按 ``session_id`` 分组:每个会话用一行不可选的分隔头(``── 会话 #N · 时间 ──``
其下列出该轮生成的所有图片。会话按"最近使用"倒序,组内按时间倒序。
"""
self.ai_image_records = _svc.list_records()
self.ai_image_listbox.delete(0, tk.END)
for rec in self.ai_image_records:
label = _format_list_label(rec)
self.ai_image_listbox.insert(tk.END, label)
# 维护行号 → 记录索引的映射;分隔头处为 None
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_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:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(0)
self.ai_image_listbox.activate(0)
_select_record(self, self.ai_image_records[0])
# 选中第一张实际记录
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_set(row)
self.ai_image_listbox.activate(row)
_select_record(self, self.ai_image_records[ridx])
break
else:
self.ai_image_current = None
self.ai_image_photo = None
@@ -191,6 +238,14 @@ def reload_ai_image_list(self):
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:
# 分辨率前缀:优先从 extra["size"] 取,其次从文件名猜
size_tag = ""
@@ -205,21 +260,34 @@ def _format_list_label(rec: _svc.AIImageRecord) -> str:
except Exception:
pass
prompt_line = (rec.prompt or "(无提示)").strip().splitlines()[0]
# 剩余可用宽度width=34)去掉 size_tag
max_prompt = 34 - len(size_tag) - 2
if max_prompt > 4 and len(prompt_line) > max_prompt:
prompt_line = prompt_line[:max_prompt] + ""
return f"{size_tag}{prompt_line}"
name_line = rec.display_name.splitlines()[0] if rec.display_name else "(未命名)"
# 列表宽度 width=34,需要扣除两格缩进 + size_tag
max_name = 34 - 2 - len(size_tag) - 2
if max_name > 4 and len(name_line) > max_name:
name_line = name_line[:max_name] + ""
return f"{size_tag}{name_line}"
def _on_list_select(self):
sel = self.ai_image_listbox.curselection()
if not sel:
return
idx = sel[0]
if 0 <= idx < len(self.ai_image_records):
_select_record(self, self.ai_image_records[idx])
row = sel[0]
row_map = getattr(self, "_ai_image_row_map", None) or []
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):
@@ -243,6 +311,7 @@ def _redraw_preview(self):
ch = canvas.winfo_height() or 1
try:
img = Image.open(rec.image_path)
img.load()
except Exception as exc:
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
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):
if getattr(self, "_ai_image_requesting", False):
return
@@ -267,7 +388,7 @@ def _send_prompt(self):
_set_requesting(self, True)
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):
self.root.after(0, lambda: _on_request_done(self, record, None))
@@ -283,17 +404,6 @@ def _send_prompt(self):
)
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)
@@ -301,8 +411,25 @@ def _set_requesting(self, flag: bool):
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)
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
def _on_request_done(self, record, exc):
@@ -314,13 +441,16 @@ def _on_request_done(self, record, exc):
self.ai_image_status_var.set("完成")
self.ai_image_input.delete("1.0", tk.END)
reload_ai_image_list(self)
# 定位到新生成项(最新在前)
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:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i)
self.ai_image_listbox.activate(i)
self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(row)
self.ai_image_listbox.see(row)
_select_record(self, r)
break
@@ -358,53 +488,41 @@ def _delete_current(self):
def _rename_current(self):
"""弹窗让用户修改当前记录的备注名称(即列表显示中 size_tag 之后的部分)。"""
"""弹窗让用户修改当前记录的展示标题(保存到侧车 ``title`` 字段,原始 prompt 不变)。"""
rec = getattr(self, "ai_image_current", None)
if rec is None:
messagebox.showinfo("提示", "请先选择一张图片")
return
current_name = rec.prompt or ""
current = rec.title or rec.display_name
new_name = simpledialog.askstring(
"重命名",
"修改备注名称(显示在分辨率标签后面",
initialvalue=current_name,
"修改显示标题(留空可恢复使用原始提示词",
initialvalue=current,
parent=self.root,
)
if new_name is None: # 用户点了取消
if new_name is None: # 取消
return
new_name = new_name.strip()
if not new_name:
messagebox.showwarning("提示", "备注名称不能为空")
return
if new_name == current_name:
if new_name == (rec.title or ""):
return
# 写回 JSON 元数据
try:
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}")
if not _svc.update_record_title(rec, new_name):
messagebox.showerror("保存失败", "无法更新元数据,请检查文件权限。")
return
# 同步内存中的记录并刷新列表
rec.prompt = new_name
target_id = rec.id
reload_ai_image_list(self)
# 重新定位到刚才被重命名的图片
for i, r in enumerate(self.ai_image_records):
if r.id == rec.id:
# 重新定位
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 == target_id:
self.ai_image_listbox.selection_clear(0, tk.END)
self.ai_image_listbox.selection_set(i)
self.ai_image_listbox.activate(i)
self.ai_image_listbox.see(i)
self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(row)
self.ai_image_listbox.see(row)
_select_record(self, r)
break
@@ -415,14 +533,16 @@ def _rename_current(self):
def _show_list_context_menu(self, event):
"""在图片列表上显示右键菜单,并根据状态启用/禁用项。"""
try:
idx = self.ai_image_listbox.nearest(event.y)
row = self.ai_image_listbox.nearest(event.y)
except Exception:
idx = -1
if 0 <= idx < len(self.ai_image_records):
row = -1
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_set(idx)
self.ai_image_listbox.activate(idx)
_select_record(self, self.ai_image_records[idx])
self.ai_image_listbox.selection_set(row)
self.ai_image_listbox.activate(row)
_select_record(self, self.ai_image_records[ridx])
has_selection = self.ai_image_current is not None
ucd = getattr(self, "ucd", None)