修复 macOS 粘贴闪退/长时间运行闪退 +
新增缩略图拖拽重排
粘贴闪退 (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>
Showing
1 changed file
with
214 additions
and
35 deletions
| ... | @@ -13,7 +13,7 @@ from PySide6.QtWidgets import ( | ... | @@ -13,7 +13,7 @@ from PySide6.QtWidgets import ( |
| 13 | QMenu, QProgressBar, QInputDialog | 13 | QMenu, QProgressBar, QInputDialog |
| 14 | ) | 14 | ) |
| 15 | from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData | 15 | from PySide6.QtCore import Qt, QThread, Signal, QSize, QTimer, QMimeData |
| 16 | from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent | 16 | from PySide6.QtGui import QPixmap, QFont, QIcon, QDesktopServices, QAction, QImage, QDragEnterEvent, QDropEvent, QDrag, QMouseEvent |
| 17 | from PySide6.QtCore import QUrl | 17 | from PySide6.QtCore import QUrl |
| 18 | 18 | ||
| 19 | import base64 | 19 | import base64 |
| ... | @@ -41,6 +41,36 @@ from dataclasses import dataclass, asdict | ... | @@ -41,6 +41,36 @@ from dataclasses import dataclass, asdict |
| 41 | from typing import List, Optional, Dict, Any | 41 | from typing import List, Optional, Dict, Any |
| 42 | 42 | ||
| 43 | 43 | ||
| 44 | def _cleanup_clipboard_tempfiles(max_age_hours: int = 24) -> int: | ||
| 45 | """清理遗留的剪贴板临时文件,防止长时间运行累积到磁盘/句柄上限。 | ||
| 46 | |||
| 47 | 仅删除 {tempdir}/nano_banana_app/clipboard_*.png / _clipboard_tmp.png 中 | ||
| 48 | 超过 max_age_hours 小时的文件。返回删除数量。 | ||
| 49 | """ | ||
| 50 | try: | ||
| 51 | import time | ||
| 52 | temp_dir = Path(tempfile.gettempdir()) / "nano_banana_app" | ||
| 53 | if not temp_dir.exists(): | ||
| 54 | return 0 | ||
| 55 | cutoff = time.time() - max_age_hours * 3600 | ||
| 56 | removed = 0 | ||
| 57 | for p in temp_dir.iterdir(): | ||
| 58 | try: | ||
| 59 | if not p.is_file(): | ||
| 60 | continue | ||
| 61 | name = p.name | ||
| 62 | if not (name.startswith("clipboard_") or name == "_clipboard_tmp.png"): | ||
| 63 | continue | ||
| 64 | if p.stat().st_mtime < cutoff: | ||
| 65 | p.unlink() | ||
| 66 | removed += 1 | ||
| 67 | except Exception: | ||
| 68 | pass | ||
| 69 | return removed | ||
| 70 | except Exception: | ||
| 71 | return 0 | ||
| 72 | |||
| 73 | |||
| 44 | def _get_crash_log_path() -> Path: | 74 | def _get_crash_log_path() -> Path: |
| 45 | """获取崩溃日志文件路径(尽早可用,不依赖任何初始化)""" | 75 | """获取崩溃日志文件路径(尽早可用,不依赖任何初始化)""" |
| 46 | system = platform.system() | 76 | system = platform.system() |
| ... | @@ -271,8 +301,12 @@ def init_logging(log_level=logging.INFO): | ... | @@ -271,8 +301,12 @@ def init_logging(log_level=logging.INFO): |
| 271 | # 配置日志格式 | 301 | # 配置日志格式 |
| 272 | log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" | 302 | log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" |
| 273 | 303 | ||
| 274 | # 配置处理器 | 304 | # 配置处理器 - 使用 RotatingFileHandler 避免日志无限增长导致磁盘/IO 压力 |
| 275 | handlers = [logging.FileHandler(log_file, encoding='utf-8')] | 305 | from logging.handlers import RotatingFileHandler |
| 306 | max_bytes = int(logging_config.get("max_bytes", 5 * 1024 * 1024)) # 默认 5MB | ||
| 307 | backup_count = int(logging_config.get("backup_count", 5)) | ||
| 308 | handlers = [RotatingFileHandler(log_file, maxBytes=max_bytes, | ||
| 309 | backupCount=backup_count, encoding='utf-8')] | ||
| 276 | if logging_config.get("log_to_console", True): | 310 | if logging_config.get("log_to_console", True): |
| 277 | handlers.append(logging.StreamHandler()) | 311 | handlers.append(logging.StreamHandler()) |
| 278 | 312 | ||
| ... | @@ -1159,6 +1193,41 @@ class LoginDialog(QDialog): | ... | @@ -1159,6 +1193,41 @@ class LoginDialog(QDialog): |
| 1159 | return getattr(self, 'current_password_hash', '') | 1193 | return getattr(self, 'current_password_hash', '') |
| 1160 | 1194 | ||
| 1161 | 1195 | ||
| 1196 | THUMB_REORDER_MIME = "application/x-zb100-thumb-index" | ||
| 1197 | |||
| 1198 | |||
| 1199 | class DraggableThumbnail(QWidget): | ||
| 1200 | """可拖拽重排序的缩略图容器""" | ||
| 1201 | |||
| 1202 | def __init__(self, index: int, parent_window, parent=None): | ||
| 1203 | super().__init__(parent) | ||
| 1204 | self.index = index | ||
| 1205 | self.parent_window = parent_window | ||
| 1206 | self._drag_start_pos = None | ||
| 1207 | self.setCursor(Qt.OpenHandCursor) | ||
| 1208 | |||
| 1209 | def mousePressEvent(self, event: QMouseEvent): | ||
| 1210 | if event.button() == Qt.LeftButton: | ||
| 1211 | self._drag_start_pos = event.pos() | ||
| 1212 | super().mousePressEvent(event) | ||
| 1213 | |||
| 1214 | def mouseMoveEvent(self, event: QMouseEvent): | ||
| 1215 | if self._drag_start_pos is None or not (event.buttons() & Qt.LeftButton): | ||
| 1216 | return | ||
| 1217 | if (event.pos() - self._drag_start_pos).manhattanLength() < QApplication.startDragDistance(): | ||
| 1218 | return | ||
| 1219 | |||
| 1220 | drag = QDrag(self) | ||
| 1221 | mime = QMimeData() | ||
| 1222 | mime.setData(THUMB_REORDER_MIME, str(self.index).encode("utf-8")) | ||
| 1223 | drag.setMimeData(mime) | ||
| 1224 | pixmap = self.grab() | ||
| 1225 | drag.setPixmap(pixmap) | ||
| 1226 | drag.setHotSpot(event.pos()) | ||
| 1227 | drag.exec(Qt.MoveAction) | ||
| 1228 | self._drag_start_pos = None | ||
| 1229 | |||
| 1230 | |||
| 1162 | class DragDropScrollArea(QScrollArea): | 1231 | class DragDropScrollArea(QScrollArea): |
| 1163 | """自定义支持拖拽的图像滚动区域""" | 1232 | """自定义支持拖拽的图像滚动区域""" |
| 1164 | 1233 | ||
| ... | @@ -1182,6 +1251,11 @@ class DragDropScrollArea(QScrollArea): | ... | @@ -1182,6 +1251,11 @@ class DragDropScrollArea(QScrollArea): |
| 1182 | """拖拽进入事件处理""" | 1251 | """拖拽进入事件处理""" |
| 1183 | mime_data = event.mimeData() | 1252 | mime_data = event.mimeData() |
| 1184 | 1253 | ||
| 1254 | # 内部缩略图重排拖拽 | ||
| 1255 | if mime_data.hasFormat(THUMB_REORDER_MIME): | ||
| 1256 | event.acceptProposedAction() | ||
| 1257 | return | ||
| 1258 | |||
| 1185 | # 检查是否包含文件URL | 1259 | # 检查是否包含文件URL |
| 1186 | if mime_data.hasUrls(): | 1260 | if mime_data.hasUrls(): |
| 1187 | urls = mime_data.urls() | 1261 | urls = mime_data.urls() |
| ... | @@ -1247,6 +1321,19 @@ class DragDropScrollArea(QScrollArea): | ... | @@ -1247,6 +1321,19 @@ class DragDropScrollArea(QScrollArea): |
| 1247 | } | 1321 | } |
| 1248 | """) | 1322 | """) |
| 1249 | 1323 | ||
| 1324 | # 内部缩略图重排 | ||
| 1325 | if mime_data.hasFormat(THUMB_REORDER_MIME): | ||
| 1326 | try: | ||
| 1327 | src_index = int(bytes(mime_data.data(THUMB_REORDER_MIME)).decode("utf-8")) | ||
| 1328 | except (ValueError, UnicodeDecodeError): | ||
| 1329 | event.ignore() | ||
| 1330 | return | ||
| 1331 | target_index = self._compute_reorder_target(event.position().toPoint() if hasattr(event, "position") else event.pos()) | ||
| 1332 | if target_index is not None and hasattr(self.parent_window, "reorder_image"): | ||
| 1333 | self.parent_window.reorder_image(src_index, target_index) | ||
| 1334 | event.acceptProposedAction() | ||
| 1335 | return | ||
| 1336 | |||
| 1250 | # 处理文件拖拽 | 1337 | # 处理文件拖拽 |
| 1251 | if mime_data.hasUrls(): | 1338 | if mime_data.hasUrls(): |
| 1252 | urls = mime_data.urls() | 1339 | urls = mime_data.urls() |
| ... | @@ -1263,14 +1350,27 @@ class DragDropScrollArea(QScrollArea): | ... | @@ -1263,14 +1350,27 @@ class DragDropScrollArea(QScrollArea): |
| 1263 | event.acceptProposedAction() | 1350 | event.acceptProposedAction() |
| 1264 | return | 1351 | return |
| 1265 | 1352 | ||
| 1266 | # 处理剪贴板图像拖拽 | 1353 | # 处理拖入的图像数据(非文件) |
| 1354 | # macOS 上 mime_data.imageData() 会触发 NSPasteboard → NSImage 转换,在 macOS 26 上 | ||
| 1355 | # 是已知 native crash 路径;改为优先读取 image/* MIME 的原始字节。 | ||
| 1267 | try: | 1356 | try: |
| 1268 | if mime_data.hasImage(): | 1357 | if platform.system() == "Darwin": |
| 1269 | image = mime_data.imageData() | 1358 | for mime_type in ("image/png", "image/jpeg", "image/bmp", "image/tiff"): |
| 1270 | if isinstance(image, QImage) and not image.isNull(): | 1359 | if mime_data.hasFormat(mime_type): |
| 1271 | self.parent_window.add_clipboard_image(image.copy()) | 1360 | raw = mime_data.data(mime_type) |
| 1272 | event.acceptProposedAction() | 1361 | if raw and len(raw) > 0: |
| 1273 | return | 1362 | image = QImage() |
| 1363 | if image.loadFromData(bytes(raw)): | ||
| 1364 | self.parent_window.add_clipboard_image(image.copy()) | ||
| 1365 | event.acceptProposedAction() | ||
| 1366 | return | ||
| 1367 | else: | ||
| 1368 | if mime_data.hasImage(): | ||
| 1369 | image = mime_data.imageData() | ||
| 1370 | if isinstance(image, QImage) and not image.isNull(): | ||
| 1371 | self.parent_window.add_clipboard_image(image.copy()) | ||
| 1372 | event.acceptProposedAction() | ||
| 1373 | return | ||
| 1274 | except Exception: | 1374 | except Exception: |
| 1275 | pass # 拖放图像数据获取失败,静默忽略 | 1375 | pass # 拖放图像数据获取失败,静默忽略 |
| 1276 | 1376 | ||
| ... | @@ -1281,6 +1381,32 @@ class DragDropScrollArea(QScrollArea): | ... | @@ -1281,6 +1381,32 @@ class DragDropScrollArea(QScrollArea): |
| 1281 | valid_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} | 1381 | valid_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'} |
| 1282 | return Path(file_path).suffix.lower() in valid_extensions | 1382 | return Path(file_path).suffix.lower() in valid_extensions |
| 1283 | 1383 | ||
| 1384 | def _compute_reorder_target(self, drop_pos): | ||
| 1385 | """根据 drop 坐标算出目标插入位置(索引)。 | ||
| 1386 | |||
| 1387 | 策略:落点 x 与每个缩略图的中心 x 比较 —— 小于中心则插在该缩略图之前,大于则之后。 | ||
| 1388 | 落在所有缩略图右侧则追加到末尾。 | ||
| 1389 | """ | ||
| 1390 | window = self.parent_window | ||
| 1391 | if not hasattr(window, "img_layout") or not hasattr(window, "uploaded_images"): | ||
| 1392 | return None | ||
| 1393 | layout = window.img_layout | ||
| 1394 | # 映射坐标到 img_container | ||
| 1395 | viewport_pos = self.viewport().mapFrom(self, drop_pos) if self.viewport() else drop_pos | ||
| 1396 | container_pos = window.img_container.mapFrom(self.viewport(), viewport_pos) | ||
| 1397 | drop_x = container_pos.x() | ||
| 1398 | |||
| 1399 | thumb_count = len(window.uploaded_images) | ||
| 1400 | for i in range(thumb_count): | ||
| 1401 | item = layout.itemAt(i) | ||
| 1402 | if item is None or item.widget() is None: | ||
| 1403 | continue | ||
| 1404 | w = item.widget() | ||
| 1405 | center_x = w.x() + w.width() / 2 | ||
| 1406 | if drop_x < center_x: | ||
| 1407 | return i | ||
| 1408 | return thumb_count | ||
| 1409 | |||
| 1284 | 1410 | ||
| 1285 | class ImageGeneratorWindow(QMainWindow): | 1411 | class ImageGeneratorWindow(QMainWindow): |
| 1286 | """Qt-based main application window""" | 1412 | """Qt-based main application window""" |
| ... | @@ -2078,20 +2204,27 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2078,20 +2204,27 @@ class ImageGeneratorWindow(QMainWindow): |
| 2078 | 2204 | ||
| 2079 | 注意:Finder 复制的文件应通过 paste_from_clipboard 中的 hasUrls 分支处理, | 2205 | 注意:Finder 复制的文件应通过 paste_from_clipboard 中的 hasUrls 分支处理, |
| 2080 | 不会走到这里。此方法只处理纯图像数据(截图、从应用复制的图片等)。 | 2206 | 不会走到这里。此方法只处理纯图像数据(截图、从应用复制的图片等)。 |
| 2207 | |||
| 2208 | macOS 26 (Darwin 25+) 上 Qt 的 mimeData.imageData() / clipboard.image() / | ||
| 2209 | application/x-qt-image 反序列化会触发 NSPasteboard → NSImage 转换, | ||
| 2210 | 在部分场景下 native crash,因此这些路径在 macOS 上一律禁用,改走 osascript。 | ||
| 2081 | """ | 2211 | """ |
| 2082 | self.logger.info("[clipboard] 开始获取剪贴板图像...") | 2212 | self.logger.info("[clipboard] 开始获取剪贴板图像...") |
| 2083 | clipboard = QApplication.clipboard() | 2213 | clipboard = QApplication.clipboard() |
| 2214 | is_mac = platform.system() == "Darwin" | ||
| 2084 | 2215 | ||
| 2085 | # 方法1: 从 MIME data 读取图像 | 2216 | # 方法1: 从 MIME data 读取原始字节(安全 —— 只读 bytes,不触发 NSImage 转换) |
| 2086 | # 避免直接调用 clipboard.image(),该方法在 macOS 26 可能导致 native crash | ||
| 2087 | try: | 2217 | try: |
| 2088 | mime_data = clipboard.mimeData() | 2218 | mime_data = clipboard.mimeData() |
| 2089 | if mime_data is not None: | 2219 | if mime_data is not None: |
| 2090 | available_formats = mime_data.formats() | 2220 | available_formats = list(mime_data.formats()) |
| 2091 | self.logger.info(f"[clipboard] 可用 MIME 格式: {list(available_formats)}") | 2221 | self.logger.info(f"[clipboard] 可用 MIME 格式: {available_formats}") |
| 2092 | # 尝试从图像 MIME 格式读取原始字节 | 2222 | # macOS 上跳过 application/x-qt-image(Qt 内部格式,反序列化可能崩溃) |
| 2093 | for mime_type in ("image/png", "image/jpeg", "image/bmp", "image/tiff", "application/x-qt-image"): | 2223 | mime_candidates = ["image/png", "image/jpeg", "image/bmp", "image/tiff"] |
| 2094 | if mime_type in mime_data.formats(): | 2224 | if not is_mac: |
| 2225 | mime_candidates.append("application/x-qt-image") | ||
| 2226 | for mime_type in mime_candidates: | ||
| 2227 | if mime_type in available_formats: | ||
| 2095 | raw_data = mime_data.data(mime_type) | 2228 | raw_data = mime_data.data(mime_type) |
| 2096 | self.logger.info(f"[clipboard] 从 {mime_type} 读取到 {len(raw_data)} bytes") | 2229 | self.logger.info(f"[clipboard] 从 {mime_type} 读取到 {len(raw_data)} bytes") |
| 2097 | if raw_data and len(raw_data) > 0: | 2230 | if raw_data and len(raw_data) > 0: |
| ... | @@ -2101,8 +2234,8 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2101,8 +2234,8 @@ class ImageGeneratorWindow(QMainWindow): |
| 2101 | f"size={image.width()}x{image.height()}") | 2234 | f"size={image.width()}x{image.height()}") |
| 2102 | return image.copy() | 2235 | return image.copy() |
| 2103 | 2236 | ||
| 2104 | # 尝试 hasImage + imageData | 2237 | # 非 macOS 上尝试 hasImage + imageData(macOS 禁用,会 native crash) |
| 2105 | if mime_data.hasImage(): | 2238 | if not is_mac and mime_data.hasImage(): |
| 2106 | self.logger.info("[clipboard] 尝试 hasImage + imageData 方式") | 2239 | self.logger.info("[clipboard] 尝试 hasImage + imageData 方式") |
| 2107 | image_data = mime_data.imageData() | 2240 | image_data = mime_data.imageData() |
| 2108 | if isinstance(image_data, QImage) and not image_data.isNull(): | 2241 | if isinstance(image_data, QImage) and not image_data.isNull(): |
| ... | @@ -2114,12 +2247,12 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2114,12 +2247,12 @@ class ImageGeneratorWindow(QMainWindow): |
| 2114 | except Exception as e: | 2247 | except Exception as e: |
| 2115 | self.logger.warning(f"MIME data 方式获取剪贴板图像失败: {e}") | 2248 | self.logger.warning(f"MIME data 方式获取剪贴板图像失败: {e}") |
| 2116 | 2249 | ||
| 2117 | # 方法2: macOS 专用 - 用 osascript 将剪贴板图片写入临时文件 | 2250 | # 方法2: macOS 专用 - 用 osascript 将剪贴板图片写入临时文件(最可靠路径) |
| 2118 | if platform.system() == "Darwin": | 2251 | if is_mac: |
| 2119 | try: | 2252 | try: |
| 2120 | import subprocess | 2253 | import subprocess |
| 2121 | temp_path = Path(tempfile.gettempdir()) / "nano_banana_app" / "_clipboard_tmp.png" | 2254 | temp_path = Path(tempfile.gettempdir()) / "nano_banana_app" / "_clipboard_tmp.png" |
| 2122 | temp_path.parent.mkdir(exist_ok=True) | 2255 | temp_path.parent.mkdir(parents=True, exist_ok=True) |
| 2123 | if temp_path.exists(): | 2256 | if temp_path.exists(): |
| 2124 | temp_path.unlink() | 2257 | temp_path.unlink() |
| 2125 | 2258 | ||
| ... | @@ -2141,17 +2274,19 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2141,17 +2274,19 @@ class ImageGeneratorWindow(QMainWindow): |
| 2141 | if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0: | 2274 | if result.returncode == 0 and temp_path.exists() and temp_path.stat().st_size > 0: |
| 2142 | image = QImage(str(temp_path)) | 2275 | image = QImage(str(temp_path)) |
| 2143 | if not image.isNull(): | 2276 | if not image.isNull(): |
| 2277 | self.logger.info("[clipboard] osascript 方式成功") | ||
| 2144 | return image.copy() | 2278 | return image.copy() |
| 2145 | except Exception as e: | 2279 | except Exception as e: |
| 2146 | self.logger.warning(f"osascript 方式获取剪贴板图像失败: {e}") | 2280 | self.logger.warning(f"osascript 方式获取剪贴板图像失败: {e}") |
| 2147 | 2281 | ||
| 2148 | # 方法3: 直接调用 clipboard.image()(低版本 macOS / Windows / Linux) | 2282 | # 方法3: clipboard.image() 仅在非 macOS 上使用(macOS 26 上会 native crash) |
| 2149 | try: | 2283 | if not is_mac: |
| 2150 | image = clipboard.image() | 2284 | try: |
| 2151 | if image and not image.isNull(): | 2285 | image = clipboard.image() |
| 2152 | return image.copy() | 2286 | if image and not image.isNull(): |
| 2153 | except Exception as e: | 2287 | return image.copy() |
| 2154 | self.logger.warning(f"clipboard.image() 失败: {e}") | 2288 | except Exception as e: |
| 2289 | self.logger.warning(f"clipboard.image() 失败: {e}") | ||
| 2155 | 2290 | ||
| 2156 | return None | 2291 | return None |
| 2157 | 2292 | ||
| ... | @@ -2289,8 +2424,8 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2289,8 +2424,8 @@ class ImageGeneratorWindow(QMainWindow): |
| 2289 | 2424 | ||
| 2290 | pixmap = pixmap.scaled(100, 100, Qt.KeepAspectRatio, Qt.SmoothTransformation) | 2425 | pixmap = pixmap.scaled(100, 100, Qt.KeepAspectRatio, Qt.SmoothTransformation) |
| 2291 | 2426 | ||
| 2292 | # Container | 2427 | # Container (draggable for reorder) |
| 2293 | container = QWidget() | 2428 | container = DraggableThumbnail(idx, self) |
| 2294 | container_layout = QVBoxLayout() | 2429 | container_layout = QVBoxLayout() |
| 2295 | container_layout.setContentsMargins(5, 5, 5, 5) | 2430 | container_layout.setContentsMargins(5, 5, 5, 5) |
| 2296 | 2431 | ||
| ... | @@ -2350,6 +2485,30 @@ class ImageGeneratorWindow(QMainWindow): | ... | @@ -2350,6 +2485,30 @@ class ImageGeneratorWindow(QMainWindow): |
| 2350 | else: | 2485 | else: |
| 2351 | self.logger.warning(f"无效的图片索引: {index}") | 2486 | self.logger.warning(f"无效的图片索引: {index}") |
| 2352 | 2487 | ||
| 2488 | def reorder_image(self, src_index: int, target_index: int): | ||
| 2489 | """在 uploaded_images 里把 src 拖到 target 位置。 | ||
| 2490 | |||
| 2491 | target_index 语义:插入前,所以从 src 向右移动时需要 -1。 | ||
| 2492 | """ | ||
| 2493 | n = len(self.uploaded_images) | ||
| 2494 | if not (0 <= src_index < n): | ||
| 2495 | return | ||
| 2496 | if target_index < 0: | ||
| 2497 | target_index = 0 | ||
| 2498 | if target_index > n: | ||
| 2499 | target_index = n | ||
| 2500 | # 向右移动时,移除后目标索引 -1 | ||
| 2501 | if target_index > src_index: | ||
| 2502 | target_index -= 1 | ||
| 2503 | if target_index == src_index: | ||
| 2504 | return | ||
| 2505 | item = self.uploaded_images.pop(src_index) | ||
| 2506 | self.uploaded_images.insert(target_index, item) | ||
| 2507 | self.logger.info(f"缩略图重排: {src_index} -> {target_index}") | ||
| 2508 | self.update_image_preview() | ||
| 2509 | self.status_label.setText(f"● 已调整图片顺序 (图 {src_index + 1} → 图 {target_index + 1})") | ||
| 2510 | self.status_label.setStyleSheet("QLabel { color: #007AFF; }") | ||
| 2511 | |||
| 2353 | def update_saved_prompts_list(self): | 2512 | def update_saved_prompts_list(self): |
| 2354 | """Update the saved prompts dropdown""" | 2513 | """Update the saved prompts dropdown""" |
| 2355 | # Block signals to avoid triggering load_saved_prompt during update | 2514 | # Block signals to avoid triggering load_saved_prompt during update |
| ... | @@ -3102,7 +3261,7 @@ class ImageGenerationWorker(QThread): | ... | @@ -3102,7 +3261,7 @@ class ImageGenerationWorker(QThread): |
| 3102 | if "gemini-3-pro-image-preview" in self.model: | 3261 | if "gemini-3-pro-image-preview" in self.model: |
| 3103 | # Gemini 3 Pro supports both aspect_ratio and image_size | 3262 | # Gemini 3 Pro supports both aspect_ratio and image_size |
| 3104 | config = types.GenerateContentConfig( | 3263 | config = types.GenerateContentConfig( |
| 3105 | response_modalities=["IMAGE"], | 3264 | response_modalities=["TEXT", "IMAGE"], |
| 3106 | image_config=types.ImageConfig( | 3265 | image_config=types.ImageConfig( |
| 3107 | aspect_ratio=self.aspect_ratio, | 3266 | aspect_ratio=self.aspect_ratio, |
| 3108 | image_size=self.image_size | 3267 | image_size=self.image_size |
| ... | @@ -3111,7 +3270,7 @@ class ImageGenerationWorker(QThread): | ... | @@ -3111,7 +3270,7 @@ class ImageGenerationWorker(QThread): |
| 3111 | else: | 3270 | else: |
| 3112 | # Gemini 2.5 Flash only supports aspect_ratio (fixed 1024px output) | 3271 | # Gemini 2.5 Flash only supports aspect_ratio (fixed 1024px output) |
| 3113 | config = types.GenerateContentConfig( | 3272 | config = types.GenerateContentConfig( |
| 3114 | response_modalities=["IMAGE"], | 3273 | response_modalities=["TEXT", "IMAGE"], |
| 3115 | image_config=types.ImageConfig( | 3274 | image_config=types.ImageConfig( |
| 3116 | aspect_ratio=self.aspect_ratio | 3275 | aspect_ratio=self.aspect_ratio |
| 3117 | ) | 3276 | ) |
| ... | @@ -3125,7 +3284,9 @@ class ImageGenerationWorker(QThread): | ... | @@ -3125,7 +3284,9 @@ class ImageGenerationWorker(QThread): |
| 3125 | ) | 3284 | ) |
| 3126 | 3285 | ||
| 3127 | # Extract image | 3286 | # Extract image |
| 3128 | for part in response.parts: | 3287 | text_fragments = [] |
| 3288 | parts = response.parts or [] | ||
| 3289 | for part in parts: | ||
| 3129 | if hasattr(part, 'inline_data') and part.inline_data: | 3290 | if hasattr(part, 'inline_data') and part.inline_data: |
| 3130 | if isinstance(part.inline_data.data, bytes): | 3291 | if isinstance(part.inline_data.data, bytes): |
| 3131 | image_bytes = part.inline_data.data | 3292 | image_bytes = part.inline_data.data |
| ... | @@ -3145,8 +3306,18 @@ class ImageGenerationWorker(QThread): | ... | @@ -3145,8 +3306,18 @@ class ImageGenerationWorker(QThread): |
| 3145 | self.finished.emit(image_bytes, self.prompt, reference_images_bytes, | 3306 | self.finished.emit(image_bytes, self.prompt, reference_images_bytes, |
| 3146 | self.aspect_ratio, self.image_size, self.model) | 3307 | self.aspect_ratio, self.image_size, self.model) |
| 3147 | return | 3308 | return |
| 3309 | if getattr(part, 'text', None): | ||
| 3310 | text_fragments.append(part.text) | ||
| 3148 | 3311 | ||
| 3149 | error_msg = "响应中没有图片数据" | 3312 | finish_reason = "" |
| 3313 | try: | ||
| 3314 | finish_reason = str(response.candidates[0].finish_reason) | ||
| 3315 | except Exception: | ||
| 3316 | pass | ||
| 3317 | detail = " | ".join(t for t in text_fragments if t).strip() | ||
| 3318 | error_msg = f"响应中没有图片数据 (finish_reason={finish_reason})" | ||
| 3319 | if detail: | ||
| 3320 | error_msg += f"\n模型说明: {detail}" | ||
| 3150 | self.logger.error(error_msg) | 3321 | self.logger.error(error_msg) |
| 3151 | self.error.emit(error_msg) | 3322 | self.error.emit(error_msg) |
| 3152 | 3323 | ||
| ... | @@ -4203,6 +4374,14 @@ def main(): | ... | @@ -4203,6 +4374,14 @@ def main(): |
| 4203 | logger.info("[BOOT] Phase 2: 记录系统环境...") | 4374 | logger.info("[BOOT] Phase 2: 记录系统环境...") |
| 4204 | _log_system_info() | 4375 | _log_system_info() |
| 4205 | 4376 | ||
| 4377 | # 第 2.5 步:清理遗留的剪贴板临时文件(长时间运行防护) | ||
| 4378 | try: | ||
| 4379 | removed = _cleanup_clipboard_tempfiles(max_age_hours=24) | ||
| 4380 | if removed > 0: | ||
| 4381 | logger.info(f"[BOOT] 已清理 {removed} 个超过 24 小时的剪贴板临时文件") | ||
| 4382 | except Exception as e: | ||
| 4383 | logger.warning(f"[BOOT] 清理剪贴板临时文件失败: {e}") | ||
| 4384 | |||
| 4206 | # 第3步:加载配置 | 4385 | # 第3步:加载配置 |
| 4207 | logger.info("[BOOT] Phase 3: 加载配置文件...") | 4386 | logger.info("[BOOT] Phase 3: 加载配置文件...") |
| 4208 | 4387 | ... | ... |
-
Please register or sign in to post a comment