Files
cowork-local/presentation/chat/chat_panel.py
T
duylh19andClaude Opus 5 ddb31f9aff 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>
2026-09-22 08:39:43 +09:00

366 lines
15 KiB
Python

"""Base chat panel shared by the Cowork and Code tabs.
Provides: streaming transcript, a message queue, and history autosave.
Several messages can run **at the same time** inside one tab: each turn owns its
own worker thread and its own turn-context (assistant bubble, transcript record,
message list, output folder), so their streaming output and files never collide.
The number of simultaneous turns is capped by ``cowork.max_parallel`` (default 5);
extra messages wait in the composer queue and start automatically as slots free
up. Graph events are still forwarded per session.
"""
from __future__ import annotations
from .chat_event_stream import ChatEventStreamMixin
from .chat_panel_layout import ChatPanelLayoutMixin
from .chat_live_turns import ChatLiveTurnsMixin
from .chat_helpers import ( # noqa: F401 — giữ đường vào cũ
_TOOL_STATUS, _format_plan_steps, _is_scratch,
)
from .attachment_picker import AttachmentMixin
from .chat_output_panel import OutputPanelMixin
from .chat_agents import ChatAgentsMixin
from .chat_turn_runner import ChatTurnRunnerMixin
from .chat_session_store import ChatSessionMixin
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtCore import QFileSystemWatcher
from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QMessageBox, QPushButton, QSplitter,
QVBoxLayout, QWidget,
)
from ...core.worker import AgentWorker
from ...i18n import on_language_changed, tr
from ...state import AppContext
from ...theme import current_palette
from .chat_bubble_style import ThinkingIndicator
from .chat_history_widget import ChatView
from .composer_widget import Composer
from ...ui.icons import collapse_right_icon, icon as app_icon
from ...ui.osutil import is_image, open_path
from ...ui.widgets import CollapsibleSection, CollapseStrip, PlanSection
_PLAN_ICONS = {"pending": "○", "running": "▶", "done": "✓", "error": "✗"}
# Friendly "what the agent is doing now" translation keys for the working
# indicator, so a long file/document build reads as "Creating…" rather than a
# generic "Running".
class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin, AttachmentMixin,
OutputPanelMixin,
ChatAgentsMixin,
ChatTurnRunnerMixin,
ChatSessionMixin,
QWidget):
"""Khung chat dùng chung, ghép từ 8 mixin: bố cục, dòng sự kiện, tệp đính kèm,
ô vào/ra, bộ chọn agent, chạy lượt, lưu phiên và các lượt đang chạy.
Màn Cowork kế thừa lớp này và chỉ ghi đè phần khác biệt của mình.
"""
graph_event = Signal(str, dict) # (session_name, event)
turn_finished = Signal(dict)
status_message = Signal(str)
output_changed = Signal(str) # workspace dir; emitted when a file is written
history_changed = Signal() # a session was created/updated → refresh History
def __init__(self, ctx: AppContext, kind: str, session_name: str,
placeholder_key: str = "composer.placeholder_default"):
"""Dựng khung chat cho một bề mặt (``kind``) và mở một phiên mới.
``placeholder_key`` là khoá i18n chứ không phải chữ sẵn, để đổi ngôn ngữ là
chữ gợi ý đổi theo.
"""
super().__init__()
from ...core.history import new_session_id
self.ctx = ctx
self.kind = kind
self.session_name = session_name
self.session_id = new_session_id()
self.title = ""
self._notify_title()
# Which project (workspace) this conversation belongs to — every new
# thread inherits the currently selected project (Claude-Projects style).
self.project_id = "default"
self.messages: List[Dict[str, Any]] = []
# self.worker points at the most-recently-started worker (kept for
# back-compat); every running turn is tracked in self._active so several
# 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
# runs: the running turn keeps writing to its own conversation in the
# background, and reopening it attaches to the SAME list (never a stale disk
# copy), so the two never race on save.
self._sessions_live: Dict[str, List[Dict[str, Any]]] = {}
self._teams_worker: AgentWorker | None = None
self.turns: List[Dict[str, Any]] = []
# File system watcher — watches the workspace/output folder for new files
# and auto-loads them into the agent's context on the next turn.
self._file_watcher = QFileSystemWatcher(self)
self._file_watcher.directoryChanged.connect(self._on_watched_dir_changed)
self._known_files: set = set() # set of known file paths in the watched dir
self._watch_debounce = QTimer(self)
self._watch_debounce.setSingleShot(True)
self._watch_debounce.setInterval(800) # debounce rapid file changes
self._watch_debounce.timeout.connect(self._process_new_watched_files)
self._watched_dir: Optional[Path] = None
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
root.setSpacing(0)
self._toolbar = QWidget()
self.toolbar_v = QVBoxLayout(self._toolbar)
self.toolbar_v.setContentsMargins(10, 8, 10, 4)
self.toolbar_v.setSpacing(4)
self.toolbar_layout = QHBoxLayout() # first row; tabs may add more rows
self.toolbar_layout.setSpacing(8)
self.toolbar_v.addLayout(self.toolbar_layout)
root.addWidget(self._toolbar)
self.chat_view = ChatView()
self.composer = Composer(placeholder_key)
self.composer.submitted.connect(self.submit)
self.composer.stop_requested.connect(self.stop)
self.composer.attachments_added.connect(self._on_attachments_added)
self.composer.attachment_removed.connect(self._on_attachment_removed)
self.composer.attach_limit_note.connect(self.status_message)
self.composer.manage_skills.connect(self._open_skills_manager)
self.composer.set_max_attachments(
int(ctx.config.data.get("attachments", {}).get("max_files", 10) or 0))
# Conversation token/cost total (↓in ↑out ▤total $cost) — bottom-left,
# updated after each turn; cost uses the Monitoring model-price table.
self._usage_total_lbl = QLabel("")
self._usage_total_lbl.setObjectName("hint")
self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};")
# Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma /
# qwen for the local provider). Cowork and Code pick independently and
# run in parallel. The list is fetched from the active provider.
# The per-tab Agent defaults to the Settings model on startup; a manual
# pick (override) is remembered only until the active provider changes.
self._model = ctx.config.provider_conf().get("model", "")
self._agent_provider = ctx.config.active_provider
self._agent_user_override = False
self._admin_agent = None # selected Admin-defined agent preset, if any
# Auto Model Routing override for the NEXT turn (set by _apply_routing when
# the router picks a different model). None → use the tab's own selection.
self._routed_provider: Optional[str] = None
self._routed_model: Optional[str] = None
self._last_turn_agent_signature = None # what ran the LAST turn (see _note_agent_switch)
self._pending_agent_switch_review = False
self._agent_worker: AgentWorker | None = None
self._agent_lbl = QLabel(tr("chatpanel.agent_label"))
self._agent_lbl.setObjectName("hint")
self.agent_combo = QComboBox()
self.agent_combo.setMinimumWidth(150)
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
self.agent_combo.currentIndexChanged.connect(self._on_agent_changed)
self.composer.add_bottom_left(self._agent_lbl)
self.composer.add_bottom_left(self.agent_combo)
# Off/Auto/Manual routing toggle — lets the router pick the best-fit
# model per message (see core/routing + _apply_routing).
from ...ui.routing_toggle import RoutingToggle
self.routing_toggle = RoutingToggle(ctx, self.kind)
# The drawing reads the strip left to right as
# Agent: … · Định tuyến: … · ↓in ↑out · $cost · 📁 folder
# so these sit together on the left, with the folder box the Cowork tab
# appends landing after them. Nén and Tự chạy stay on the right, where
# the control inventory marks them "giữ nguyên tại chỗ".
self.composer.add_bottom_left(self.routing_toggle)
self.composer.add_bottom_left(self._usage_total_lbl)
# Manual "compress conversation" — trim old history to cut tokens.
self.compress_btn = QPushButton(tr("chatpanel.compress_btn"))
self.compress_btn.setIcon(app_icon("compress"))
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
self.compress_btn.clicked.connect(self._compress_messages)
self.composer.add_bottom_right(self.compress_btn)
self.refresh_agents()
self._build_layout(root)
def _retranslate_base(self) -> None:
"""Re-apply the current language to the chrome shared by every tab
(Cowork/Code toolbars call their own retranslate on top of this)."""
self._agent_lbl.setText(tr("chatpanel.agent_label"))
self.agent_combo.setToolTip(tr("chatpanel.agent_tooltip"))
self.compress_btn.setText(tr("chatpanel.compress_btn"))
self.compress_btn.setToolTip(tr("chatpanel.compress_tooltip"))
self.input_section.set_title(tr("widgets.input_files"))
self.output_section.set_title(tr("widgets.output_files").upper())
self.plan_section.set_title(tr("widgets.plan_title"))
self._io_collapse_btn.setToolTip(tr("chatpanel.collapse_files_tooltip"))
self._files_header.setText(tr("chatpanel.files_header"))
self._io_strip.setToolTip(tr("chatpanel.expand_files_tooltip"))
def apply_theme(self) -> None:
"""Re-apply theme styles to the chat view so all existing message bubbles
adapt when the app switches between light and dark modes."""
self.chat_view.apply_theme()
# ---- hooks for subclasses ---------------------------------------
def assistant_title(self) -> str:
"""Nhãn hiện trên bong bóng trả lời; lớp con ghi đè để đổi tên trợ lý."""
return tr("chat.assistant")
# ---- file system watcher for auto-loading new files --------------
# ---- skills management (shared by Cowork and Code) ---------------
# ---- per-tab agent (model / admin-agent preset) selection --------
_ADMIN_AGENT_PREFIX = "admin:"
# Sent (invisibly — folded into the outgoing content, never the visible
# chat bubble) as a one-shot prefix on the FIRST turn run under a newly
# picked model/agent, when the conversation already has prior turns: asks
# the new model to check over the most recent step before doing anything
# new, so a mid-conversation switch doesn't silently drop continuity.
_MODEL_SWITCH_REVIEW_NOTE = (
"[Note: the AI model/agent for this conversation was just switched.] Before "
"addressing the request below, briefly re-check the most recent step above — "
"if anything there looks incomplete, inconsistent, or wrong, redo or fix it "
"first, then continue."
)
# ---- shared split-pane collapse helpers (used by subclasses too) ----
# ---- delete a turn (message + its input/output files) ------------
# ---- turn lifecycle ---------------------------------------------
# File types considered valid input data in the workspace/output folder
_INPUT_EXTS = {
".csv", ".json", ".txt", ".md", ".log", ".xml", ".yaml", ".yml",
".docx", ".docm", ".xlsx", ".xlsm", ".pptx", ".pdf", ".odt", ".ods", ".odp",
".rtf", ".tsv",
}
# ---- token / cost accounting (shown in the chat, Claude-style) ----------
# ---- Teams auto-notify ------------------------------------------
def _last_assistant_text(self) -> str:
"""Nội dung trả lời cuối cùng của trợ lý trong hội thoại; '' nếu chưa có."""
for m in reversed(self.messages):
if m.get("role") == "assistant" and m.get("content"):
return m["content"]
return ""
# ---- persistence -------------------------------------------------
def _busy(self) -> bool:
"""True while any turn is still running in this tab (any conversation)."""
return bool(self._active)
def _view_busy(self) -> bool:
"""True while the CURRENTLY-VIEWED conversation has a turn running."""
return any(c.get("home_id") == self.session_id for c in self._active.values())
def _sync_indicators(self) -> None:
"""Reflect the CURRENT conversation's agent status in the chat box + composer.
Switching chats, or hitting History → Refresh, shows whether THIS chat is
still processing (a background turn) or idle."""
if self._view_busy():
# Đã 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
self.composer.set_busy(self._view_busy() or len(self._active) >= self._max_parallel())
def refresh_status(self) -> None:
"""Public: re-sync the on-screen agent status for the current conversation
(used by the History Refresh button)."""
self._sync_indicators()
def _max_parallel(self) -> int:
"""Unlimited concurrent turns — no cap (the old Settings limit was removed).
A large sentinel keeps the queue logic intact without ever gating."""
return 100000
def active_workers(self) -> List[AgentWorker]:
"""Workers for turns still running (used to stop them all on quit)."""
return list(self._active)