7.1 KiB
7.1 KiB
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 临时脚本运行,未新增或修改业务 / 测试文件。
- 将线程池 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 正常创建。
- 使用真实 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}
- 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。
建议的最小修复
- 将 UI 终态处理集中为幂等 helper,确保 done、error、silent finished 都能停止计时、冲刷待显示文本、清除
_asking并恢复发送按钮;done 不等待网络或 worker 退出。保留已完成气泡 / 元数据 / 最终耗时,避免用会清空这些字段的_cancel_stream()直接处理 done。 - worker 将有效 done 视为终止事件,发出后不再拉取下一项;在 finally 中安全关闭拥有 close() 的迭代器。即使底层关闭有延迟,UI 在步骤 1 已恢复。
- 选定忙碌时点击工具的明确策略:若允许切换,则工具入口取消当前流并发起新的工具请求;若保持串行,则提供可见的忙碌反馈或禁用状态。当前静默吞点击不利于判断应用是否失效。不要误将旧 finished 用于解锁新请求,继续依赖 stream_generation 隔离。
- 在
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 路径。