fix(chat): bấm Dừng thì báo rõ là đã dừng, không dội lỗi Python

Một lượt chạy đã 159 giây, bấm Dừng, rồi hoặc không thấy gì đổi, hoặc nhận một
dòng đỏ "'NoneType' object has no attribute 'read'". Cả hai đều không trả lời
được câu người dùng đang hỏi: nó dừng chưa?

Ba chỗ nói sai:

- stop() chỉ đặt cờ huỷ rồi thôi. Huỷ thật mất vài giây (đang chờ gateway, đang
  chạy dở một tool), mà trong khoảng đó chỉ báo vẫn đếm "Đang chạy · 160s".
  Nay đổi nhãn sang "Đang dừng" ngay lúc bấm, và _sync_indicators giữ nhãn đó
  khi chuyển tab.
- Huỷ giữa stream đóng socket, urllib3 ném AttributeError chứ không phải
  requests.RequestException, nên hai vòng đọc stream của provider bắt hụt và
  lỗi chui lên tận giao diện. Nay bắt rộng rồi lọc lại: chỉ nuốt khi thật sự
  đang huỷ, lỗi khác vẫn ném tiếp nguyên vẹn.
- Lượt chạy kết thúc sau khi huỷ vẫn đặt dấu XANH "Đã hoàn thành" (hoặc bong
  bóng lỗi đỏ nếu kết thúc bằng ngoại lệ). Nay đặt "⏹ Đã dừng theo yêu cầu", và
  thanh trạng thái nói cùng một chuyện.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 08:39:43 +09:00
co-authored by Claude Opus 5
parent b500b3e57d
commit ddb31f9aff
7 changed files with 335 additions and 7 deletions
+7 -1
View File
@@ -99,6 +99,9 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
# can run concurrently. Each value is a turn-context dict — see _start_turn.
self.worker: AgentWorker | None = None
self._active: Dict[AgentWorker, Dict[str, Any]] = {}
# Người dùng đã bấm Dừng cho lượt đang chạy chưa. Cờ này chỉ đổi thứ
# MÀN HÌNH nói, không đổi việc huỷ: huỷ vẫn là cờ trên worker.
self._stop_requested = False
self._turn_seq: int = 0
# session_id -> its live messages list, for every conversation that still has
# a turn running. Lets you start a new chat / reopen an old one WHILE work
@@ -337,7 +340,10 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
Switching chats, or hitting History → Refresh, shows whether THIS chat is
still processing (a background turn) or idle."""
if self._view_busy():
self.thinking.start("chat.running") # this conversation is still working
# Đã bấm Dừng mà lượt chưa kết thúc: giữ nhãn "đang dừng", đừng kéo
# ngược về "đang chạy" — người dùng vừa bấm xong mà thấy chữ cũ thì
# đọc ra là nút không ăn.
self.thinking.start("chat.stopping" if self._stop_requested else "chat.running")
else:
self.thinking.stop()
self.composer.set_running(bool(self._active)) # Stop shows while anything runs
+32 -3
View File
@@ -217,6 +217,7 @@ class ChatTurnRunnerMixin:
if self._view_busy() or len(self._active) >= self._max_parallel():
self.composer.set_busy(True)
self.status_message.emit(tr("chatpanel.working", name=tr(f"app.tab.{self.kind}")))
self._stop_requested = False # lượt mới: xoá dấu vết lần Dừng trước
self.thinking.start("chat.running")
worker.start()
@@ -259,7 +260,7 @@ class ChatTurnRunnerMixin:
self._show_usage(ctx) # per-turn + conversation token/cost
except Exception: # noqa: BLE001 — usage display must never break a turn
pass
done = self.chat_view.add_success(tr("chat.done_marker")) # green done marker in the chat box
done = self._dau_ket_thuc()
folder = self.workspace_dir()
if folder:
done.add_folder_link(str(folder), tr("chat.open_output_folder"))
@@ -273,12 +274,32 @@ class ChatTurnRunnerMixin:
self._maybe_notify_teams(result)
self._drain_queue()
def _dau_ket_thuc(self):
"""Dấu kết thúc đặt vào khung chat khi một lượt vừa xong.
Dừng theo yêu cầu KHÔNG phải là hoàn thành: dấu xanh "Đã hoàn thành" ở
đó nói ngược hẳn với thứ người dùng vừa làm, và là lý do người dùng báo
"bấm Dừng mà không biết nó đã dừng hay chưa".
Tách khỏi ``_on_finished`` để nhánh này kiểm được bằng test mà không
phải dựng cả một ``ChatPanel``.
"""
if self._stop_requested:
return self.chat_view.add_status(tr("chat.stopped_marker"))
return self.chat_view.add_success(tr("chat.done_marker"))
def _on_failed(self, ctx: Dict[str, Any], err: str) -> None:
"""Lượt chạy lỗi: huỷ thư mục kết quả tạm và hiện lỗi (nếu hội thoại còn đang mở)."""
live = self._turn_is_live(ctx)
self._end_turn(ctx)
self._cleanup_turn(ctx, False) # discard this turn's output sandbox
if live:
if live and self._stop_requested:
# Người dùng vừa bấm Dừng: mọi lỗi phát sinh trong lúc huỷ là hệ quả
# của chính việc huỷ (đóng socket giữa stream, tool bị cắt ngang).
# Dội một traceback đỏ vào mặt họ là trả lời sai câu hỏi "nó dừng
# chưa?" — thứ họ cần là một dòng nói rõ là đã dừng.
self._dau_ket_thuc()
elif live:
self.chat_view.add_error(err)
self.graph_event.emit(self.session_name, {"type": "error", "content": err})
from ...providers.base import is_model_not_found_error
@@ -291,7 +312,10 @@ class ChatTurnRunnerMixin:
self.composer.set_text(ctx["display_text"])
else:
self._persist_session(ctx)
self.status_message.emit(tr("chatpanel.failed", name=tr(f"app.tab.{self.kind}")))
# Thanh trạng thái phải nói cùng một chuyện với khung chat: dừng theo
# yêu cầu thì không phải "lỗi".
key = "chatpanel.stopped" if self._stop_requested else "chatpanel.failed"
self.status_message.emit(tr(key, name=tr(f"app.tab.{self.kind}")))
self.turn_finished.emit({"error": err})
self._drain_queue()
@@ -316,6 +340,11 @@ class ChatTurnRunnerMixin:
"""Dừng mọi lượt đang chạy của hội thoại này và xoá sạch hàng đợi."""
if not self._active:
return
# Báo NGAY trên màn: huỷ thật có thể mất vài giây (đang chờ gateway trả
# lời, đang chạy dở một tool), mà trong lúc đó chỉ báo vẫn đếm "Đang
# chạy · 160s" — người dùng đọc ra là nút Dừng không ăn.
self._stop_requested = True
self.thinking.set_label("chat.stopping")
for w in list(self._active):
if w.isRunning():
w.request_stop()