2473f5d4 by 柴进

:bug: 修复 macOS 粘贴闪退/长时间运行闪退 + :sparkles: 新增缩略图拖拽重排

粘贴闪退 (macOS 26):
- _safe_get_clipboard_image 在 Darwin 上禁用 mimeData.imageData() /
  clipboard.image() / application/x-qt-image 三条 native crash 路径,
  统一走 image/* 原始字节 + osascript PNGf 兜底
- DragDropScrollArea.dropEvent 的拖入图像分支同步做平台分流
- Windows/Linux 路径完全保留,零回归

长时间运行闪退:
- init_logging 改用 RotatingFileHandler (5MB × 5),避免日志无限增长
- 启动时清理超过 24 小时的 clipboard_*.png 遗留临时文件

Gemini 返回空图片:
- response_modalities 加上 TEXT,允许模型回传拒绝理由
- response.parts 增加 None 保护,修复日志里 20+ 次
  'NoneType object is not iterable' 异常
- 错误上浮 finish_reason + 模型说明到 QMessageBox

缩略图拖拽重排:
- 新增 DraggableThumbnail + THUMB_REORDER_MIME 内部协议
- 缩略图可拖动调整顺序,reorder_image 正确处理左右移动的索引

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f7fd7ef0
......@@ -13,7 +13,7 @@ from PySide6.QtWidgets import (
QMenu, QProgressBar, QInputDialog
)
from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData
from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent
from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent, QDrag, QMouseEvent
from PySide6.QtCore import QUrl
import base64
......@@ -41,6 +41,36 @@ from dataclasses import dataclass, asdict
from typing import List, Optional, Dict, Any
def _cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int:
"""清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。
仅删除 {tempdir}/nano_banana_app/clipboard_*.png / _clipboard_tmp.png 中
超过 max_age_hours 小时的文件。返回删除数量。
"""
try:
import time
temp_dir = Path(tempfile.gettempdir()) / "nano_banana_app"
if not temp_dir.exists():
return 0
cutoff = time.time() - max_age_hours * 3600
removed = 0
for p in temp_dir.iterdir():
try:
if not p.is_file():
continue
name = p.name
if not (name.startswith("clipboard_") or name == "_clipboard_tmp.png"):
continue
if p.stat().st_mtime < cutoff:
p.unlink()
removed += 1
except Exception:
pass
return removed
except Exception:
return 0
def _get_crash_log_path() -> Path:
"""获取崩溃日志文件路径(尽早可用,不依赖任何初始化)"""
system = platform.system()
......@@ -271,8 +301,12 @@ def init_logging(log_level=logging.INFO):
# 配置日志格式
log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
# 配置处理器
handlers = [logging.FileHandler(log_file, encoding='utf-8')]
# 配置处理器 - 使用 RotatingFileHandler 避免日志无限增长导致磁盘/IO 压力
from logging.handlers import RotatingFileHandler
max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024)) # 默认 5MB
backup_count = int(logging_config.get("backup_count", 5))
handlers = [RotatingFileHandler(log_file, maxBytes=max_bytes,
backupCount=backup_count, encoding='utf-8')]
if logging_config.get("log_to_console", True):
handlers.append(logging.StreamHandler())
......@@ -1159,6 +1193,41 @@ class LoginDialog(QDialog):
return getattr(self, 'current_password_hash', '')
THUMB_REORDER_MIME = "application/x-zb100-thumb-index"
class DraggableThumbnail(QWidget):
"""可拖拽重排序的缩略图容器"""
def __init__(self, index: int, parent_window, parent=None):
super().__init__(parent)
self.index = index
self.parent_window = parent_window
self._drag_start_pos = None
self.setCursor(Qt.OpenHandCursor)
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self._drag_start_pos = event.pos()
super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent):
if self._drag_start_pos is None or not (event.buttons() & Qt.LeftButton):
return
if (event.pos() - self._drag_start_pos).manhattanLength() < QApplication.startDragDistance():
return
drag = QDrag(self)
mime = QMimeData()
mime.setData(THUMB_REORDER_MIME, str(self.index).encode("utf-8"))
drag.setMimeData(mime)
pixmap = self.grab()
drag.setPixmap(pixmap)
drag.setHotSpot(event.pos())
drag.exec(Qt.MoveAction)
self._drag_start_pos = None
class DragDropScrollArea(QScrollArea):
"""自定义支持拖拽的图像滚动区域"""
......@@ -1182,6 +1251,11 @@ class DragDropScrollArea(QScrollArea):
"""拖拽进入事件处理"""
mime_data = event.mimeData()
# 内部缩略图重排拖拽
if mime_data.hasFormat(THUMB_REORDER_MIME):
event.acceptProposedAction()
return
# 检查是否包含文件URL
if mime_data.hasUrls():
urls = mime_data.urls()
......@@ -1247,6 +1321,19 @@ class DragDropScrollArea(QScrollArea):
}
""")
# 内部缩略图重排
if mime_data.hasFormat(THUMB_REORDER_MIME):
try:
src_index = int(bytes(mime_data.data(THUMB_REORDER_MIME)).decode("utf-8"))
except (ValueError, UnicodeDecodeError):
event.ignore()
return
target_index = self._compute_reorder_target(event.position().toPoint() if hasattr(event, "position") else event.pos())
if target_index is not None and hasattr(self.parent_window, "reorder_image"):
self.parent_window.reorder_image(src_index, target_index)
event.acceptProposedAction()
return
# 处理文件拖拽
if mime_data.hasUrls():
urls = mime_data.urls()
......@@ -1263,14 +1350,27 @@ class DragDropScrollArea(QScrollArea):
event.acceptProposedAction()
return
# 处理剪贴板图像拖拽
# 处理拖入的图像数据(非文件)
# macOS 上 mime_data.imageData() 会触发 NSPasteboard → NSImage 转换,在 macOS 26 上
# 是已知 native crash 路径;改为优先读取 image/* MIME 的原始字节。
try:
if mime_data.hasImage():
image = mime_data.imageData()
if isinstance(image, QImage) and not image.isNull():
self.parent_window.add_clipboard_image(image.copy())
event.acceptProposedAction()
return
if platform.system() == "Darwin":
for mime_type in ("image/png", "image/jpeg", "image/bmp", "image/tiff"):
if mime_data.hasFormat(mime_type):
raw = mime_data.data(mime_type)
if raw and len(raw) > 0:
image = QImage()
if image.loadFromData(bytes(raw)):
self.parent_window.add_clipboard_image(image.copy())
event.acceptProposedAction()
return
else:
if mime_data.hasImage():
image = mime_data.imageData()
if isinstance(image, QImage) and not image.isNull():
self.parent_window.add_clipboard_image(image.copy())
event.acceptProposedAction()
return
except Exception:
pass # 拖放图像数据获取失败,静默忽略
......@@ -1281,6 +1381,32 @@ class DragDropScrollArea(QScrollArea):
valid_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
return Path(file_path).suffix.lower() in valid_extensions
def _compute_reorder_target(self, drop_pos):
"""根据 drop 坐标算出目标插入位置(索引)。
策略:落点 x 与每个缩略图的中心 x 比较 —— 小于中心则插在该缩略图之前,大于则之后。
落在所有缩略图右侧则追加到末尾。
"""
window = self.parent_window
if not hasattr(window, "img_layout") or not hasattr(window, "uploaded_images"):
return None
layout = window.img_layout
# 映射坐标到 img_container
viewport_pos = self.viewport().mapFrom(self, drop_pos) if self.viewport() else drop_pos
container_pos = window.img_container.mapFrom(self.viewport(), viewport_pos)
drop_x = container_pos.x()
thumb_count = len(window.uploaded_images)
for i in range(thumb_count):
item = layout.itemAt(i)
if item is None or item.widget() is None:
continue
w = item.widget()
center_x = w.x() + w.width() / 2
if drop_x < center_x:
return i
return thumb_count
class ImageGeneratorWindow(QMainWindow):
"""Qt-based main application window"""
......@@ -2078,20 +2204,27 @@ class ImageGeneratorWindow(QMainWindow):
注意:Finder 复制的文件应通过 paste_from_clipboard 中的 hasUrls 分支处理,
不会走到这里。此方法只处理纯图像数据(截图、从应用复制的图片等)。
macOS 26 (Darwin 25+) 上 Qt 的 mimeData.imageData() / clipboard.image() /
application/x-qt-image 反序列化会触发 NSPasteboard → NSImage 转换,
在部分场景下 native crash,因此这些路径在 macOS 上一律禁用,改走 osascript。
"""
self.logger.info("[clipboard] 开始获取剪贴板图像...")
clipboard = QApplication.clipboard()
is_mac = platform.system() == "Darwin"
# 方法1: 从 MIME data 读取图像
# 避免直接调用 clipboard.image(),该方法在 macOS 26 可能导致 native crash
# 方法1: 从 MIME data 读取原始字节(安全 —— 只读 bytes,不触发 NSImage 转换)
try:
mime_data = clipboard.mimeData()
if mime_data is not None:
available_formats = mime_data.formats()
self.logger.info(f"[clipboard] 可用 MIME 格式: {list(available_formats)}")
# 尝试从图像 MIME 格式读取原始字节
for mime_type in ("image/png", "image/jpeg", "image/bmp", "image/tiff", "application/x-qt-image"):
if mime_type in mime_data.formats():
available_formats = list(mime_data.formats())
self.logger.info(f"[clipboard] 可用 MIME 格式: {available_formats}")
# macOS 上跳过 application/x-qt-image(Qt 内部格式,反序列化可能崩溃)
mime_candidates = ["image/png", "image/jpeg", "image/bmp", "image/tiff"]
if not is_mac:
mime_candidates.append("application/x-qt-image")
for mime_type in mime_candidates:
if mime_type in available_formats:
raw_data = mime_data.data(mime_type)
self.logger.info(f"[clipboard] 从 {mime_type} 读取到 {len(raw_data)} bytes")
if raw_data and len(raw_data) > 0:
......@@ -2101,8 +2234,8 @@ class ImageGeneratorWindow(QMainWindow):
f"size={image.width()}x{image.height()}")
return image.copy()
# 尝试 hasImage + imageData
if mime_data.hasImage():
# 非 macOS 上尝试 hasImage + imageData(macOS 禁用,会 native crash)
if not is_mac and mime_data.hasImage():
self.logger.info("[clipboard] 尝试 hasImage + imageData 方式")
image_data = mime_data.imageData()
if isinstance(image_data, QImage) and not image_data.isNull():
......@@ -2114,12 +2247,12 @@ class ImageGeneratorWindow(QMainWindow):
except Exception as e:
self.logger.warning(f"MIME data 方式获取剪贴板图像失败: {e}")
# 方法2: macOS 专用 - 用 osascript 将剪贴板图片写入临时文件
if platform.system() == "Darwin":
# 方法2: macOS 专用 - 用 osascript 将剪贴板图片写入临时文件(最可靠路径)
if is_mac:
try:
import subprocess
temp_path = Path(tempfile.gettempdir()) / "nano_banana_app" / "_clipboard_tmp.png"
temp_path.parent.mkdir(exist_ok=True)
temp_path.parent.mkdir(parents=True, exist_ok=True)
if temp_path.exists():
temp_path.unlink()
......@@ -2141,17 +2274,19 @@ class ImageGeneratorWindow(QMainWindow):
if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0:
image = QImage(str(temp_path))
if not image.isNull():
self.logger.info("[clipboard] osascript 方式成功")
return image.copy()
except Exception as e:
self.logger.warning(f"osascript 方式获取剪贴板图像失败: {e}")
# 方法3: 直接调用 clipboard.image()(低版本 macOS / Windows / Linux)
try:
image = clipboard.image()
if image and not image.isNull():
return image.copy()
except Exception as e:
self.logger.warning(f"clipboard.image() 失败: {e}")
# 方法3: clipboard.image() 仅在非 macOS 上使用(macOS 26 上会 native crash)
if not is_mac:
try:
image = clipboard.image()
if image and not image.isNull():
return image.copy()
except Exception as e:
self.logger.warning(f"clipboard.image() 失败: {e}")
return None
......@@ -2289,8 +2424,8 @@ class ImageGeneratorWindow(QMainWindow):
pixmap = pixmap.scaled(100, 100, Qt.KeepAspectRatio, Qt.SmoothTransformation)
# Container
container = QWidget()
# Container (draggable for reorder)
container = DraggableThumbnail(idx, self)
container_layout = QVBoxLayout()
container_layout.setContentsMargins(5, 5, 5, 5)
......@@ -2350,6 +2485,30 @@ class ImageGeneratorWindow(QMainWindow):
else:
self.logger.warning(f"无效的图片索引: {index}")
def reorder_image(self, src_index: int, target_index: int):
"""在 uploaded_images 里把 src 拖到 target 位置。
target_index 语义:插入前,所以从 src 向右移动时需要 -1。
"""
n = len(self.uploaded_images)
if not (0 <= src_index < n):
return
if target_index < 0:
target_index = 0
if target_index > n:
target_index = n
# 向右移动时,移除后目标索引 -1
if target_index > src_index:
target_index -= 1
if target_index == src_index:
return
item = self.uploaded_images.pop(src_index)
self.uploaded_images.insert(target_index, item)
self.logger.info(f"缩略图重排: {src_index} -> {target_index}")
self.update_image_preview()
self.status_label.setText(f"● 已调整图片顺序 (图 {src_index + 1} → 图 {target_index + 1})")
self.status_label.setStyleSheet("QLabel { color: #007AFF; }")
def update_saved_prompts_list(self):
"""Update the saved prompts dropdown"""
# Block signals to avoid triggering load_saved_prompt during update
......@@ -3102,7 +3261,7 @@ class ImageGenerationWorker(QThread):
if "gemini-3-pro-image-preview" in self.model:
# Gemini 3 Pro supports both aspect_ratio and image_size
config = types.GenerateContentConfig(
response_modalities=["IMAGE"],
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=self.aspect_ratio,
image_size=self.image_size
......@@ -3111,7 +3270,7 @@ class ImageGenerationWorker(QThread):
else:
# Gemini 2.5 Flash only supports aspect_ratio (fixed 1024px output)
config = types.GenerateContentConfig(
response_modalities=["IMAGE"],
response_modalities=["TEXT", "IMAGE"],
image_config=types.ImageConfig(
aspect_ratio=self.aspect_ratio
)
......@@ -3125,7 +3284,9 @@ class ImageGenerationWorker(QThread):
)
# Extract image
for part in response.parts:
text_fragments = []
parts = response.parts or []
for part in parts:
if hasattr(part, 'inline_data') and part.inline_data:
if isinstance(part.inline_data.data, bytes):
image_bytes = part.inline_data.data
......@@ -3145,8 +3306,18 @@ class ImageGenerationWorker(QThread):
self.finished.emit(image_bytes, self.prompt, reference_images_bytes,
self.aspect_ratio, self.image_size, self.model)
return
if getattr(part, 'text', None):
text_fragments.append(part.text)
error_msg = "响应中没有图片数据"
finish_reason = ""
try:
finish_reason = str(response.candidates[0].finish_reason)
except Exception:
pass
detail = " | ".join(t for t in text_fragments if t).strip()
error_msg = f"响应中没有图片数据 (finish_reason={finish_reason})"
if detail:
error_msg += f"\n模型说明: {detail}"
self.logger.error(error_msg)
self.error.emit(error_msg)
......@@ -4203,6 +4374,14 @@ def main():
logger.info("[BOOT] Phase 2: 记录系统环境...")
_log_system_info()
# 第 2.5 步:清理遗留的剪贴板临时文件(长时间运行防护)
try:
removed = _cleanup_clipboard_tempfiles(max_age_hours=24)
if removed > 0:
logger.info(f"[BOOT] 已清理 {removed} 个超过 24 小时的剪贴板临时文件")
except Exception as e:
logger.warning(f"[BOOT] 清理剪贴板临时文件失败: {e}")
# 第3步:加载配置
logger.info("[BOOT] Phase 3: 加载配置文件...")
......