Files
cowork-local/presentation/chat/chat_panel.py
T
Nam Pham Dinh ThanhandClaude Opus 5 7e11e9676d refactor: nốt 3 chỗ R08 còn thiếu — ChatPanel và 2 tab admin về đúng chỗ
Soát lại từng dòng plan thì thấy tôi báo R08 xong hơi sớm. Ba chỗ thiếu thật:

  T06  ChatPanel vẫn ở ui/, plan đòi presentation/chat/chat_panel.py
  T08  agents_admin_tab.py (498) và tools_admin_tab.py (245) vẫn ở ui/

    presentation/chat/chat_panel.py                    346
    presentation/monitoring/tabs/agents_admin_tab.py   383
    presentation/monitoring/tabs/agent_edit_dialog.py  143
    presentation/monitoring/tabs/tools_admin_tab.py    245
    ui/chat_panel.py / agents_admin_tab.py / tools_admin_tab.py  ~10 mỗi cái

agents_admin_tab.py 498 dòng nên tách thêm agent_edit_dialog.py: bảng danh
sách và hộp thoại sửa là hai việc, và hộp thoại còn tự đi hỏi provider xem có
model nào — thứ bảng không cần biết.

BA CHỖ CÒN LẠI KHÔNG PHẢI THIẾU, đã kiểm từng cái:
* audio_recorder_widget.py (T04) — repo KHÔNG có chức năng ghi âm nào.
* connector_settings_widget.py (T07) — UI Connector đã dời khỏi Cài đặt.
* sandbox_status_tab.py / mcp_history_tab.py (T08) — Hiệp đặt tên sandbox_tab
  và mcp_tab, nội dung đủ.

R08: 14/14 task, 0 file thiếu thật sự.
756 test xanh. 24/24 checker qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 01:24:45 +09:00

347 lines
14 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 ...ui.chat_view import ChatView, ThinkingIndicator
from ...ui.composer 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):
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"):
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]] = {}
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:
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:
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():
self.thinking.start("chat.running") # this conversation is still working
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)