diff --git a/i18n/composer.py b/i18n/composer.py index d49b72b..5fe08cb 100644 --- a/i18n/composer.py +++ b/i18n/composer.py @@ -114,6 +114,8 @@ STRINGS: Dict[str, Dict[str, str]] = { "chatpanel.working": {"en": "{name}: working…", "ja": "{name}: 処理中…", "vi": "{name}: đang xử lý…"}, "chatpanel.done": {"en": "{name}: done.", "ja": "{name}: 完了。", "vi": "{name}: xong."}, "chatpanel.failed": {"en": "{name}: error.", "ja": "{name}: エラー。", "vi": "{name}: lỗi."}, + "chatpanel.stopped": {"en": "{name}: stopped.", "ja": "{name}: 停止しました。", + "vi": "{name}: đã dừng."}, "chatpanel.stopping": {"en": "{name}: stopping…", "ja": "{name}: 停止中…", "vi": "{name}: đang dừng…"}, "chatpanel.attach_limit": { "en": "Max {n} attachments — extra files were skipped.", diff --git a/i18n/sidebar.py b/i18n/sidebar.py index 49d26d5..57054c4 100644 --- a/i18n/sidebar.py +++ b/i18n/sidebar.py @@ -229,6 +229,11 @@ STRINGS: Dict[str, Dict[str, str]] = { "chat.open_folder": {"en": "Open folder", "ja": "フォルダを開く", "vi": "Mở thư mục"}, "chat.open_output_folder": {"en": "Open output folder", "ja": "出力フォルダを開く", "vi": "Mở thư mục output"}, "chat.done_marker": {"en": "Done", "ja": "完了しました", "vi": "Đã hoàn thành"}, + "chat.stopping": {"en": "Stopping", "ja": "停止中", "vi": "Đang dừng"}, + "chat.stopped_marker": { + "en": "⏹ Stopped at your request", + "ja": "⏹ リクエストにより停止しました", + "vi": "⏹ Đã dừng theo yêu cầu"}, "chat.session_folder_marker": { "en": "This conversation's output folder", "ja": "この会話の出力フォルダ", "vi": "Thư mục output của hội thoại này"}, diff --git a/presentation/chat/chat_panel.py b/presentation/chat/chat_panel.py index 01e845d..8b636f3 100644 --- a/presentation/chat/chat_panel.py +++ b/presentation/chat/chat_panel.py @@ -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 diff --git a/presentation/chat/chat_turn_runner.py b/presentation/chat/chat_turn_runner.py index 8f72a63..a9a94ec 100644 --- a/presentation/chat/chat_turn_runner.py +++ b/presentation/chat/chat_turn_runner.py @@ -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() diff --git a/providers/anthropic.py b/providers/anthropic.py index 29962db..5d9f26c 100644 --- a/providers/anthropic.py +++ b/providers/anthropic.py @@ -278,10 +278,17 @@ class AnthropicProvider(Provider): raise ProviderError(f"Anthropic: {evt.get('error', {}).get('message', 'error')}") resp.close() break # stream finished normally (or cancelled) - except requests.RequestException as exc: + except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới resp.close() + # Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError + # ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải + # RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng + # thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự + # đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn. if self._is_cancelled(cancel): break + if not isinstance(exc, requests.RequestException): + raise if text_parts or blocks: if on_text: on_text("\n⚠ Kết nối bị ngắt giữa chừng — hiển thị phần đã nhận được.\n") diff --git a/providers/openai_compat.py b/providers/openai_compat.py index 3b13a27..2920d1f 100644 --- a/providers/openai_compat.py +++ b/providers/openai_compat.py @@ -254,13 +254,19 @@ class OpenAICompatProvider(Provider): slot["args"] += fn["arguments"] resp.close() break # stream finished normally (or cancelled) - except requests.RequestException as exc: + except Exception as exc: # noqa: BLE001 — lọc lại ngay bên dưới resp.close() - # If cancel was requested, close cleanly without retry + # Huỷ giữa chừng đóng socket, và urllib3 ném AttributeError + # ("'NoneType' object has no attribute 'read'") chứ KHÔNG phải + # RequestException — bắt hẹp là lỗi đó lọt ra ngoài và người dùng + # thấy một lỗi Python đỏ thay vì "đã dừng". Chỉ nuốt khi thật sự + # đang huỷ; lỗi khác vẫn ném tiếp nguyên vẹn. if cancel_event is not None and cancel_event.is_set(): break if self._is_cancelled(cancel): break + if not isinstance(exc, requests.RequestException): + raise if text_parts or tool_acc: # Partial answer already on screen — keep it, note the cut. if on_text: diff --git a/tests/ui/test_stop_feedback.py b/tests/ui/test_stop_feedback.py new file mode 100644 index 0000000..adf6475 --- /dev/null +++ b/tests/ui/test_stop_feedback.py @@ -0,0 +1,273 @@ +"""Bấm "Dừng" phải thấy được là nó đã ăn. + +Triệu chứng người dùng báo: một lượt chạy đã 159 giây, bấm Dừng, rồi không có +gì đổi trên màn hình — chỉ báo vẫn đếm "Đang chạy · 160s", nên không biết nút +có tác dụng hay không. + +Hai chỗ nói sai, cả hai đều kiểm được: + +* Lúc bấm — ``stop()`` chỉ đặt cờ huỷ trên worker rồi thôi. 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 khoảng đó + màn hình vẫn nói "Đang chạy". +* Lúc kết thúc — ``_on_finished`` luôn đặt dấu XANH "Đã hoàn thành", kể cả khi + lượt chạy vừa bị người dùng dừng. Dấu đó nói ngược hẳn với thứ vừa xảy ra. + +Gọi thẳng phương thức với một ``self`` giả, không dựng ``ChatPanel`` thật: những +hàm này là Python thuần trên vài thuộc tính của chính nó, và dựng cả màn chat +trong ``tests/ui`` kéo theo QtWebEngine (xem +``test_graphrag_follows_folder_change.py``). +""" +from __future__ import annotations + +import pytest + +pytest.importorskip("PySide6", reason="cần PySide6 để nạp module") + +from cowork_local.presentation.chat.chat_panel import ChatPanel +from cowork_local.presentation.chat.chat_turn_runner import ChatTurnRunnerMixin + + +class _ChiBaoGia: + """Thay ``ThinkingIndicator`` — ghi lại nhãn nó được yêu cầu hiện.""" + + def __init__(self): + self.nhan = [] + + def set_label(self, key): + self.nhan.append(key) + + def start(self, key="chat.running"): + self.nhan.append(key) + + def stop(self): + self.nhan.append(None) + + +class _KhungChatGia: + """Thay ``chat_view`` — ghi lại loại dấu kết thúc được đặt vào.""" + + def __init__(self): + self.dau = [] + + def add_error(self, text): + self.dau.append(("error", text)) + return object() + + def add_status(self, text): + self.dau.append(("status", text)) + return object() + + def add_success(self, text): + self.dau.append(("success", text)) + return object() + + +class _WorkerGia: + def __init__(self, dang_chay=True): + self._dang_chay = dang_chay + self.da_yeu_cau_dung = False + + def isRunning(self): + return self._dang_chay + + def request_stop(self): + self.da_yeu_cau_dung = True + + +class _ComposerGia: + def __init__(self): + self.da_xoa_hang_doi = False + + def clear_queue(self): + self.da_xoa_hang_doi = True + + def set_running(self, _v): + pass + + def set_busy(self, _v): + pass + + def set_text(self, _v): + pass + + +class _TinNhanGia: + def __init__(self): + self.da_phat = [] + + def emit(self, *args): + self.da_phat.append(args[0] if len(args) == 1 else args) + + +class _Panel: + """``self`` giả cho các phương thức đang kiểm.""" + + kind = "cowork" + + def __init__(self, dang_chay=True): + self.worker = _WorkerGia(dang_chay) + self._active = {self.worker: {}} if dang_chay else {} + self.composer = _ComposerGia() + self.status_message = _TinNhanGia() + self.turn_finished = _TinNhanGia() + self.graph_event = _TinNhanGia() + self.session_name = "test" + self.thinking = _ChiBaoGia() + self.chat_view = _KhungChatGia() + self._stop_requested = False + self._ban_ron = dang_chay + + def _view_busy(self): + return self._ban_ron + + def _max_parallel(self): + return 2 + + # -- những thứ _on_failed cần, đều là no-op ------------------------- # + def _turn_is_live(self, _ctx): + return True + + def _end_turn(self, _ctx): + pass + + def _cleanup_turn(self, _ctx, _ok): + pass + + def _drain_queue(self): + pass + + def _persist_session(self, _ctx): + pass + + def _dau_ket_thuc(self): + """Gọi phương thức THẬT — đây là thứ đang được kiểm, không giả lập. + + Tra tên lúc GỌI chứ không lúc dựng lớp: tra lúc dựng thì trên bản + chưa sửa cả file test đổ ngay ở khâu thu thập, và một lỗi thu thập + không nói được gì về hành vi. + """ + return ChatTurnRunnerMixin._dau_ket_thuc(self) + + + +# ---- lúc bấm Dừng ------------------------------------------------------- + +def test_bam_dung_thi_chi_bao_doi_sang_dang_dung(): + """Đây là chỗ hỏng người dùng thấy: bấm xong màn hình không đổi gì.""" + p = _Panel() + + ChatTurnRunnerMixin.stop(p) + + assert p._stop_requested is True + assert "chat.stopping" in p.thinking.nhan, ( + "chỉ báo vẫn nói 'Đang chạy' — người dùng đọc ra là nút Dừng không ăn") + + +def test_bam_dung_van_yeu_cau_worker_dung_va_xoa_hang_doi(): + """Hành vi vốn có, không được mất khi thêm phần hiển thị.""" + p = _Panel() + + ChatTurnRunnerMixin.stop(p) + + assert p.worker.da_yeu_cau_dung is True + assert p.composer.da_xoa_hang_doi is True + + +def test_khong_co_luot_nao_chay_thi_bam_dung_khong_lam_gi(): + """Không có gì để dừng thì đừng nói dối là đang dừng.""" + p = _Panel(dang_chay=False) + + ChatTurnRunnerMixin.stop(p) + + assert p._stop_requested is False + assert p.thinking.nhan == [] + + +# ---- lúc lượt chạy kết thúc -------------------------------------------- + +def test_da_dung_thi_dat_dau_da_dung_chu_khong_phai_hoan_thanh(): + p = _Panel() + p._stop_requested = True + + ChatTurnRunnerMixin._dau_ket_thuc(p) + + loai, text = p.chat_view.dau[0] + assert loai == "status", "dừng theo yêu cầu mà vẫn đặt dấu xanh 'Đã hoàn thành'" + assert "dừng" in text.lower() or "stop" in text.lower(), text + + +def test_ket_thuc_binh_thuong_van_dat_dau_hoan_thanh(): + """Chặn một chiều là hỏng tính năng — lượt chạy xong xuôi vẫn phải xanh.""" + p = _Panel() + + ChatTurnRunnerMixin._dau_ket_thuc(p) + + assert p.chat_view.dau[0][0] == "success" + + +# ---- đồng bộ lại chỉ báo (đổi tab, Refresh History) -------------------- + +def test_dong_bo_lai_khong_keo_nhan_ve_dang_chay(): + """``_sync_indicators`` chạy lại khi chuyển tab; nó mà đặt lại + "chat.running" là nhãn "đang dừng" bị xoá ngay sau khi bấm.""" + p = _Panel() + p._stop_requested = True + + ChatPanel._sync_indicators(p) + + assert p.thinking.nhan[-1] == "chat.stopping" + + +def test_dong_bo_lai_khi_chua_bam_dung_thi_van_la_dang_chay(): + p = _Panel() + + ChatPanel._sync_indicators(p) + + assert p.thinking.nhan[-1] == "chat.running" + + + +# ---- lượt chạy kết thúc bằng NGOẠI LỆ vì vừa bị huỷ -------------------- + +def test_da_bam_dung_thi_loi_luc_huy_hien_ra_la_da_dung(): + """Đúng thứ người dùng chụp lại: bấm Dừng xong nhận một dòng đỏ + "'NoneType' object has no attribute 'read'" — đó là hệ quả của chính việc + huỷ (đóng socket giữa stream), không phải một lỗi cần báo.""" + p = _Panel() + p._stop_requested = True + + ChatTurnRunnerMixin._on_failed(p, {}, "'NoneType' object has no attribute 'read'") + + loai = [l for l, _ in p.chat_view.dau] + assert "error" not in loai, "vẫn dội lỗi Python ra màn hình sau khi người dùng bấm Dừng" + assert loai == ["status"] + assert "dừng" in p.chat_view.dau[0][1].lower() + + +def test_loi_that_khi_chua_bam_dung_van_bao_loi_nhu_cu(): + """Chặn một chiều là nuốt mất lỗi thật.""" + p = _Panel() + + ChatTurnRunnerMixin._on_failed(p, {}, "gateway 500") + + assert p.chat_view.dau == [("error", "gateway 500")] + + +def test_thanh_trang_thai_noi_cung_mot_chuyen_voi_khung_chat(): + p = _Panel() + p._stop_requested = True + + ChatTurnRunnerMixin._on_failed(p, {}, "bat ky loi gi") + + assert any("dừng" in t.lower() for t in p.status_message.da_phat), p.status_message.da_phat + +# ---- i18n --------------------------------------------------------------- + +@pytest.mark.parametrize("key", ["chat.stopping", "chat.stopped_marker", + "chatpanel.stopped"]) +def test_key_moi_co_du_ba_ngon_ngu(key): + from cowork_local import i18n + + entry = i18n.STRINGS[key] + for lang in ("en", "ja", "vi"): + assert entry.get(lang), f"{key} thiếu {lang}"