Files
zyt/artifacts/desktop-ai-quick-tools-review.md
T
2026-10-17 16:08:24 +08:00

7.1 KiB
Raw Blame History

Desktop AI quick-tools lifecycle review

审查时间:2026-10-17。范围:app/src/doctor_workstation/ui/dialogs/ai_consult.py 的工具绑定、流状态与关闭路径;辅助读取 repository / API SSE 实现和现有 UI 测试。审查时业务文件及测试已有未提交改动,未编辑这些文件,也未调用生产或任何外部服务。

结论

1. 已确认:done 后仍由 worker.finished 持有忙碌门控

  • 六个工具在 ai_consult.py:4009-4012 用 _ClickCard.clicked = Signal() 绑定各自的默认参数 prompt。信号不携带 bool 参数,因此未发现常见的 lambda 参数覆盖或循环变量晚绑定问题。
  • _ask() 在 5996 遇到 _asking 就无提示返回;卡片本身仍保留可点击外观,忙碌期间的工具选择被直接丢弃。
  • _stream_event(done) 在 6130-6143 标记完成、停止总超时与耗时计时、渲染最终内容,但不清除 _asking,也不启用发送按钮。
  • _asking 只在 _stream_finished() 的 6196-6197 恢复。_AiStreamWorker.run() 在 3185-3188 对 done 仍然继续迭代,直到底层迭代器退出才会进入 finally 发出 finished。
  • 因而“已显示最终答案”和“允许再次点击工具”不是同一个时点。done 后迭代器或连接清理阻塞时,工具无反应可以持续;同时总超时已经停止,手工调用 _stream_timed_out() 也会被 _stream_completed 门控直接返回。
  • 当前 RemoteDoctorRepository.stream_diagnosis_ai() 在 yield done 后有 return(repository.py:1438-1442),通常会很快触发 finished,所以不能仅凭该缺陷断言真实部署每次都会永久卡住。确定的客户端缺陷是 UI 终态仍依赖传输退出,并且在此期间没有超时恢复。

2. 已确认:返回、侧栏 ×、Escape 的 reject 路径绕过流清理

  • 返回与侧栏 × 绑定 self.reject(3710、3966),但唯一关闭清理在 closeEvent()(6231-6237);没有覆盖 done() / reject() 或连接 finished 作统一清理。
  • Qt 的 QDialog.reject() 隐藏对话框并结束 exec,不触发此处的 closeEvent 清理。离线调用已证实:reject 后 _asking=True、worker 未取消、两个流计时器仍运行、generation 未变化。
  • open_for() 会先 _cancel_stream() 并递增会话 generation(3601-3608),所以复用同一对话框且按标准 open_for 重开时能够隔离旧回调。present_ai_consult() 每次新建一个对话框,旧对话框带父对象时隐藏后仍可能被保留,造成后台 worker / timer 继续存在。
  • 这是生命周期缺陷;它本身不能证明同一已显示窗口的其他工具永久失效,但应与本次门控修复一起覆盖。

3. 其他退出路径

  • _stream_failed() 与 done 相同,停止计时并显示错误,却只靠稍后的 finished 解锁。实际 worker 的 except 紧接 finally,常规错误路径间隙很短;仍建议终态入口统一恢复 UI 状态。
  • _stream_finished() 对 generation / stream_generation 作检查;_cancel_stream() 与 open_for() 的 generation 隔离正确,迟到旧事件不会覆盖新流。
  • 取消仅设置线程 Event,无法中断正在阻塞的 next()/网络读取;因此 UI 必须先解除对旧 worker 的依赖,再由 worker 完成清理。
  • 未发现六个 prompt 绑定错误。连续点击无反馈的直接入口是 _ask() 的无提示忙碌返回,而非按钮失去信号连接。

离线复现

使用 app/.venv/Scripts/python.exe、QT_QPA_PLATFORM=offscreen,以 stdin 临时脚本运行,未新增或修改业务 / 测试文件。

  1. 将线程池 start 替换成仅记录 worker 的函数,点击第一张工具卡,直接投递 done,不投递 finished,再点第二张卡。
{"case":"done_before_finished","asking":true,"send_enabled":false,"timeout_active":false,"completed":true,"second_click_started_worker":false}
{"case":"forced_timeout_after_done","asking":true}

补发 finished 后再次点第二张卡,第二个 worker 正常创建。

  1. 使用真实 QThreadPool / QRunnable 与完全本地的假 repository。假生成器 yield done 后在 Event 上等待,模拟终态数据已到但迭代器未退出。UI 事件循环实际收到 done 后立即发起第二问:
{"case":"real_qrunnable_done_then_open_iterator","done_delivered":true,"iterator_resumed_after_done":true,"asking":true,"timeout_active":false,"prompts":["first"]}
{"case":"iterator_released","asking":false,"send_enabled":true}
  1. fake pool 下 show 对话框并开始一问,调用实际 dialog.reject():
{"case":"reject","visible":false,"asking":true,"worker_cancelled":false,"generation_unchanged":true,"stream_generation_unchanged":true,"elapsed_timer_active":true,"timeout_active":true}
{"case":"open_for_after_reject","worker_cancelled":true,"asking":false,"generation_changed":true}

所有复现脚本 exit code 0,未访问 API。

建议的最小修复

  1. 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除 _asking 并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的 _cancel_stream() 直接处理 done。
  2. worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。
  3. 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。
  4. 在 done(result) 或其他统一的对话框结束入口中执行 generation 失效与 _cancel_stream(),覆盖 reject / accept / Escape / 窗口关闭;确保和 closeEvent 重入时幂等。

建议回归情境

  • 按截图六个工具依次真实点击(包含卡片文字和图标区域),每个 done 到达即点下一个,不提前补发 finished;每次 prompt 与 worker 都应正确创建。
  • done 后迭代器保持打开 / cleanup 延迟:UI 必须允许下一问,旧 worker 的迟到 delta/error/finished 不得改动新气泡或错误解锁新请求。
  • 正在首帧等待或生成中点击另一工具,验证所选忙碌策略且用户可理解当前状态。
  • error 到达但 finished 延迟、无 done 的静默结束、总超时、切换患者后迟到信号,均应恢复交互且不串患者。
  • 对返回按钮、侧栏 ×、Escape、原生窗口关闭分别验证 worker 已取消、计时器停止、旧 generation 无效;重开后新请求正常。
  • 保留首字即时显示、40ms 后续合并、完成后结构化渲染、首字/总耗时固定、一次性全量上下文提示的现有行为。

现有 test_answered_stream_replaces_the_pending_placeholder 与耗时完成测试在 done 后立即手工调用 finished,掩盖终态间隙;现有关闭耗时测试用 dialog.close(),未覆盖 reject 路径。