添加AI图片功能
This commit is contained in:
285
app/views/panels/ai_image_panel.py
Normal file
285
app/views/panels/ai_image_panel.py
Normal file
@@ -0,0 +1,285 @@
|
||||
"""AI 图片对话面板:输入提示 → 生成 → 显示 → 列表切换/保存/删除。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tkinter as tk
|
||||
from tkinter import filedialog, messagebox
|
||||
from typing import List, Optional
|
||||
|
||||
import ttkbootstrap as ttk
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from app.services import ai_image as _svc
|
||||
|
||||
|
||||
# ---------------- 面板创建 ----------------
|
||||
|
||||
|
||||
def create_ai_image_panel(self):
|
||||
"""创建 AI 图片对话面板,并注册到面板管理。"""
|
||||
frame = ttk.Frame(self.content_frame)
|
||||
self.ai_image_frame = frame
|
||||
|
||||
# 内部状态
|
||||
self.ai_image_records: List[_svc.AIImageRecord] = []
|
||||
self.ai_image_current: Optional[_svc.AIImageRecord] = None
|
||||
self.ai_image_photo = None # 防止 ImageTk.PhotoImage 被 GC
|
||||
self._ai_image_requesting = False
|
||||
|
||||
container = ttk.Frame(frame, padding=10)
|
||||
container.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
# 左列:图片列表
|
||||
left = ttk.Frame(container)
|
||||
left.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
|
||||
|
||||
ttk.Label(left, text="历史图片", font=("微软雅黑", 10, "bold")).pack(
|
||||
anchor=tk.W, pady=(0, 4)
|
||||
)
|
||||
|
||||
list_wrap = ttk.Frame(left)
|
||||
list_wrap.pack(fill=tk.Y, expand=False)
|
||||
|
||||
scroll = ttk.Scrollbar(list_wrap, orient=tk.VERTICAL)
|
||||
self.ai_image_listbox = tk.Listbox(
|
||||
list_wrap,
|
||||
width=28,
|
||||
height=22,
|
||||
activestyle="dotbox",
|
||||
yscrollcommand=scroll.set,
|
||||
)
|
||||
scroll.config(command=self.ai_image_listbox.yview)
|
||||
self.ai_image_listbox.pack(side=tk.LEFT, fill=tk.Y)
|
||||
scroll.pack(side=tk.RIGHT, fill=tk.Y)
|
||||
self.ai_image_listbox.bind("<<ListboxSelect>>", lambda e: _on_list_select(self))
|
||||
|
||||
btn_row = ttk.Frame(left)
|
||||
btn_row.pack(fill=tk.X, pady=(6, 0))
|
||||
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.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
|
||||
|
||||
preview_frame = ttk.LabelFrame(right, text="图片预览", padding=6)
|
||||
preview_frame.pack(fill=tk.BOTH, expand=True)
|
||||
|
||||
self.ai_image_canvas = tk.Canvas(
|
||||
preview_frame, bg="#1e1e1e", highlightthickness=0
|
||||
)
|
||||
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,
|
||||
foreground="#666", font=("微软雅黑", 9),
|
||||
).pack(side=tk.LEFT)
|
||||
|
||||
# 输入区
|
||||
input_frame = ttk.LabelFrame(right, text="提示输入(Ctrl+Enter 发送)", padding=6)
|
||||
input_frame.pack(fill=tk.X, pady=(4, 0))
|
||||
|
||||
self.ai_image_input = tk.Text(input_frame, height=3, wrap=tk.WORD)
|
||||
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,
|
||||
foreground="#888", font=("微软雅黑", 9),
|
||||
).pack(side=tk.LEFT)
|
||||
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.register_panel("ai_image", frame, None, "ai_image_visible")
|
||||
self.ai_image_visible = False
|
||||
|
||||
# 初次加载缓存
|
||||
reload_ai_image_list(self)
|
||||
|
||||
|
||||
def toggle_ai_image_panel(self):
|
||||
"""切换 AI 图片面板显隐。"""
|
||||
self.show_panel("ai_image")
|
||||
|
||||
|
||||
# ---------------- 列表 / 选中 ----------------
|
||||
|
||||
|
||||
def reload_ai_image_list(self):
|
||||
"""重新扫描缓存并刷新列表。"""
|
||||
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)
|
||||
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])
|
||||
else:
|
||||
self.ai_image_current = None
|
||||
self.ai_image_photo = None
|
||||
self.ai_image_canvas.delete("all")
|
||||
self.ai_image_meta_var.set("暂无缓存图片")
|
||||
|
||||
|
||||
def _format_list_label(rec: _svc.AIImageRecord) -> str:
|
||||
when = (rec.created_at or "")[:19].replace("T", " ")
|
||||
preview = (rec.prompt or "(无提示)").strip().splitlines()[0]
|
||||
if len(preview) > 18:
|
||||
preview = preview[:18] + "…"
|
||||
return f"{when} {preview}" if when else preview
|
||||
|
||||
|
||||
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])
|
||||
|
||||
|
||||
def _select_record(self, rec: _svc.AIImageRecord):
|
||||
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):
|
||||
rec = getattr(self, "ai_image_current", None)
|
||||
canvas = self.ai_image_canvas
|
||||
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)
|
||||
except Exception as exc:
|
||||
canvas.create_text(cw // 2, ch // 2, text=f"加载失败: {exc}", fill="#f66")
|
||||
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 _send_prompt(self):
|
||||
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
|
||||
if not _svc.has_api():
|
||||
messagebox.showerror(
|
||||
"API 未配置",
|
||||
"AI 图片 API 尚未接入。\n请在启动时通过 "
|
||||
"app.services.ai_image.set_api_caller(...) 注入真实实现。",
|
||||
)
|
||||
return
|
||||
|
||||
_set_requesting(self, True)
|
||||
self.ai_image_status_var.set("请求中…")
|
||||
|
||||
def _success(record):
|
||||
self.root.after(0, lambda: _on_request_done(self, record, None))
|
||||
|
||||
def _error(exc):
|
||||
self.root.after(0, lambda: _on_request_done(self, None, exc))
|
||||
|
||||
_svc.request_image_async(prompt, on_success=_success, on_error=_error)
|
||||
|
||||
|
||||
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)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _on_request_done(self, record: Optional[_svc.AIImageRecord], exc: Optional[Exception]):
|
||||
_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)
|
||||
# 定位到新生成项(最新在前)
|
||||
if record is not None and self.ai_image_records:
|
||||
for i, r in enumerate(self.ai_image_records):
|
||||
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)
|
||||
_select_record(self, r)
|
||||
break
|
||||
|
||||
|
||||
def _save_current(self):
|
||||
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):
|
||||
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)
|
||||
Reference in New Issue
Block a user