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>
397 lines
16 KiB
Python
397 lines
16 KiB
Python
"""Scrollable chat transcript built from message bubbles."""
|
|
from __future__ import annotations
|
|
|
|
from .chat_bubble_style import ( # noqa: F401 — giữ đường vào cũ
|
|
ThinkingIndicator, _TimelineGutter, _app_theme, _diff_legend, _dot_color, _p,
|
|
diff_to_html, format_status_line,
|
|
)
|
|
|
|
import html
|
|
from pathlib import Path
|
|
|
|
from PySide6.QtCore import QPointF, Qt, QTimer, Signal
|
|
from PySide6.QtGui import QColor, QPainter, QPen, QPixmap
|
|
from PySide6.QtWidgets import (
|
|
QFrame, QHBoxLayout, QLabel, QPushButton, QScrollArea, QTextBrowser,
|
|
QVBoxLayout, QWidget,
|
|
)
|
|
|
|
from ...i18n import on_language_changed, tr
|
|
from ...theme import palette, resolve_theme
|
|
from ...config import CONFIG_DIR
|
|
from ...ui.osutil import is_image, open_folder, open_path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class MessageBubble(QFrame):
|
|
"""One message; assistant/tool bubbles render markdown via QTextBrowser."""
|
|
|
|
def __init__(self, role: str, title: str = "", collapsible: bool = False,
|
|
collapsed: bool = True):
|
|
"""Một bong bóng tin nhắn.
|
|
|
|
``collapsible`` dành cho khối dài (kết quả tool, suy nghĩ của model): chúng
|
|
mở sẵn thì đẩy phần trả lời thật ra khỏi màn hình.
|
|
"""
|
|
super().__init__()
|
|
self.role = role
|
|
self._text = ""
|
|
self._collapsible = collapsible
|
|
self._title = title
|
|
self._head = None
|
|
# Point-conversation layout: [dot rail][content column].
|
|
outer = QHBoxLayout(self)
|
|
outer.setContentsMargins(0, 0, 0, 0)
|
|
outer.setSpacing(6)
|
|
self._gutter = _TimelineGutter(role)
|
|
outer.addWidget(self._gutter)
|
|
content = QWidget()
|
|
lay = QVBoxLayout(content)
|
|
lay.setContentsMargins(2, 4, 8, 8)
|
|
lay.setSpacing(4)
|
|
self._content_layout = lay
|
|
outer.addWidget(content, 1)
|
|
|
|
if title:
|
|
if collapsible:
|
|
# Clickable header that folds long tool output away to keep the
|
|
# transcript short. Collapsed by default; click to expand.
|
|
self._head = QPushButton(title)
|
|
self._head.setCursor(Qt.PointingHandCursor)
|
|
self._head.setStyleSheet(
|
|
"QPushButton { text-align:left; border:none; background:transparent;"
|
|
f" font-weight:600; color:{_p().text_muted}; padding:0; }}")
|
|
self._head.clicked.connect(self._toggle_body)
|
|
lay.addWidget(self._head)
|
|
else:
|
|
head = QLabel(title)
|
|
head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};")
|
|
lay.addWidget(head)
|
|
|
|
self.body = QTextBrowser()
|
|
self.body.setOpenExternalLinks(True)
|
|
self.body.setFrameShape(QFrame.NoFrame)
|
|
# Text color adapts to theme.
|
|
self._apply_theme_styles(role)
|
|
lay.addWidget(self.body)
|
|
|
|
self._apply_style(role)
|
|
if collapsible and collapsed:
|
|
self.body.setVisible(False)
|
|
if collapsible:
|
|
self._update_head()
|
|
|
|
def _toggle_body(self) -> None:
|
|
"""Gập/mở phần thân bong bóng khi bấm vào dòng tiêu đề."""
|
|
self.body.setVisible(not self.body.isVisible())
|
|
if self.body.isVisible():
|
|
self._autosize()
|
|
self._update_head()
|
|
|
|
def _update_head(self) -> None:
|
|
"""Vẽ lại dòng tiêu đề: mũi tên gập/mở, và khi đang gập thì kèm 70 ký tự
|
|
đầu làm xem trước — để nhìn dòng tiêu đề là đoán được bên trong có gì.
|
|
"""
|
|
if not self._head:
|
|
return
|
|
expanded = self.body.isVisible()
|
|
arrow = "▾" if expanded else "▸"
|
|
preview = ""
|
|
if not expanded and self._text.strip():
|
|
first = self._text.strip().splitlines()[0]
|
|
if len(first) > 70:
|
|
first = first[:70] + "…"
|
|
preview = f" {first}"
|
|
self._head.setText(f"{arrow} {self._title}{preview}")
|
|
|
|
def _current_theme(self) -> str:
|
|
"""Resolve the current app theme (light or dark)."""
|
|
return _app_theme()
|
|
|
|
def _apply_theme_styles(self, role: str) -> None:
|
|
"""Apply text color to the body QTextBrowser based on current theme + role."""
|
|
p = _p()
|
|
text_color = {
|
|
"success": p.success,
|
|
"error": p.danger,
|
|
"tool": p.text_muted, # secondary, like Claude's steps
|
|
}.get(role, p.text)
|
|
self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};")
|
|
|
|
def _apply_style(self, role: str) -> None:
|
|
"""Flat timeline row — no bubble box; the left dot/rail conveys role and
|
|
structure (Claude-Code style). The user's own message gets a faint tint
|
|
so questions are easy to pick out when scanning."""
|
|
p = _p()
|
|
if role == "user":
|
|
self.setStyleSheet(
|
|
f"QFrame {{ background: {p.surface}; border: none; "
|
|
f"border-radius: {p.radius}px; }}")
|
|
else:
|
|
self.setStyleSheet("QFrame { background: transparent; border: none; }")
|
|
|
|
def apply_theme(self) -> None:
|
|
"""Re-apply theme-dependent styles so existing rows adapt when the app
|
|
theme switches (light ↔ dark)."""
|
|
self._apply_theme_styles(self.role)
|
|
self._apply_style(self.role)
|
|
self._gutter.set_role(self.role)
|
|
|
|
def chat_view(self):
|
|
"""Walk up the parent chain to find the enclosing ChatView, if any."""
|
|
p = self.parent()
|
|
while p is not None:
|
|
if isinstance(p, ChatView):
|
|
return p
|
|
p = p.parent()
|
|
return None
|
|
|
|
def append_delta(self, delta: str) -> None:
|
|
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
|
|
self._text += delta
|
|
self.set_markdown(self._text)
|
|
|
|
def set_markdown(self, text: str) -> None:
|
|
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
|
|
self._text = text
|
|
self.body.setMarkdown(text)
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def set_plain(self, text: str) -> None:
|
|
"""Đặt toàn bộ nội dung dạng văn bản thuần (không diễn giải markdown)."""
|
|
self._text = text
|
|
self.body.setPlainText(text)
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def append_plain(self, delta: str) -> None:
|
|
"""Nối thêm một mẩu văn bản thuần đang phát dần (dùng cho log tool)."""
|
|
self._text += delta
|
|
self.set_plain(self._text)
|
|
|
|
def set_diff(self, diff_text: str) -> None:
|
|
"""Render a unified diff (see :func:`diff_to_html`) with colored
|
|
before/after lines instead of a flat text block."""
|
|
self._text = diff_text
|
|
self.body.setHtml(diff_to_html(diff_text))
|
|
self._autosize()
|
|
if self._collapsible:
|
|
self._update_head()
|
|
|
|
def add_usage(self, text: str) -> None:
|
|
"""A small muted token/cost footer under the message (↓in ↑out ▤ctx $cost),
|
|
like Claude Code. Replaces any previous usage line on this bubble."""
|
|
existing = getattr(self, "_usage_lbl", None)
|
|
if existing is not None:
|
|
existing.setText(text)
|
|
return
|
|
lbl = QLabel(text)
|
|
lbl.setObjectName("faint")
|
|
lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;")
|
|
self._usage_lbl = lbl
|
|
self._content_layout.addWidget(lbl)
|
|
|
|
def add_delete_link(self, callback) -> None:
|
|
"""Gắn liên kết "xoá" màu cảnh báo dưới bong bóng, bấm vào thì gọi ``callback``."""
|
|
link = QLabel(f'<a href="#del" style="color:{_p().danger};">{tr("chat.delete_link")}</a>')
|
|
link.setToolTip(tr("chat.delete_tooltip"))
|
|
link.linkActivated.connect(lambda *_: callback())
|
|
self._content_layout.addWidget(link)
|
|
|
|
def add_folder_link(self, folder: str, label: str | None = None) -> None:
|
|
"""Gắn liên kết mở thư mục kết quả trong trình quản lý tệp của hệ điều hành."""
|
|
label = label or tr("chat.open_workspace")
|
|
link = QLabel(f'<a href="#open" style="color:{_p().accent};">{label}</a>')
|
|
link.setToolTip(str(folder))
|
|
link.linkActivated.connect(lambda *_: open_folder(folder))
|
|
self._content_layout.addWidget(link)
|
|
|
|
def add_attachments(self, paths) -> None:
|
|
"""Show attached files: images as thumbnails, others as clickable links."""
|
|
for p in paths:
|
|
path = str(p)
|
|
name = Path(path).name
|
|
if is_image(path):
|
|
pix = QPixmap(path)
|
|
if not pix.isNull():
|
|
thumb = QLabel()
|
|
thumb.setPixmap(pix.scaledToWidth(min(320, pix.width()), Qt.SmoothTransformation))
|
|
thumb.setToolTip(name)
|
|
thumb.setCursor(Qt.PointingHandCursor)
|
|
self._content_layout.addWidget(thumb)
|
|
continue
|
|
file_link = QLabel(f'<a href="#open" style="color:{_p().accent};">{name}</a>')
|
|
file_link.setToolTip(path)
|
|
file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp))
|
|
self._content_layout.addWidget(file_link)
|
|
|
|
def _autosize(self) -> None:
|
|
"""Đặt chiều cao bong bóng vừa đúng nội dung.
|
|
|
|
QTextBrowser không tự co giãn theo nội dung, nên phải tính tay. Chặn
|
|
trong khoảng 28..1200px: quá thấp thì mất chữ, quá cao thì một câu trả
|
|
lời dài chiếm trọn màn hình và không cuộn tới cái sau được.
|
|
"""
|
|
width = self.body.viewport().width()
|
|
if width <= 0:
|
|
width = 560 # sensible default before the widget is laid out
|
|
self.body.document().setTextWidth(width)
|
|
height = int(self.body.document().size().height()) + 12
|
|
self.body.setFixedHeight(max(28, min(height, 1200)))
|
|
|
|
def resizeEvent(self, event): # noqa: N802 - re-flow on width change
|
|
"""Đổi bề rộng thì phải tính lại chiều cao — chữ xuống dòng khác đi."""
|
|
super().resizeEvent(event)
|
|
self._autosize()
|
|
|
|
|
|
class ChatView(QScrollArea):
|
|
"""Scrollable chat transcript.
|
|
|
|
Emits ``theme_changed`` (via the apply_theme method) so every child
|
|
``MessageBubble`` can re-apply its theme-aware inline styles when the
|
|
app switches between light and dark modes."""
|
|
|
|
def __init__(self):
|
|
"""Vùng cuộn chứa dòng thời gian tin nhắn.
|
|
|
|
Có một ``addStretch`` ở cuối để khi ít tin, chúng dồn lên trên thay vì trôi
|
|
giữa khung.
|
|
"""
|
|
super().__init__()
|
|
self.setWidgetResizable(True)
|
|
self._container = QWidget()
|
|
self._lay = QVBoxLayout(self._container)
|
|
self._lay.setContentsMargins(12, 12, 12, 12)
|
|
self._lay.setSpacing(10)
|
|
self._lay.addStretch(1)
|
|
self.setWidget(self._container)
|
|
|
|
def apply_theme(self) -> None:
|
|
"""Ask every MessageBubble inside this view to re-apply theme styles.
|
|
|
|
Called from ``ChatPanel.apply_theme`` whenever the app theme changes."""
|
|
for i in range(self._lay.count()):
|
|
item = self._lay.itemAt(i)
|
|
w = item.widget() if item else None
|
|
if isinstance(w, MessageBubble):
|
|
w.apply_theme()
|
|
|
|
def _add(self, bubble: MessageBubble) -> MessageBubble:
|
|
"""Chèn bong bóng vào cuối danh sách (trước phần đệm co giãn) rồi cuộn xuống."""
|
|
# insert before the trailing stretch
|
|
self._lay.insertWidget(self._lay.count() - 1, bubble)
|
|
self._scroll_to_bottom()
|
|
return bubble
|
|
|
|
def add_user(self, text: str) -> MessageBubble:
|
|
"""Thêm một tin nhắn của người dùng."""
|
|
b = MessageBubble("user", tr("chat.you"))
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_assistant(self, title: str | None = None) -> MessageBubble:
|
|
"""Thêm một bong bóng trả lời rỗng để model phát nội dung dần vào."""
|
|
b = MessageBubble("assistant", title or tr("chat.assistant"))
|
|
return self._add(b)
|
|
|
|
def add_tool(self, title: str, body: str, ok: bool = True) -> MessageBubble:
|
|
"""Thêm một bước tool (chạy lệnh, sinh mã, kết quả).
|
|
|
|
Chạy xuôi thì gập lại cho dòng thời gian ngắn gọn; lỗi thì mở sẵn vì đó
|
|
là thứ người dùng cần đọc ngay.
|
|
"""
|
|
# Tool steps (run command, generated code/diff, output) are collapsible to
|
|
# keep the transcript short — collapsed when OK, expanded on error.
|
|
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
|
b.set_plain(body)
|
|
return self._add(b)
|
|
|
|
def add_diff(self, title: str, diff_text: str, ok: bool = True) -> MessageBubble:
|
|
"""Like :meth:`add_tool`, but renders ``diff_text`` as a colored
|
|
before/after diff (see :func:`diff_to_html`) instead of flat text."""
|
|
b = MessageBubble("tool" if ok else "error", title, collapsible=True, collapsed=ok)
|
|
b.set_diff(diff_text)
|
|
return self._add(b)
|
|
|
|
def add_plan(self, body: str) -> MessageBubble:
|
|
"""The task plan shown INLINE in the timeline (never a pop-up or side
|
|
panel) — a permanent, always-expanded row whose steps tick off as they
|
|
complete. The agent re-sends the full list on each update; the caller
|
|
updates this same row in place via ``set_plain``."""
|
|
b = MessageBubble("tool", tr("widgets.plan_title"), collapsible=False)
|
|
b.set_plain(body)
|
|
return self._add(b)
|
|
|
|
def add_reasoning(self, title: str | None = None) -> MessageBubble:
|
|
"""Thêm hộp suy luận nội bộ của model — gập sẵn, mở ra đọc được.
|
|
|
|
Để người dùng thấy nó đang nghĩ mà không bị lấp kín khung chat.
|
|
"""
|
|
# The model's private reasoning — a collapsed, collapsible box so the user
|
|
# can see it's thinking (and expand to read) without it flooding the chat.
|
|
b = MessageBubble("tool", title or tr("chat.thinking"), collapsible=True, collapsed=True)
|
|
return self._add(b)
|
|
|
|
def add_error(self, text: str) -> MessageBubble:
|
|
"""Thêm một bong bóng báo lỗi."""
|
|
b = MessageBubble("error", tr("chat.error"))
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_status(self, text: str) -> MessageBubble:
|
|
"""A small one-line status marker in the transcript (e.g. '✅ Đã hoàn thành')."""
|
|
b = MessageBubble("tool", "")
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def add_success(self, text: str) -> MessageBubble:
|
|
"""Like :meth:`add_status`, but styled green — used for the "turn done"
|
|
marker so completion reads as an unmistakable success signal."""
|
|
b = MessageBubble("success", "")
|
|
b.set_plain(text)
|
|
return self._add(b)
|
|
|
|
def clear(self) -> None:
|
|
"""Xoá sạch dòng thời gian, giữ lại phần đệm co giãn ở cuối."""
|
|
while self._lay.count() > 1:
|
|
item = self._lay.takeAt(0)
|
|
w = item.widget()
|
|
if w:
|
|
w.deleteLater()
|
|
|
|
def scroll_to_bottom(self) -> None:
|
|
"""Scroll to the newest message, deferred so freshly-added bubbles have
|
|
finished sizing (their height is computed after layout)."""
|
|
QTimer.singleShot(0, self._scroll_to_bottom)
|
|
QTimer.singleShot(80, self._scroll_to_bottom)
|
|
|
|
def _scroll_to_bottom(self) -> None:
|
|
"""Kéo thanh cuộn xuống đáy ngay lập tức."""
|
|
bar = self.verticalScrollBar()
|
|
bar.setValue(bar.maximum())
|
|
|
|
|
|
ChatHistoryWidget = ChatView
|
|
|
|
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
|
|
|