57047eb9 by 柴进

:zap:️ 历史记录列表改增量渲染 + Model/View 视口懒加载

阶段 1: 生成完成不再 refresh_history() 全量重建 360+ 个 widget,
改走 prepend_history_item() O(1) 增量插入.

阶段 2 Step 1: QListWidget -> QListView + HistoryListModel:
- Model 仅持有 list[str] timestamps + OrderedDict LRU 缓存
- 视口可见行才触发 data() 加载缩略图,与历史总条数解耦
- delete/clear 走 model 增量,不再触发全量刷新

修复 macOS 上长时间运行被 jetsam SIGKILL 的崩溃路径
(2026-04-24 单日 14 次闪退,无 Traceback / faulthandler 栈).
1 parent f848e929
# 设计:历史记录列表视口懒加载(路线 A)
## 背景与抉择
2026-04-24 会话中与用户讨论了三条路线:
| 路线 | 做法 | 评价 |
|---|---|---|
| A | `QListView + QAbstractListModel + QStyledItemDelegate`,Qt 原生视口懒加载 | **采纳**。用户零感知,消除"全部 widget 常驻"的根本问题 |
| B | 显式分页按钮 / "加载更多" | 破坏线性浏览的心智模型,被用户否决 |
| C | 保持 `QListWidget` 只做增量 prepend | **已作为阶段 1 落地**,治标不治本 |
路线 A 是架构性修复,阶段 2 启动。
## 数据结构
### 现状(阶段 1 之后)
```
QListWidget
└── 360 × QListWidgetItem
├── QIcon(QPixmap 120×120) ← 全部常驻内存
├── 完整 tooltip 字符串
└── text (timestamp + prompt preview)
```
内存占用:每条 item ~60 KB(QPixmap 纹理 + Qt 对象 overhead),360 条 ≈ 21 MB。**但这是"当前活 widget"的成本,不算 Qt C++ 层在 clear/重建过程中的临时 peak 和回收延迟。** 连续 6 小时高频重建才是真正的内存杀手。
### 目标(阶段 2)
```
HistoryListModel
└── list[str] timestamps ← 内存 ~ 360 × 16B = 5.7 KB
HistoryItemDelegate
├── QPixmapCache (LRU, ~50 MB) ← 只缓存视口 + 最近滚过的
└── 每次 paint() 按需从 cache 取 pixmap,miss 则异步加载
```
内存占用:timestamps 本体几乎为零;pixmap cache 由 Qt 统一管控上限。**不管历史 1000 条还是 10000 条,活内存都只和视口大小相关。**
## 关键决策
### 1. Model 为何只存 timestamps 而不存完整 HistoryItem
- timestamp 是稳定主键,`HistoryManager.load_history_item_fast(ts)` 可 O(1) 拿到完整数据
- 避免 model 内部持有 360 个 HistoryItem(那样又回到"全量常驻")
- metadata.json 读取很快(几 KB / 次),magneto 让 OS 页缓存帮我们做热度管理
- **如果实测 paint() 时的 metadata.json 读取成为瓶颈**,再引入轻量 LRU cache(`functools.lru_cache(maxsize=200)` on `load_history_item_fast`
### 2. Delegate 的 paint() 是否会阻塞滚动
会,如果在 paint() 里同步做磁盘 IO / PIL 解码。规避方案:
- `paint()`**只查 QPixmapCache**,不做 IO
- Cache miss → 丢一个 `ThumbnailLoader(QRunnable)``QThreadPool`,当前 paint 返回占位图
- 后台加载完 → `cache.insert(key, pixmap)` + `model.dataChanged(index, index, [Qt.DecorationRole])`
- 下次 paint 重新从 cache 取到真图,丝滑切换
### 3. 与现有 prepend_history_item 的衔接
阶段 1 的 `prepend_history_item` 目前做的是 `history_list.insertItem(0, widget)`,阶段 2 改造后简化成:
```python
def prepend_history_item(self, timestamp):
self.history_model.insertRow(0, timestamp)
```
对外接口签名不变,调用方(`on_image_generated` 等 4 处)**零改动**
### 4. 尺寸对齐策略
`QListWidgetItem` 的默认 sizeHint 由 icon size + text 行高决定。我们的 delegate `sizeHint()` 必须返回**同一个值**,否则:
- 切换架构时列表视觉抖动
- 选中高亮条高度不对
- 滚动到特定位置时错位
具体值需在迁移时从 runtime 抓取(比如先给老代码加一次 `print(item.sizeHint())`)。
### 5. 信号迁移
`QListWidget``itemClicked(QListWidgetItem)``QListView``clicked(QModelIndex)`
所有槽函数参数类型变化,需要:
- `timestamp = index.data(Qt.UserRole)` 替换 `item.data(Qt.UserRole)`
- 右键菜单从 `itemAt(position)` 改成 `indexAt(position)`
## 风险与回滚
### 风险
1. **视觉差异**`QListView` 默认选中样式(蓝底白字)与 `QListWidget` 不完全一致,需 QSS 调齐
2. **delegate 尺寸计算 off-by-one**:会表现为列表项之间 1–2 px 间隙或重叠,反复打磨
3. **异步缩略图加载竞态**:快速滚动时可能看到闪烁的占位图(可接受,滚停后 ~100ms 内补齐)
### 回滚
每个 step 独立可跑可回滚:
- Step 1 完成(Model/View 替换)后可单独发版,此时延续 Qt 默认的"见一个画一个"但是每次仍 build widget-like object
- Step 2(QPixmapCache + 异步加载)失败时保留 Step 1 的同步加载,性能不如目标但正确性不受影响
- 完整回滚:`git revert` 本 change 对应 commit,回到阶段 1 的 QListWidget + 增量 prepend 模式
## 性能目标
| 指标 | 阶段 1(当前) | 阶段 2(目标) |
|---|---|---|
| 生成完成后 UI 刷新耗时 | ~50 ms(1 条 widget) | ~5 ms(1 次 insertRow) |
| 首次切到历史 tab(360 条) | ~250 ms | < 50 ms(只画视口 15 条) |
| 首次切到历史 tab(1000 条) | ~700 ms(线性增长) | < 50 ms(与总数无关) |
| 连续 6 小时 100 次生成的活内存峰值 | 不确定,疑似 GB 级 | < 300 MB(cache 上限可控) |
| 闪退次数 / 日(Mac) | 14 次 → 期望 0 次(阶段 1 观察中) | 0 次 |
## 不做的事
- **不改 index.json 存储格式** —— 那是另一个独立坑,留给后续 change 处理
- **不动 `_fix_history_path` 的路径修正逻辑** —— 同上,独立重构
- **不引入虚拟滚动库**(如自写 virtual list)—— Qt 原生 Model/View 已经提供这能力
- **不做"翻页按钮"** —— 用户明确否决,会破坏线性浏览体验
# 提案:历史记录列表增量渲染 + 视口懒加载(路线 A)
> 状态:**第一阶段已完成(增量 prepend)**,第二阶段(Model/Delegate 重构)**下周启动**。
## Why
2026-04-24 当天应用在 Mac 上闪退 14 次。根因定位:
- `app(7).log` 末尾戛然而止,无 Traceback
- `crash_log(3).txt` 的 faulthandler 也没捕到任何信号栈
- **两处都没栈 = 只能是 SIGKILL**(faulthandler 无法拦截 SIGKILL,Unix 铁律)→ 99% 是 macOS jetsam 内存压力强杀
`image_generator.py` 历史记录渲染路径有三个相互放大的问题:
1. **每次生成完图片都 `refresh_history()` 全量重建**`:2859``:2884``:4452``:4507`)。360 条 × 120×120 QPixmap + QIcon + QListWidgetItem,6 小时内累计创建 ~9 万个 Qt 对象,C++ 层回收滞后 + 内存碎片化。
2. **`load_history_index()` 每次调用都做 O(N) 路径修正**,360 条 × 2 图 = 720 次 `os.stat` 在 UI 主线程上。崩溃前 11 秒内被 UI 事件重复触发了 7 次。
3. **`QListWidget` 架构决定"全部 widget 常驻"**,用户只看得到视口内 10–15 行,但 Qt 必须为所有 360 条持有 QPixmap。历史记录越多,风险越高。
代码注释已自承风险:`# macOS 上触发 SIGKILL``image_generator.py:3071`)。
## What Changes
### 阶段 1:增量 prepend(已完成,2026-04-24)
历史记录是 append-only 数据(生成后 prompt/图片/参数不可编辑),生成完成后只需追加单条到列表首位,无需全量重建。
- 新增 `HistoryManager.load_history_item_fast(timestamp)` — 只读 `{timestamp}/metadata.json` + 扫目录下 `reference_*.png`**不触碰 `index.json`、不扫其他记录、不做路径修正**
- 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑,供全量和增量共用
- 新增 `prepend_history_item(timestamp)` — 增量插入到列表首位,异常时自动回退 `refresh_history()` 全量
- 四处生成完成回调从 `refresh_history()` 切换为 `prepend_history_item(timestamp)`
### 阶段 2:Model/Delegate 视口懒加载(下周启动)
阶段 1 消除了"每次生成的 O(N) 重建",但"首次加载/手动刷新"仍是一次性画 360 个 widget。真正的根治是换架构:
- `QListWidget``QListView + HistoryListModel(QAbstractListModel) + HistoryItemDelegate(QStyledItemDelegate)`
- Model 只存 `list[str]` 的 timestamps(内存几乎为零)
- Delegate 在 `paint()` 时按需读 metadata + 缩略图,滚出视口自动不再绘制
- **效果等同于无限懒加载,用户零感知,不引入"翻页按钮"类的 UI 变化**
额外优化:
- `QPixmapCache`(LRU,默认 ~50 MB)缓存最近加载的缩略图
- 后台线程(`QThreadPool + QRunnable`)异步加载缩略图,主线程占位 placeholder,缩略图回来再触发重绘
- `delete` / `clear` 改为 model 级别增量(`removeRow` / `reset`),不再触发"全量 refresh"
## Impact
- **Affected specs**: history-list-rendering(新,阶段 2 时建立)
- **Affected code**:
- `image_generator.py`: HistoryManager / ImageGeneratorWindow 历史 tab 相关方法
- 阶段 2 会新增 `HistoryListModel` / `HistoryItemDelegate` 两个类
- **User-visible**:
- 阶段 1:**无感知**(仅性能/稳定性提升)
- 阶段 2:历史 tab 的选中样式、间距、hover 可能有 1–2 px 的视觉差异(`QListView` 默认样式 vs `QListWidget`),需要 QSS 调齐
- **Risk**:
- 阶段 2 触及历史 tab 核心交互路径(单击/双击/右键菜单/详情面板联动),需要完整回归
- delegate 的尺寸计算(`sizeHint`)必须和现有 icon size + text 行高严格一致,否则滚动位置/选中高亮会错位
# 任务列表:历史记录列表增量渲染 + 视口懒加载
## 阶段 1:增量 prepend(已完成 2026-04-24)
- [x] 1.1 `HistoryManager.load_history_item_fast(timestamp)` — 轻量单条读取(不走 index.json)
- [x] 1.2 抽出 `_build_history_list_item(item)` — 单条 widget 构建逻辑
- [x] 1.3 `prepend_history_item(timestamp)` — 增量插入 + 异常回退 `refresh_history()`
- [x] 1.4 四处 `refresh_history()` 调用点切换为 `prepend_history_item(timestamp)`
- [x] 1.4.1 `on_generation_complete``image_generator.py:2895`
- [x] 1.4.2 `on_image_generated``image_generator.py:2919`
- [x] 1.4.3 `_on_my_task_completed``image_generator.py:4514`
- [x] 1.4.4 款式设计 tab `on_generation_success``image_generator.py:4568`
- [x] 1.5 语法自检通过(`python -c "import ast; ast.parse(...)"`
**仍保留全量刷新**(刻意保留,不影响使用习惯):
- 手动点"刷新"按钮
- 首次切到历史 tab
- 删除单条 / 清空全部(阶段 2 才会动)
## 阶段 2:Model/Delegate 视口懒加载(下周启动)
### Step 1 — 引入 Model/View 架构(已完成 2026-04-27)
- [x] 2.1 新增 `HistoryListModel(QAbstractListModel)`
- [x] 2.1.1 数据仅 `list[str]` timestamps(按时间戳倒序)
- [x] 2.1.2 实现 `rowCount` / `data(index, role)` / `flags`
- [x] 2.1.3 `data()` 按需调 `HistoryManager.load_history_item_fast(timestamp)`,内部 OrderedDict LRU(max=300)缓存 icon/display_text/tooltip
- [x] 2.1.4 支持 `prepend_timestamp` / `remove_timestamp` / `reset_timestamps` / `invalidate_cache`
- [~] 2.2 `HistoryItemDelegate(QStyledItemDelegate)`**Step 1 暂未引入自定义 delegate**
- [x] 2.2.1 沿用 `QStyledItemDelegate` 默认实现(IconMode 默认绘制 icon + 文本,与 QListWidget 一致)
- [x] 2.2.2 sizeHint 沿用默认(与 QListWidget IconMode 行为一致)
- 备注:Step 2 引入异步 thumbnail 加载时再考虑是否需要自定义 delegate
- [x] 2.3 历史 tab 替换
- [x] 2.3.1 `self.history_list = QListWidget(...)``QListView(...)` + IconMode/IconSize/Spacing/Adjust/Static 全部对齐旧版
- [x] 2.3.2 `setModel(self.history_model)`(delegate 沿用默认)
- [x] 2.3.3 `clicked(QModelIndex)` / `customContextMenuRequested` + `indexAt(position)` 信号迁移
- [x] 2.4 `refresh_history` 简化为 `history_model.reset_timestamps([item.timestamp for ...])`(不再建 widget)
- [x] 2.5 `prepend_history_item` 简化为 `history_model.prepend_timestamp(timestamp)`
- [x] 2.6 `delete_history_item` UI 侧改为 `history_model.remove_timestamp(timestamp)`(失败回退 refresh)
- [x] 2.7 `clear_history` UI 侧改为 `history_model.reset_timestamps([])`
### Step 2 — 缩略图缓存 + 异步加载(性能优化)
- [ ] 2.8 `QPixmapCache` 接入 delegate `paint()`(key = thumb_path)
- [ ] 2.9 设置 cache limit(默认 ~50 MB,足够 ~500 张 thumb.jpg)
- [ ] 2.10 `ThumbnailLoader(QRunnable)` 后台加载
- [ ] 2.10.1 `QThreadPool.globalInstance()` 丢任务
- [ ] 2.10.2 加载完回主线程 `dataChanged(index)` 触发 delegate 重绘
- [ ] 2.10.3 cache miss 时 delegate 先画占位图,不阻塞滚动
### Step 3 — 收尾与回归
- [ ] 2.11 回归测试:
- [ ] 2.11.1 单击历史项 → 详情面板正常联动
- [ ] 2.11.2 双击 / 右键菜单 / 删除 / 清空 全部正常
- [ ] 2.11.3 生成新图 → 立即出现在列表顶部
- [ ] 2.11.4 滚动 300+ 条流畅,Activity Monitor 观察内存稳定
- [ ] 2.11.5 Mac 连续工作 4 小时无闪退
- [ ] 2.12 删除过时的 `_build_history_list_item` / `QListWidgetItem` 构造代码
- [ ] 2.13 更新本文档(阶段 2 勾选完毕)
## 验证标准
- **阶段 1**:Mac 连续工作一天,崩溃次数从 14 次/日显著下降(目标:0 次)
- **阶段 2**:历史记录 1000 条时,首次切到历史 tab 的 UI 响应时间 < 200ms;滚动过程中内存峰值不超过 500 MB