Hồi quy đã vá
-------------
F-12 Kéo–thả hoặc dán tệp vào ô chat ném NameError. R08 tách `_Input` sang
`chat_input_box.py` nhưng để `_paths_from_mime()` ở lại
`composer_widget.py`, nên hai hàm sự kiện Qt gọi một cái tên không tồn
tại. Bốn hàm dùng chung chuyển sang `composer_mime.py` — module thứ ba
là chỗ duy nhất không lặp lại được lỗi này. Đo lại: cả thả lẫn dán đều
gắn 1 tệp, khớp bản trước refactor.
F-01 Đổi provider thì bộ chọn model AI-Edit không làm gì. Hook cũ kiểm
`folder.ai_model_combo`, thuộc tính R08-T12 đã dời sang
`ai_panel.resolver`. Làm mới vô điều kiện, đúng như tab cũ: lần lấy đầu
tiên hỏng thì đổi provider chính là lúc phải thử lại.
F-07 Hàng chọn kỳ của Dashboard bị đẩy xuống dưới các thẻ số liệu. Hàng này
lọc CẢ BA thẻ con chứ không riêng biểu đồ, nên để nó nằm dưới là bắt
người dùng đọc con số trước khi thấy con số đó tính cho kỳ nào. Kèm
theo: `TokenUsageCardWidget` bị bỏ sót `setContentsMargins(0,0,0,0)`
mà hai thẻ con còn lại đã có, đẩy cả hàng thẻ lệch 9px.
`check_layout_geometry` nay khớp TỪNG BYTE với bản trước refactor.
F-11 Hai lớp khai trùng tên phương thức; Python giữ bản sau nên bản đầu là
mã chết. `co4e_tab.py::showEvent` bản đầu gọi `_narrow_guard.attach()`
và không bao giờ chạy.
Tách file (F-09)
----------------
Bốn file chạm trần 400 dòng, mỗi lần cắt ra một trách nhiệm thật:
graph_renderer.py -> graph_scene_builder.py + graph_export.py
co4e_workflow_service.py -> co4e_run_history.py
json_config_repository.py -> config_sections.py
agents_admin_tab.py -> shared/agent_kind_visuals.py
File cuối còn xoá 3 bản sao của hàm đã có trong `shared/formatters.py`,
giống hệt đến từng dòng — nay định dạng thời gian và avatar không lệch nhau
giữa các bảng Giám sát nữa.
Docstring
---------
41,6% -> 100% (3.478/3.478 định nghĩa production), kể cả module dormant và
phương thức dunder. Toàn bộ phần bổ sung viết bằng tiếng Việt; comment tiếng
Anh có sẵn giữ nguyên — dịch ngược là một đợt riêng.
Seam chưa nối dây (F-05)
------------------------
9 seam mang nhãn `SEAM · dựng <ngày>` kèm hai câu: được nối khi nào, và để
dormant thì hỏng gì. Ngày lấy từ lịch sử git, không phải hạn tự đặt. Gate O
đọc nhãn đó và nhắc khi quá 30 ngày.
859 test xanh · 4/4 cổng CASAN · 19/24 checker khớp từng byte bản cũ.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
360 lines
14 KiB
Python
360 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 .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]] = {}
|
|
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():
|
|
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)
|
|
|