refactor: vá 4 hồi quy, tách 4 file chạm trần LOC, docstring lên 100%

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>
This commit is contained in:
2026-08-30 10:41:45 +09:00
co-authored by Claude Opus 5
parent d20306be08
commit e29a0ccdbd
264 changed files with 4593 additions and 359 deletions
+4 -1
View File
@@ -17,11 +17,14 @@ from .chat_output_panel import OutputPanelMixin
from .chat_panel import ChatPanel
from .composer_widget import Composer, ComposerWidget
# ``ChatOutputPanelMixin`` từng nằm trong danh sách này nhưng module không hề
# export cái tên đó — tên thật là ``OutputPanelMixin`` (đã có ở dưới). Khai một
# tên không tồn tại làm ``from ... import *`` ném AttributeError, và ``__all__``
# thì không có bước kiểm nào bắt được. Đã gỡ.
__all__ = [
"AttachmentMixin",
"AudioRecorderWidget",
"ChatHistoryWidget",
"ChatOutputPanelMixin",
"ChatPanel",
"ChatView",
"Composer",
+5
View File
@@ -24,12 +24,14 @@ class AttachmentMixin:
def _on_attachments_added(self, paths: List[str]) -> None:
# Push attachments into the Input box as soon as they're attached.
"""Tệp vừa được đính kèm: đưa luôn vào mục "Tệp đầu vào" để người dùng thấy ngay."""
for p in paths:
self.input_section.add(p)
def _on_attachment_removed(self, path: str) -> None:
# A file added by mistake was removed in the composer — drop it from the
# Input panel too (only matters before the message is sent).
"""Gỡ tệp đính kèm nhầm khỏi cả mục "Tệp đầu vào" (chỉ có ý nghĩa trước khi gửi)."""
self.input_section.remove(path)
def _attach_char_limit(self) -> int:
@@ -137,6 +139,9 @@ class AttachmentMixin:
return result
def progress(page: int, total: int, _name=name) -> None:
"""Báo tiến độ trích nội dung, chỉ với tệp nhiều trang — tệp một trang thì dòng
tiến độ chỉ làm nhiễu.
"""
if notify is not None and total > 1:
notify({"type": "notice", "level": "progress",
"text": tr("chat.reading_progress", name=_name, page=page, total=total)})
@@ -38,6 +38,7 @@ class AudioRecorderWidget(QWidget):
audio_ready = Signal(bytes, str) # audio_data, format (e.g., 'wav')
def __init__(self, parent: Optional[QWidget] = None) -> None:
"""Dựng nút ghi âm kèm đồng hồ đếm giây, nhịp 1 giây."""
super().__init__(parent)
self._is_recording = False
self._elapsed_seconds = 0
+8
View File
@@ -36,6 +36,7 @@ class ChatAgentsMixin:
return self._model or tr("chat.provider_default_short")
def _on_agent_changed(self, _i: int) -> None:
"""Đổi agent trong bộ chọn: agent quản trị thì lấy cả provider/model riêng của nó."""
data = self.agent_combo.currentData() or ""
if isinstance(data, str) and data.startswith(self._ADMIN_AGENT_PREFIX):
# An Admin-defined agent preset (Monitoring → Agents Admin): runs
@@ -94,6 +95,7 @@ class ChatAgentsMixin:
self._agent_provider = name
def job(worker: AgentWorker):
"""Chạy nền: hỏi provider danh sách model để đổ vào bộ chọn agent."""
error = ""
try:
prov = self.ctx.build_provider_for(name)
@@ -105,6 +107,7 @@ class ChatAgentsMixin:
return {"models": models, "keep": keep, "error": error}
def done(result) -> None:
"""Đổ danh sách vào bộ chọn, giữ nguyên lựa chọn trước đó nếu còn."""
self._populate_agents(result.get("models", []), result.get("keep", ""))
# Surface the REAL reason models didn't load (network/auth/config)
# instead of silently falling back to "(provider default)".
@@ -118,6 +121,11 @@ class ChatAgentsMixin:
w.start()
def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent.
Bộ chọn này thực chất là bộ chọn MODEL: danh sách model của provider đang
dùng, cộng thêm các agent quản trị có provider/model riêng.
"""
self.agent_combo.blockSignals(True)
self.agent_combo.clear()
# The Agent picker is a MODEL picker — the raw model list of the active
+13
View File
@@ -53,15 +53,21 @@ class _TimelineGutter(QWidget):
(Claude-Code style) instead of separate boxes."""
def __init__(self, role: str):
"""Dải dọc bên trái mỗi bong bóng, màu theo vai người nói."""
super().__init__()
self._role = role
self.setFixedWidth(22)
def set_role(self, role: str) -> None:
"""Đổi vai trò (người dùng/trợ lý/tool/lỗi) và vẽ lại chấm mốc."""
self._role = role
self.update()
def paintEvent(self, _e): # noqa: N802
"""Vẽ dải mốc thời gian bên trái bong bóng: một đường mờ chạy suốt chiều cao
cộng một chấm màu theo vai trò — nối lại thành một trục liền mạch cho cả
dòng thời gian.
"""
p = QPainter(self)
p.setRenderHint(QPainter.Antialiasing)
tok = _p()
@@ -87,6 +93,7 @@ def _diff_legend(diff_text: str) -> str:
p = _p()
def pill(bg: str, fg: str, key: str) -> str:
"""Một nhãn viên thuốc (nền + chữ) trong chú giải diff."""
return (f'<span style="background:{bg}; color:{fg}; padding:1px 8px; '
f'border-radius:4px; font-weight:600;">{html.escape(tr(key))}</span>')
@@ -149,6 +156,7 @@ class ThinkingIndicator(QWidget):
running rather than stuck."""
def __init__(self):
"""Dòng "đang nghĩ…" hiện trong lúc chờ model trả lời."""
super().__init__()
lay = QHBoxLayout(self)
lay.setContentsMargins(14, 2, 14, 4)
@@ -167,6 +175,7 @@ class ThinkingIndicator(QWidget):
on_language_changed(self._render)
def start(self, label_key: str = "chat.running") -> None:
"""Bật chỉ báo "đang nghĩ" kèm nhãn và bộ đếm chấm động."""
self._base_key = label_key
self._override = None
self._ticks = 0
@@ -176,6 +185,7 @@ class ThinkingIndicator(QWidget):
self._timer.start()
def set_label(self, label_key: str) -> None:
"""Đổi nhãn đang hiện (ví dụ từ "đang chạy" sang "đang gọi tool")."""
if label_key != self._base_key:
self._base_key = label_key
self._override = None
@@ -189,14 +199,17 @@ class ThinkingIndicator(QWidget):
self._render()
def stop(self) -> None:
"""Tắt chỉ báo và dừng bộ đếm."""
self._timer.stop()
self._override = None
self.setVisible(False)
def _tick(self) -> None:
"""Một nhịp của hiệu ứng chấm động: tăng bộ đếm rồi vẽ lại nhãn."""
self._ticks += 1
self._render()
def _render(self) -> None:
"""Vẽ lại dòng trạng thái: nhãn hiện tại cộng chấm động theo bộ đếm."""
base = self._override if self._override is not None else tr(self._base_key)
self._label.setText(format_status_line(base, self._ticks))
+15 -1
View File
@@ -17,13 +17,21 @@ from PySide6.QtCore import Qt, Signal
from ...core.worker import AgentWorker
from ...i18n import tr
from ...state import AppContext
from ...ui.composer import Composer
# Nhập thẳng từ chỗ ở thật, không đi qua vỏ chuyển tiếp ``ui/composer.py``:
# vỏ ấy lại nhập ngược vào gói này, nên đi vòng qua nó tạo một chu trình
# import — ``import cowork_local.ui.composer`` khi chưa nạp gói này sẽ hỏng.
from .composer_widget import Composer
class ChatEventStreamMixin:
"""Xử lý sự kiện của một lượt. Trộn vào ChatPanel."""
def _on_event(self, ctx: Dict[str, Any], ev: Dict[str, Any]) -> None:
"""Nhận một sự kiện từ lượt chạy và vẽ vào dòng thời gian.
Trạng thái của lượt vẫn được cập nhật kể cả khi hội thoại này không còn hiện
trên màn — người dùng có thể quay lại và phải thấy đúng những gì đã xảy ra.
"""
etype = ev.get("type")
# Track the in-progress state even while this turn is a detached background
# job, so reopening its conversation can re-render the CURRENT task (partial
@@ -164,6 +172,12 @@ class ChatEventStreamMixin:
# falling back to the global "confirm before running commands" setting.
# Resolve on THIS turn's worker, never the latest — several turns may
# be awaiting approval at once.
"""Một tool cần phê duyệt trước khi chạy.
Tự đồng ý TRỪ KHI workspace này bật "hỏi trước khi chạy lệnh" (đặt riêng theo
workspace, không có thì lấy cấu hình chung). Trả lời phải gửi đúng worker của
LƯỢT NÀY, không phải worker mới nhất — có thể nhiều lượt cùng đang chờ duyệt.
"""
if self.ctx.project_confirm_commands():
from ...ui.permission_dialog import PermissionDialog
+42
View File
@@ -43,6 +43,11 @@ class MessageBubble(QFrame):
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 = ""
@@ -92,12 +97,16 @@ class MessageBubble(QFrame):
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()
@@ -153,10 +162,12 @@ class MessageBubble(QFrame):
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()
@@ -164,6 +175,7 @@ class MessageBubble(QFrame):
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()
@@ -171,6 +183,7 @@ class MessageBubble(QFrame):
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)
@@ -197,12 +210,14 @@ class MessageBubble(QFrame):
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))
@@ -229,6 +244,12 @@ class MessageBubble(QFrame):
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
@@ -237,6 +258,7 @@ class MessageBubble(QFrame):
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()
@@ -249,6 +271,11 @@ class ChatView(QScrollArea):
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()
@@ -269,21 +296,29 @@ class ChatView(QScrollArea):
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)
@@ -307,12 +342,17 @@ class ChatView(QScrollArea):
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)
@@ -331,6 +371,7 @@ class ChatView(QScrollArea):
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()
@@ -344,6 +385,7 @@ class ChatView(QScrollArea):
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())
+60 -14
View File
@@ -8,19 +8,11 @@ là thanh công cụ quanh nó.
"""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Dict, List
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage, QKeyEvent
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
)
from ...config import CONFIG_DIR
from ...i18n import on_language_changed, tr
from ...theme import current_palette
from ...ui.icons import icon, IconLabel
from PySide6.QtGui import QKeyEvent
from PySide6.QtWidgets import QListWidget, QListWidgetItem, QPlainTextEdit
from ...i18n import tr
from .composer_mime import paths_from_mime
class _SkillPopup(QListWidget):
@@ -33,6 +25,11 @@ class _SkillPopup(QListWidget):
an item selects it; the popup auto-hides when the input loses focus."""
def __init__(self, parent=None):
"""Danh sách gợi ý skill nổi lên khi gõ ``/``.
Đặt cờ không nhận tiêu điểm: bật lên mà cướp tiêu điểm thì con trỏ rời ô
nhập và người dùng gõ tiếp không được.
"""
super().__init__(parent)
self.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
| Qt.WindowStaysOnTopHint | Qt.NoDropShadowWindowHint)
@@ -51,6 +48,11 @@ class _Input(QPlainTextEdit):
MAX_HEIGHT = 220 # ~8 lines, then it scrolls
def __init__(self):
"""Ô soạn tin, nhận kéo–thả tệp.
Đặt phông riêng vì quy tắc ``*`` chung rơi về phông tiếng Nhật, làm một số
chữ tiếng Việt hiện sai dấu.
"""
super().__init__()
self.setAcceptDrops(True)
# Use a clean Latin/Vietnamese-friendly UI font for the input (the global
@@ -115,6 +117,11 @@ class _Input(QPlainTextEdit):
def _maybe_show_skills(self) -> None:
# One popup serves both commands: show skills while typing /skill, agents
# while typing /agent (Cowork parity with the Co4E chat).
"""Mở bảng gợi ý khi con trỏ đang đứng trên lệnh ``/skill`` hoặc ``/agent``.
Một bảng dùng chung cho cả hai lệnh, ``_popup_kind`` nhớ đang gợi ý loại
nào — giống hệt cách khung chat Co4E làm.
"""
stok = self._skill_token()
if stok is not None:
self._popup_kind = "skill"
@@ -130,6 +137,11 @@ class _Input(QPlainTextEdit):
self._skill_popup.hide()
def _populate_skill_popup(self, filt: str) -> None:
"""Nạp danh sách skill khớp bộ lọc vào bảng gợi ý.
Gồm cả skill dựng sẵn để bảng vẫn dùng được khi người dùng chưa tạo skill
nào. Dấu ✓ đánh dấu skill đang bật. Dòng cuối luôn là lối mở trình quản lý.
"""
try:
from ..core.skills import builtin_skills, list_skills
# Include always-on built-ins so the picker is usable before the user
@@ -158,6 +170,7 @@ class _Input(QPlainTextEdit):
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
def _populate_agent_popup(self, filt: str) -> None:
"""Nạp danh sách agent khớp bộ lọc (dựng sẵn + admin + tự tạo) vào bảng gợi ý."""
try:
from ..core.agent_command import collect_agents
agents = collect_agents("") # built-ins + local admin + custom agents
@@ -179,6 +192,11 @@ class _Input(QPlainTextEdit):
self._skill_popup.setCurrentRow(0 if matches else self._skill_popup.count() - 1)
def _show_cmd_popup(self) -> None:
"""Hiện bảng gợi ý ngay PHÍA TRÊN ô nhập, cao tối đa 7 dòng.
Đặt trên chứ không dưới vì ô nhập nằm sát đáy cửa sổ — thả xuống dưới là
rơi ra ngoài màn hình.
"""
rows = min(7, self._skill_popup.count())
h = 10 + rows * 22
self._skill_popup.resize(max(300, self.width()), h)
@@ -193,6 +211,11 @@ class _Input(QPlainTextEdit):
def focusOutEvent(self, e) -> None: # noqa: N802
# The popup never grabs focus, so a click away lands here → dismiss it
# (unless the click is on the popup itself, e.g. picking an item).
"""Bấm ra chỗ khác thì ẩn bảng gợi ý.
Bảng không bao giờ giành focus, nên mọi cú bấm ra ngoài đều rơi vào đây —
trừ khi chuột đang ở trên chính bảng, tức người dùng đang chọn một mục.
"""
if not self._skill_popup.underMouse():
self._skill_popup.hide()
super().focusOutEvent(e)
@@ -206,6 +229,11 @@ class _Input(QPlainTextEdit):
self._accept_skill(item)
def _replace_token(self, tok, replacement: str) -> None:
"""Thay đúng đoạn lệnh con trỏ đang đứng, giữ nguyên chữ trước và sau nó.
Nhờ vậy ``/skill:`` viết được ở giữa câu. Chặn tín hiệu lúc đặt lại nội
dung để không kích hoạt lại chính bảng gợi ý.
"""
pos = self.textCursor().position()
start = tok[0] if tok else pos
full = self.toPlainText()
@@ -221,6 +249,10 @@ class _Input(QPlainTextEdit):
self.setFocus()
def _accept_skill(self, item=None) -> None:
"""Chọn một skill: thay đoạn ``/skill…`` bằng ``/skill:<slug>``.
Mục cuối cùng (``__manage__``) không phải skill mà là lối mở trình quản lý.
"""
item = item or self._skill_popup.currentItem()
self._skill_popup.hide()
if item is None:
@@ -236,6 +268,7 @@ class _Input(QPlainTextEdit):
self._replace_token(self._skill_token(), f"/skill:{slug} ")
def _accept_agent(self, item=None) -> None:
"""Chọn một agent: thay đoạn ``/agent…`` bằng ``/agent:<slug>``."""
item = item or self._skill_popup.currentItem()
self._skill_popup.hide()
if item is None:
@@ -248,6 +281,11 @@ class _Input(QPlainTextEdit):
def _adjust_height(self, *_a) -> None:
# QPlainTextEdit reports the document height in LINES (not pixels), so
# convert via line spacing to get the real pixel height.
"""Co giãn ô nhập theo số dòng đang gõ, chặn trong ``MIN_HEIGHT``..``MAX_HEIGHT``.
``QPlainTextEdit`` trả chiều cao tài liệu theo SỐ DÒNG chứ không phải
pixel, nên phải nhân với khoảng cách dòng mới ra chiều cao thật.
"""
lines = self.document().size().height() or 1
line_px = self.fontMetrics().lineSpacing()
h = int(lines * line_px + 2 * self.frameWidth() + 12)
@@ -256,6 +294,9 @@ class _Input(QPlainTextEdit):
self.setFixedHeight(h)
def keyPressEvent(self, e: QKeyEvent) -> None: # noqa: N802
"""Điều hướng bảng gợi ý khi nó đang mở (lên/xuống/Enter/Esc); ngoài ra
Enter gửi tin nhắn còn Shift+Enter xuống dòng.
"""
if self._skill_popup.isVisible():
k = e.key()
if k in (Qt.Key_Down, Qt.Key_Up):
@@ -296,31 +337,36 @@ class _Input(QPlainTextEdit):
super().keyPressEvent(e)
def insertFromMimeData(self, source) -> None: # noqa: N802 - paste
paths = _paths_from_mime(source)
"""Dán ảnh hoặc đường dẫn tệp thì gắn thành tệp đính kèm thay vì dán chữ."""
paths = paths_from_mime(source)
if paths:
self.media_added.emit(paths)
return
super().insertFromMimeData(source)
def canInsertFromMimeData(self, source) -> bool: # noqa: N802
"""Cho phép dán cả ảnh và danh sách tệp, không chỉ văn bản."""
if source.hasImage() or source.hasUrls():
return True
return super().canInsertFromMimeData(source)
def dragEnterEvent(self, e) -> None: # noqa: N802
"""Nhận kéo-thả ảnh và tệp vào ô nhập."""
if e.mimeData().hasUrls() or e.mimeData().hasImage():
e.acceptProposedAction()
return
super().dragEnterEvent(e)
def dragMoveEvent(self, e) -> None: # noqa: N802
"""Giữ trạng thái nhận thả trong suốt lúc rê qua ô nhập."""
if e.mimeData().hasUrls() or e.mimeData().hasImage():
e.acceptProposedAction()
return
super().dragMoveEvent(e)
def dropEvent(self, e) -> None: # noqa: N802
paths = _paths_from_mime(e.mimeData())
"""Thả tệp/ảnh vào ô nhập: gắn thành tệp đính kèm của lượt chat."""
paths = paths_from_mime(e.mimeData())
if paths:
self.media_added.emit(paths)
e.acceptProposedAction()
+4
View File
@@ -110,10 +110,12 @@ class OutputPanelMixin:
self.register_output(path)
def on_inputs_added(self, paths: List[str]) -> None:
"""Ghi nhận các tệp người dùng vừa đính kèm vào mục "Tệp đầu vào"."""
for p in paths:
self.input_section.add(p)
def _open_io_item(self, path: str) -> None:
"""Mở một tệp trong danh sách vào/ra bằng ứng dụng mặc định."""
open_path(path)
def _io_context_menu(self, section, pos) -> None:
@@ -139,6 +141,7 @@ class OutputPanelMixin:
FileEditDialog(self.ctx, path, self).exec()
def _rebuild_io(self) -> None:
"""Dựng lại cả hai mục vào/ra từ trạng thái hiện tại của hội thoại."""
self.input_section.clear()
self.output_section.clear()
for t in self.turns:
@@ -148,6 +151,7 @@ class OutputPanelMixin:
self.output_section.add(p)
def _set_io_collapsed(self, collapsed: bool) -> None:
"""Gập/mở khối vào/ra, đổi giữa panel đầy đủ và dải mỏng."""
self._io_widget.setVisible(not collapsed)
self._io_strip.setVisible(collapsed)
strip_w = CollapseStrip.WIDTH + 2
+12
View File
@@ -63,6 +63,11 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
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)
@@ -71,6 +76,11 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
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
@@ -208,6 +218,7 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
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")
@@ -304,6 +315,7 @@ class ChatPanel(ChatLiveTurnsMixin, ChatPanelLayoutMixin, ChatEventStreamMixin,
# ---- 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"]
+26
View File
@@ -62,9 +62,13 @@ class ChatSessionMixin:
def _usage_label(self) -> str:
"""Nhãn dùng để gom số liệu token của hội thoại này: tiêu đề, hoặc id phiên nếu
chưa có tiêu đề.
"""
return self.title or self.session_id
def _session_events(self):
"""Các bản ghi token thuộc riêng hội thoại này."""
from ...core import usage_tracker as ut
label = self._usage_label()
return [e for e in ut.load_events()
@@ -100,6 +104,7 @@ class ChatSessionMixin:
return snap
def _session_cost_usd(self) -> float:
"""Tổng chi phí (USD) của hội thoại này, tính theo bảng đơn giá hiện hành."""
from ...core import model_pricing as mp
return sum(mp.turn_cost_usd(e.get("model", ""), e.get("in", 0), e.get("out", 0),
self.ctx.config) for e in self._session_events())
@@ -131,6 +136,11 @@ class ChatSessionMixin:
f"{ut.format_cost(self._session_cost_usd(), pricing)}")
def _autosave(self) -> None:
"""Tự lưu hội thoại sau mỗi mốc an toàn.
Bỏ qua khi người dùng tắt tự lưu, và khi hội thoại chưa có tin nhắn nào của
người dùng — không tạo file rỗng cho một khung chat vừa mở ra rồi bỏ đấy.
"""
if not self.ctx.config.history.get("autosave", True):
return
if not any(m.get("role") == "user" for m in self.messages):
@@ -153,6 +163,7 @@ class ChatSessionMixin:
pass # autosave must never disrupt the UI
def _maybe_notify_teams(self, result: Dict[str, Any]) -> None:
"""Gửi nhắc qua Teams khi lượt chạy xong, nếu đã bật và đã cấu hình webhook."""
teams = self.ctx.config.teams
notifier = self.ctx.teams_notifier()
if not (teams.get("notify_on_complete") and notifier.configured):
@@ -166,6 +177,7 @@ class ChatSessionMixin:
facts["Status"] = "Error"
def job(worker: AgentWorker):
"""Chạy nền: gửi thẻ thông báo lên webhook Teams."""
ok, detail = notifier.send(f"Cowork {self.session_name} — task done", summary[:1200], facts)
return {"ok": ok, "detail": detail}
@@ -175,6 +187,11 @@ class ChatSessionMixin:
w.start()
def new_session(self) -> None:
"""Mở một hội thoại mới.
Cho phép ngay cả khi đang chạy dở: lượt đang chạy vẫn tiếp tục ở nền và vẫn
ghi vào hội thoại cũ của nó.
"""
from ...core.history import new_session_id
# Allowed while work is running: current turns keep going in the background.
@@ -276,6 +293,11 @@ class ChatSessionMixin:
self.history_changed.emit() # current view changed → refresh History highlight
def _delete_turn(self, turn: Dict[str, Any]) -> None:
"""Xoá một lượt khỏi hội thoại, hỏi trước khi xoá cả tệp nó đã tạo ra.
Liệt kê tối đa 12 tệp trong hộp xác nhận — dài hơn thì người dùng không đọc
mà chỉ bấm Đồng ý.
"""
files = [p for p in (turn.get("inputs", []) + turn.get("outputs", [])) if p]
if files:
preview = "\n".join("• " + str(p) for p in files[:12])
@@ -315,6 +337,7 @@ class ChatSessionMixin:
msgs = list(self.messages)
def _tok(ms):
"""Ước lượng tổng token của một danh sách tin nhắn."""
return sum(estimate_tokens(str(m.get("content", ""))) for m in ms)
orig = _tok(msgs)
@@ -329,6 +352,9 @@ class ChatSessionMixin:
old_tok = _tok(old) or 1 # target: digest < 25% of the OLD part
def _digest(per_msg: int):
"""Rút gọn các tin nhắn cũ thành tóm tắt một dòng mỗi tin, mỗi tin không quá
``per_msg`` ký tự.
"""
parts = []
for m in old:
c = str(m.get("content", "")).strip().replace("\n", " ")
+34 -1
View File
@@ -16,7 +16,10 @@ from PySide6.QtCore import Qt, Signal
from ...core.worker import AgentWorker
from ...i18n import tr
from ...state import AppContext
from ...ui.composer import Composer
# Nhập thẳng từ chỗ ở thật, không đi qua vỏ chuyển tiếp ``ui/composer.py``:
# vỏ ấy lại nhập ngược vào gói này, nên đi vòng qua nó tạo một chu trình
# import — ``import cowork_local.ui.composer`` khi chưa nạp gói này sẽ hỏng.
from .composer_widget import Composer
class ChatTurnRunnerMixin:
@@ -25,6 +28,11 @@ class ChatTurnRunnerMixin:
def submit(self, text: str, attachments: Optional[List[str]] = None) -> None:
# Composer only emits 'submitted' when not busy; queued items are
# drained from here after each turn completes.
"""Gửi một tin nhắn mới.
Composer chỉ phát ``submitted`` khi khung chat đang rảnh; tin xếp hàng được
rút dần trong :meth:`_drain_queue` sau mỗi lượt xong.
"""
self._start_turn(text, attachments or [])
def run_prompts(self, prompts: List[str]) -> None:
@@ -48,6 +56,12 @@ class ChatTurnRunnerMixin:
raise NotImplementedError
def _start_turn(self, text: str, attachments: Optional[List[str]] = None) -> None:
"""Khởi động một lượt chat: xử lý lệnh ``/skill``, dựng snapshot yêu cầu, rồi
chạy agent ở luồng nền.
Lệnh ``/skill`` dạng liệt kê/chọn được trả lời tại chỗ, không tốn một lượt
gọi model nào.
"""
attachments = attachments or []
typed = text
prefix, request, info = self._apply_skill_command(text)
@@ -128,6 +142,11 @@ class ChatTurnRunnerMixin:
_review=review_switch):
# Worker thread: do the (possibly slow) attachment extraction here so
# the UI stays responsive, then run the real agent job.
"""Chạy nền: trích nội dung tệp đính kèm rồi mới chạy agent.
Trích ở luồng nền vì tệp lớn (PDF, Office) mất vài giây — làm ở luồng giao
diện là cả cửa sổ đứng hình.
"""
from ...core import usage_tracker
usage_tracker.set_context(self.kind, self.title or self.session_id)
body = self._augment(_t, _a, notify=worker.emit_event)
@@ -215,6 +234,12 @@ class ChatTurnRunnerMixin:
def _on_finished(self, ctx: Dict[str, Any], result: Dict[str, Any]) -> None:
"""Lượt chạy xong: dọn sandbox, giữ lại kế hoạch đã hoàn thành, rồi rút tiếp
tin trong hàng đợi.
Chỉ vẽ lên màn khi lượt này vẫn thuộc hội thoại ĐANG mở — người dùng có thể
đã chuyển sang hội thoại khác trong lúc chờ.
"""
live = self._turn_is_live(ctx)
self._end_turn(ctx)
self._cleanup_turn(ctx, True) # promote this turn's output folder, if any
@@ -240,6 +265,7 @@ class ChatTurnRunnerMixin:
self._drain_queue()
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
@@ -264,6 +290,12 @@ class ChatTurnRunnerMixin:
# Start the NEXT queued message only while THIS conversation is idle (one
# turn at a time here) and the global cap allows. Starting one flips
# _view_busy() to True, so exactly one runs — the queue drains in order.
"""Rút tin nhắn kế tiếp trong hàng đợi.
Chỉ chạy khi hội thoại NÀY đang rảnh và chưa chạm trần số lượt song song
toàn cục. Khởi động một lượt làm ``_view_busy()`` thành True nên đúng một
tin được lấy mỗi vòng — hàng đợi rút theo đúng thứ tự.
"""
while (not self._view_busy() and len(self._active) < self._max_parallel()
and self.composer.has_queue()):
nxt = self.composer.pop_next()
@@ -272,6 +304,7 @@ class ChatTurnRunnerMixin:
self._start_turn(nxt.get("text", ""), nxt.get("attachments", []))
def stop(self) -> None:
"""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
for w in list(self._active):
+86
View File
@@ -0,0 +1,86 @@
"""Đọc dữ liệu dán/kéo-thả vào ô soạn tin, và nhận diện lệnh trả lời tại chỗ.
Tách khỏi ``composer_widget.py`` vì hai bên đều cần: ô nhập
(``chat_input_box.py::_Input``) bắt sự kiện dán và thả, còn ``Composer`` bọc
ngoài quyết định tệp nào được nhận. Đợt tách widget (R08) để mấy hàm này ở lại
trong ``composer_widget.py`` trong khi phần gọi chúng đã sang
``chat_input_box.py`` — thành ra dán hay thả tệp vào ô chat đều ném
``NameError`` (F-12). Đặt ở một module thứ ba là chỗ duy nhất không lặp lại
được lỗi ấy.
"""
from __future__ import annotations
import re
from datetime import datetime
from typing import List
from PySide6.QtGui import QImage
from ...config import CONFIG_DIR
#: ``/skill`` hoặc ``/skill:<tên>`` đứng một mình — không có phần yêu cầu theo sau.
_BARE_SKILL = re.compile(r"^/skill:[\w\-.]+$")
#: ``/agent`` hoặc ``/agent:<tên>`` đứng một mình.
_BARE_AGENT = re.compile(r"^/agent:[\w\-.]+$")
def save_pasted_image(image) -> str | None:
"""Ghi ảnh vừa dán/kéo vào thư mục cấu hình; trả về đường dẫn, lỗi thì ``None``.
Tên tệp lấy tới phần nghìn giây vì dán liên tiếp trong cùng một giây là
chuyện thường — tính tới giây thôi là ảnh sau đè ảnh trước.
"""
try:
if not isinstance(image, QImage) or image.isNull():
return None
folder = CONFIG_DIR / "pasted"
folder.mkdir(parents=True, exist_ok=True)
name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png"
path = folder / name
if image.save(str(path), "PNG"):
return str(path)
except Exception: # noqa: BLE001 — dán ảnh hỏng không đáng làm vỡ ô nhập
return None
return None
def is_local_skill_command(text: str) -> bool:
"""``/skill`` (liệt kê) hoặc ``/skill:<tên>`` (chọn) — lệnh trả lời ngay tại chỗ.
Những lệnh này phải chạy được cả khi đang có lượt chat chạy dở, nên chúng đi
thẳng chứ không vào hàng đợi. Khác với ``/skill:<tên> <yêu cầu>``: cái đó là
một lượt chat thật và phải xếp hàng như mọi lượt khác.
"""
t = (text or "").strip()
return t == "/skill" or bool(_BARE_SKILL.match(t))
def is_local_agent_command(text: str) -> bool:
"""Như :func:`is_local_skill_command` nhưng cho lệnh ``/agent``."""
t = (text or "").strip()
return t == "/agent" or bool(_BARE_AGENT.match(t))
def paths_from_mime(md) -> List[str]:
"""Rút danh sách đường dẫn tệp từ dữ liệu kéo-thả/dán.
Ưu tiên đường dẫn tệp thật; chỉ khi không có tệp nào mới xét tới ảnh trong
bộ nhớ tạm và ghi nó ra tệp. Thứ tự này quan trọng: kéo một tệp ảnh từ
Explorer thì dữ liệu mang CẢ đường dẫn lẫn ảnh xem trước, và ta muốn tệp
gốc chứ không muốn một bản sao PNG.
"""
paths: List[str] = []
if md.hasUrls():
for u in md.urls():
if u.isLocalFile():
paths.append(u.toLocalFile())
if not paths and md.hasImage():
p = save_pasted_image(md.imageData())
if p:
paths.append(p)
return paths
__all__ = ["paths_from_mime", "save_pasted_image",
"is_local_skill_command", "is_local_agent_command"]
+33 -58
View File
@@ -7,77 +7,28 @@ turns finish and free up a slot. Files/images can be attached to a message.
"""
from __future__ import annotations
from .chat_input_box import _Input, _SkillPopup
from datetime import datetime
from pathlib import Path
from typing import Dict, List
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QImage, QKeyEvent
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QLabel, QListView, QListWidget, QListWidgetItem,
QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
QPushButton, QVBoxLayout, QWidget,
)
from ...config import CONFIG_DIR
from ...i18n import on_language_changed, tr
from ...theme import current_palette
from ...ui.icons import icon, IconLabel
def _save_pasted_image(image) -> str | None:
"""Save a clipboard/drag QImage to the config dir; return its path."""
try:
if not isinstance(image, QImage) or image.isNull():
return None
folder = CONFIG_DIR / "pasted"
folder.mkdir(parents=True, exist_ok=True)
name = "paste-" + datetime.now().strftime("%Y%m%d-%H%M%S-%f")[:-3] + ".png"
path = folder / name
if image.save(str(path), "PNG"):
return str(path)
except Exception:
return None
return None
def _is_local_skill_command(text: str) -> bool:
"""True for a bare ``/skill`` (list) or ``/skill:<name>`` (select) command that
is answered inline instantly — these must run even while a turn is busy, so they
bypass the message queue (unlike ``/skill:<name> <request>``, which is a real
turn and should queue)."""
import re
t = (text or "").strip()
return t == "/skill" or bool(re.match(r"^/skill:[\w\-.]+$", t))
def _is_local_agent_command(text: str) -> bool:
"""Same as ``_is_local_skill_command`` but for the ``/agent`` directive: a bare
``/agent`` (list) or ``/agent:<name>`` (select) is answered inline instantly."""
import re
t = (text or "").strip()
return t == "/agent" or bool(re.match(r"^/agent:[\w\-.]+$", t))
def _paths_from_mime(md) -> List[str]:
paths: List[str] = []
if md.hasUrls():
for u in md.urls():
if u.isLocalFile():
paths.append(u.toLocalFile())
if not paths and md.hasImage():
p = _save_pasted_image(md.imageData())
if p:
paths.append(p)
return paths
from .chat_input_box import _Input
from .composer_mime import is_local_agent_command, is_local_skill_command
class Composer(QWidget):
"""Vùng soạn tin: ô nhập, tệp đính kèm, hàng đợi tin nhắn và dải trạng thái.
Hàng đợi cho phép gõ tiếp trong lúc lượt trước còn chạy — chỗ gọi rút dần
qua :meth:`pop_next` sau mỗi lượt xong.
"""
submitted = Signal(str, list) # (text, attachment paths)
stop_requested = Signal()
queue_changed = Signal(int)
@@ -87,6 +38,11 @@ class Composer(QWidget):
manage_skills = Signal() # relayed from the /skill popup "Manage skills…"
def __init__(self, placeholder_key: str = "composer.placeholder_default"):
"""Dựng ô soạn kèm hàng đính kèm và hàng đợi tin nhắn.
Người dùng gõ tiếp trong lúc lượt trước đang chạy thì tin mới vào hàng đợi
chứ không bị bỏ.
"""
super().__init__()
self._placeholder_key = placeholder_key # i18n key, re-looked-up on language change
self._queue: List[Dict] = [] # each: {"text": str, "attachments": [str]}
@@ -182,6 +138,7 @@ class Composer(QWidget):
on_language_changed(self._retranslate)
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho placeholder và các tooltip."""
self.queue_list.setToolTip(tr("composer.queue_tooltip"))
self.attach_list.setToolTip(tr("composer.attachments_tooltip"))
self.attach_btn.setToolTip(tr("composer.attach_btn_tooltip"))
@@ -193,6 +150,9 @@ class Composer(QWidget):
self._refresh_attachments()
def add_bottom_right(self, widget) -> None:
"""Gắn thêm một widget vào dải trạng thái dưới ô nhập (bộ chọn agent, định
tuyến, usage…).
"""
self.extra_row.addWidget(widget)
def add_bottom_left(self, widget) -> None:
@@ -205,6 +165,7 @@ class Composer(QWidget):
# ---- public API --------------------------------------------------
def set_text(self, text: str) -> None:
"""Đặt nội dung ô nhập và đưa con trỏ vào đó."""
self.input.setPlainText(text)
self.input.setFocus()
@@ -228,9 +189,11 @@ class Composer(QWidget):
self.stop_btn.setVisible(running)
def has_queue(self) -> bool:
"""Còn tin nhắn nào đang xếp hàng chờ gửi không."""
return bool(self._queue)
def pop_next(self) -> Dict | None:
"""Lấy tin nhắn kế tiếp trong hàng đợi ra; ``None`` nếu hàng đợi rỗng."""
if not self._queue:
return None
item = self._queue.pop(0)
@@ -238,15 +201,18 @@ class Composer(QWidget):
return item
def clear_queue(self) -> None:
"""Xoá sạch hàng đợi (dùng khi người dùng bấm Dừng)."""
self._queue.clear()
self._refresh_queue()
def enqueue(self, text: str, attachments: List[str] | None = None) -> None:
"""Xếp một tin nhắn vào cuối hàng đợi."""
self._queue.append({"text": text, "attachments": list(attachments or [])})
self._refresh_queue()
# ---- attachments -------------------------------------------------
def set_max_attachments(self, n: int) -> None:
"""Đặt trần số tệp đính kèm cho một tin nhắn."""
self._max_attachments = max(0, int(n or 0))
def _add_one(self, path: str) -> bool:
@@ -261,6 +227,7 @@ class Composer(QWidget):
return True
def _pick_attachments(self) -> None:
"""Mở hộp thoại chọn tệp đính kèm."""
files, _ = QFileDialog.getOpenFileNames(
self, tr("composer.attach_dialog_title"), "",
tr("composer.attach_dialog_filter"),
@@ -281,6 +248,7 @@ class Composer(QWidget):
self.input.setPlaceholderText(tr("chatpanel.attached_hint", names=names))
def _remove_attachment(self, item: QListWidgetItem) -> None:
"""Gỡ một tệp đính kèm khỏi danh sách."""
idx = self.attach_list.row(item)
if 0 <= idx < len(self._attachments):
self._remove_attachment_path(self._attachments[idx])
@@ -293,6 +261,7 @@ class Composer(QWidget):
self.attachment_removed.emit(path) # also drop it from the Input panel
def _refresh_attachments(self) -> None:
"""Vẽ lại danh sách tệp đính kèm."""
self.attach_list.clear()
for p in self._attachments:
item = QListWidgetItem()
@@ -328,6 +297,10 @@ class Composer(QWidget):
# ---- submit / queue ----------------------------------------------
def _on_submit(self) -> None:
"""Gửi tin: bận thì xếp hàng, rảnh thì phát ``submitted``.
Không gửi khi vừa trống chữ vừa không có tệp đính kèm.
"""
text = self.input.toPlainText().strip()
attachments = list(self._attachments)
if not text and not attachments:
@@ -338,19 +311,21 @@ class Composer(QWidget):
self.input.setPlaceholderText(tr(self._placeholder_key)) # clear any "Attached: …" hint
# A local /skill or /agent list/select command is answered inline instantly
# — run it now even while a turn is busy (don't bury it in the queue).
if self._busy and not (_is_local_skill_command(text) or _is_local_agent_command(text)):
if self._busy and not (is_local_skill_command(text) or is_local_agent_command(text)):
self._queue.append({"text": text, "attachments": attachments})
self._refresh_queue()
else:
self.submitted.emit(text, attachments)
def _remove_queue_item(self, item: QListWidgetItem) -> None:
"""Gỡ một tin khỏi hàng đợi trước khi nó được gửi."""
idx = self.queue_list.row(item)
if 0 <= idx < len(self._queue):
self._queue.pop(idx)
self._refresh_queue()
def _refresh_queue(self) -> None:
"""Vẽ lại danh sách hàng đợi và báo số lượng ra ngoài."""
self.queue_list.clear()
for i, entry in enumerate(self._queue, 1):
text = entry.get("text", "")
+1
View File
@@ -53,6 +53,7 @@ class AgentListPanel(QWidget):
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
super().__init__(parent)
self.new_btn = QPushButton(tr("co4e.new"))
self.new_btn.setIcon(icon("plus"))
+12
View File
@@ -28,10 +28,15 @@ _CORNER_R = 12 # edge elbow corner radius
def _dist(a: QPointF, b: QPointF) -> float:
"""Khoảng cách Euclid giữa hai điểm."""
return ((a.x() - b.x()) ** 2 + (a.y() - b.y()) ** 2) ** 0.5
def _towards(a: QPointF, b: QPointF, d: float) -> QPointF:
"""Điểm cách ``a`` một đoạn ``d`` theo hướng đi về ``b``.
Hai điểm trùng nhau thì trả về chính ``a``, tránh chia cho 0.
"""
dist = _dist(a, b)
if dist < 1e-6:
return QPointF(a)
@@ -74,6 +79,10 @@ def _seg_hits_rect(p1: QPointF, p2: QPointF, rect: QRectF) -> bool:
def _hits(points, obstacles) -> bool:
"""Đường gấp khúc này có cắt qua hình chữ nhật vật cản nào không.
Dùng để định tuyến đường nối lách qua các node khác.
"""
for i in range(len(points) - 1):
for r in obstacles:
if _seg_hits_rect(points[i], points[i + 1], r):
@@ -122,5 +131,8 @@ def _ortho_path(src: QPointF, dst: QPointF, r: float = _CORNER_R) -> QPainterPat
def _elide(text: str, n: int) -> str:
"""Cắt chuỗi cho vừa ``n`` ký tự và thay xuống dòng bằng dấu cách — nhãn trên
khung vẽ chỉ có một dòng.
"""
text = (text or "").replace("\n", " ")
return text if len(text) <= n else text[: n - 1] + "…"
@@ -49,6 +49,11 @@ class _CanvasInteractionMixin:
# ---- bottom-left overlay (zoom / fit) --------------------------------
def add_overlay(self, widget) -> None:
"""Gắn thanh điều khiển zoom/fit vào góc dưới-trái khung nhìn.
Widget được đặt làm con của ``viewport()`` chứ không của khung vẽ, nên nó
đứng yên khi người dùng kéo hay phóng to đồ thị.
"""
self._overlay = widget
widget.setParent(self.viewport())
widget.show()
@@ -56,6 +61,7 @@ class _CanvasInteractionMixin:
self._place_overlay()
def _place_overlay(self) -> None:
"""Ghim lại thanh điều khiển vào góc dưới-trái, cách lề 12px."""
if self._overlay is not None:
self._overlay.adjustSize()
vp = self.viewport()
@@ -63,10 +69,16 @@ class _CanvasInteractionMixin:
self._overlay.raise_()
def resizeEvent(self, e): # noqa: N802
"""Đổi kích thước khung thì ghim lại thanh điều khiển."""
super().resizeEvent(e)
self._place_overlay()
def scrollContentsBy(self, dx, dy): # noqa: N802
"""Cuộn khung thì ghim lại thanh điều khiển.
``QGraphicsView`` cuộn cả widget con của viewport theo cảnh, nên không
ghim lại thì nút +/−/fit sẽ trôi khỏi góc.
"""
# QGraphicsView scrolls the viewport's child widgets along with the
# scene, so panning/scrolling would drag the zoom overlay off-corner.
# Re-pin it after every scroll so +/−/fit stay fixed in place.
@@ -74,11 +86,19 @@ class _CanvasInteractionMixin:
self._place_overlay()
def showEvent(self, e): # noqa: N802
"""Lần hiện đầu tiên mới biết kích thước thật của viewport — ghim lại lúc đó."""
super().showEvent(e)
self._place_overlay() # viewport size is final once shown
# ---- zoom / fit -------------------------------------------------------
def _zoom_by(self, factor: float) -> None:
"""Phóng to/thu nhỏ theo hệ số, chặn trong khoảng ``_ZOOM_MIN``..``_ZOOM_MAX``.
Mức phóng hiện tại đọc thẳng từ ma trận biến đổi, KHÔNG dùng biến đếm
riêng: biến đếm lệch khỏi thực tế sau mỗi lần ``fit_view``/``relayout``/
``reset_zoom`` là đúng thứ từng làm nút +/− và Ctrl+lăn ngẫu nhiên chết.
Chặn ở đích rồi mới tính hệ số cần áp, nên vẫn phóng được sát mép giới hạn.
"""
# Derive the CURRENT scale from the live transform (never a separate
# accumulator that can drift out of sync with fit_view/relayout/reset —
# that drift is what made the +/− buttons and Ctrl+wheel randomly stop
@@ -92,16 +112,22 @@ class _CanvasInteractionMixin:
self._zoom = target
def zoom_in(self) -> None:
"""Phóng to một nấc (×1,15)."""
self._zoom_by(1.15)
def zoom_out(self) -> None:
"""Thu nhỏ một nấc (÷1,15)."""
self._zoom_by(1 / 1.15)
def reset_zoom(self) -> None:
"""Về đúng tỉ lệ 1:1 và bỏ mọi dịch chuyển."""
self.resetTransform()
self._zoom = 1.0
def wheelEvent(self, e):
"""Lăn chuột: Ctrl = phóng to/thu nhỏ quanh con trỏ, Shift = cuộn ngang,
không phím = cuộn dọc như mặc định.
"""
# Ctrl+wheel = zoom (anchored under the cursor); Shift+wheel = pan
# horizontally; plain wheel scrolls vertically.
if e.modifiers() & Qt.ControlModifier:
@@ -117,6 +143,7 @@ class _CanvasInteractionMixin:
# ---- middle-mouse drag-to-pan ----------------------------------------
def mousePressEvent(self, e):
"""Nhấn chuột giữa: bắt đầu kéo cả khung (pan), đổi con trỏ thành bàn tay nắm."""
if e.button() == Qt.MiddleButton:
self._panning = True
self._pan_start = e.position().toPoint()
@@ -126,6 +153,7 @@ class _CanvasInteractionMixin:
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
"""Đang kéo khung: dời hai thanh cuộn ngược chiều con trỏ."""
if self._panning and self._pan_start is not None:
pos = e.position().toPoint()
delta = pos - self._pan_start
@@ -137,6 +165,7 @@ class _CanvasInteractionMixin:
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
"""Thả chuột giữa: kết thúc kéo khung, trả con trỏ về bình thường."""
if e.button() == Qt.MiddleButton and self._panning:
self._panning = False
self.setCursor(Qt.ArrowCursor)
@@ -208,6 +237,9 @@ class _CanvasInteractionMixin:
# ---- key / drop -------------------------------------------------------
def keyPressEvent(self, e):
"""Phím tắt: Delete/Backspace xoá phần đang chọn, Esc huỷ lượt nối đang dở,
Ctrl +/−/0 phóng to/thu nhỏ/về 1:1.
"""
if e.key() in (Qt.Key_Delete, Qt.Key_Backspace):
self.delete_selected()
return
@@ -227,18 +259,25 @@ class _CanvasInteractionMixin:
super().keyPressEvent(e)
def dragEnterEvent(self, e):
"""Chỉ nhận thứ kéo vào mang đúng định dạng của bảng nguyên liệu Co4E."""
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragEnterEvent(e)
def dragMoveEvent(self, e):
"""Giữ trạng thái nhận thả trong suốt lúc rê qua khung."""
if e.mimeData().hasFormat(CO4E_MIME):
e.acceptProposedAction()
else:
super().dragMoveEvent(e)
def dropEvent(self, e):
"""Thả một bước hoặc cả một luồng từ bảng nguyên liệu xuống đúng vị trí con trỏ.
Payload hỏng thì bỏ qua lặng lẽ — thả nhầm thứ gì đó vào khung không
được phép làm vỡ màn hình.
"""
if not e.mimeData().hasFormat(CO4E_MIME):
super().dropEvent(e)
return
+56
View File
@@ -58,6 +58,9 @@ class _NodeItem(QGraphicsObject):
"""One draggable step card. Emits signals via the parent canvas."""
def __init__(self, node: Node, canvas: "Co4ECanvas"):
"""Thẻ một bước trên khung vẽ: kéo được, chọn được, và báo cho khung vẽ mỗi khi
nó đổi vị trí để đường nối vẽ lại theo.
"""
super().__init__()
self.node = node
self.canvas = canvas
@@ -70,13 +73,22 @@ class _NodeItem(QGraphicsObject):
self.setZValue(2)
def boundingRect(self) -> QRectF:
"""Khung bao của node, nới rộng hai bên cho hai cổng vào/ra vẽ trọn vẹn."""
# slack left/right so the input/output ports (now on the sides) paint cleanly
return QRectF(-_PORT_R - 2, -3, _NODE_W + 2 * _PORT_R + 4, _NODE_H + 6)
def _card_rect(self) -> QRectF:
"""Khung thẻ (đã trừ 1px viền) — phần thân thật sự được vẽ."""
return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2)
def paint(self, p, _opt, _widget=None):
"""Vẽ thẻ bước: viền, dải tiêu đề, nhãn, vai trò, xem trước nội dung, chân thẻ và hai cổng.
Dải tiêu đề dùng màu trạng thái đã pha loãng (alpha 48) chứ không dùng
nguyên màu — để chữ trên thẻ vẫn là thứ nổi nhất. Cổng vào (trái) rỗng
ruột, cổng ra (phải) đặc ruột: cổng đặc chính là tay nắm để kéo nối sang
bước sau, nên nó phải trông "cầm được".
"""
tok = current_palette()
step = self.node.data
accent = QColor(_status_color(self.status))
@@ -137,10 +149,21 @@ class _NodeItem(QGraphicsObject):
p.drawEllipse(QPointF(_NODE_W - 1, _NODE_H / 2), _PORT_R, _PORT_R)
def _in_out_port(self, pos: QPointF) -> bool:
"""Điểm ``pos`` có nằm trong vùng bắt của cổng ra không.
Vùng bắt (``_PORT_HIT``) rộng hơn hình vẽ để chuột không cần trúng đúng
chấm tròn nhỏ mới kéo được.
"""
d = pos - QPointF(_NODE_W, _NODE_H / 2)
return (d.x() * d.x() + d.y() * d.y()) ** 0.5 <= _PORT_HIT
def itemChange(self, change, value):
"""Đồng bộ vị trí node về dữ liệu, và đưa node đang chọn lên trên.
Kéo node xong phải ghi lại toạ độ vào ``self.node`` rồi vẽ lại đường nối,
nếu không lần lưu kế tiếp sẽ ghi toạ độ cũ. Node được chọn nhảy lên
z=4 (trên cả cạnh ở z=3) để không bị đường nối che lúc đang sửa.
"""
if change == QGraphicsItem.ItemPositionHasChanged:
self.node.x = float(self.pos().x())
self.node.y = float(self.pos().y())
@@ -154,11 +177,17 @@ class _NodeItem(QGraphicsObject):
return super().itemChange(change, value)
def hoverMoveEvent(self, e):
"""Đổi con trỏ thành bàn tay khi rê qua cổng ra — gợi ý là kéo nối được."""
# a hand cursor over the output port hints it's draggable-to-connect
self.setCursor(Qt.PointingHandCursor if self._in_out_port(e.pos()) else Qt.ArrowCursor)
super().hoverMoveEvent(e)
def mousePressEvent(self, e):
"""Bấm chuột: chốt lượt nối đang chờ, hoặc bắt đầu kéo nối từ cổng ra.
Thứ tự quan trọng: đang có lượt nối chờ (mở từ menu chuột phải) thì cú
bấm này là chọn đích, không phải chọn node.
"""
if self.canvas._connect_from is not None:
self.canvas._finish_connect(self.node.id)
e.accept()
@@ -172,6 +201,7 @@ class _NodeItem(QGraphicsObject):
super().mousePressEvent(e)
def mouseMoveEvent(self, e):
"""Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ."""
if self._porting:
self.canvas.update_port_drag(self.mapToScene(e.pos()))
e.accept()
@@ -179,6 +209,7 @@ class _NodeItem(QGraphicsObject):
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e):
"""Thả chuột: kết thúc lượt kéo nối (nối cạnh nếu rơi trúng node khác)."""
if self._porting:
self._porting = False
self.canvas.finish_port_drag(self.mapToScene(e.pos()))
@@ -187,10 +218,12 @@ class _NodeItem(QGraphicsObject):
super().mouseReleaseEvent(e)
def mouseDoubleClickEvent(self, e):
"""Bấm đúp một bước: báo lên để mở bảng thuộc tính của bước đó."""
self.canvas.node_activated.emit(self.node.id)
e.accept()
def contextMenuEvent(self, e):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu()
a_add = menu.addAction("+ Add next step")
a_conn = menu.addAction("→ Connect from here")
@@ -205,11 +238,22 @@ class _NodeItem(QGraphicsObject):
e.accept()
def center(self) -> QPointF:
"""Toạ độ tâm node trên khung — dùng để định tuyến đường nối."""
return self.pos() + QPointF(_NODE_W / 2, _NODE_H / 2)
class _EdgeItem(QGraphicsPathItem):
"""Một đường nối giữa hai bước, kèm mũi tên ở đầu đích.
Nằm ở z=3, tức trên thẻ bước (z=2), để đường nối không bao giờ bị thẻ
che khuất — node đang được chọn thì tự nhảy lên z=4.
"""
def __init__(self, edge: Edge, canvas: "Co4ECanvas"):
"""Đường nối giữa hai bước.
Đặt ``z=3``, trên thẻ bước (``z=2``): đường nối bị thẻ che thì không nhìn ra
luồng chạy nữa.
"""
super().__init__()
self.edge = edge
self.canvas = canvas
@@ -223,6 +267,7 @@ class _EdgeItem(QGraphicsPathItem):
self._apply_pen()
def _apply_pen(self):
"""Chọn màu và độ dày nét theo trạng thái: đang chọn > đang rê chuột > bình thường."""
tok = current_palette()
if self.isSelected():
color, w = QColor(tok.accent), 3
@@ -233,13 +278,20 @@ class _EdgeItem(QGraphicsPathItem):
self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin))
def update_path(self, points):
"""Vẽ lại đường theo danh sách điểm đã định tuyến, nhớ lại điểm cuối để đặt mũi tên."""
self._dst = points[-1] if points else None
self.setPath(_rounded_path(points))
def boundingRect(self):
"""Khung bao của đường, nới thêm 10px mỗi phía cho mũi tên."""
return super().boundingRect().adjusted(-10, -10, 10, 10) # room for the arrowhead
def shape(self):
"""Nới vùng bấm/chọn lên 14px.
Đường nối chỉ dày 2px — không nới thì gần như không thể trỏ trúng để
chọn hay mở menu xoá.
"""
# Widen the clickable/selectable area so a thin line is easy to grab.
from PySide6.QtGui import QPainterPathStroker
stroker = QPainterPathStroker()
@@ -247,18 +299,21 @@ class _EdgeItem(QGraphicsPathItem):
return stroker.createStroke(self.path())
def hoverEnterEvent(self, e):
"""Rê chuột vào: làm nét đậm lên để thấy rõ đang trỏ vào đường nào."""
self._hover = True
self._apply_pen()
self.update()
super().hoverEnterEvent(e)
def hoverLeaveEvent(self, e):
"""Rời chuột: trả nét về trạng thái bình thường."""
self._hover = False
self._apply_pen()
self.update()
super().hoverLeaveEvent(e)
def paint(self, p, opt, widget=None):
"""Vẽ đường nối và mũi tên tam giác chĩa vào cổng vào của node đích."""
self._apply_pen()
super().paint(p, opt, widget)
# arrowhead at the target, pointing right into its (left) input port
@@ -277,6 +332,7 @@ class _EdgeItem(QGraphicsPathItem):
p.drawPolygon(tri)
def contextMenuEvent(self, e):
"""Menu chuột phải trên đường nối: xoá liên kết."""
menu = QMenu()
act_del = menu.addAction("🗑 Delete connection")
if menu.exec(e.screenPos()) is act_del:
+10
View File
@@ -11,9 +11,12 @@ from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EAgentsMixin:
"""Quản lý agent tự tạo và thư viện skill dùng trong luồng Co4E."""
def _new_agent(self) -> None:
"""Mở hộp thoại tạo agent tự tạo mới."""
self._edit_agent_dialog(co4e.new_custom_agent(""))
def _edit_agent(self) -> None:
"""Sửa agent đang chọn; chưa chọn gì thì nhắc người dùng chọn."""
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
@@ -23,6 +26,7 @@ class Co4EAgentsMixin:
if agent is not None:
self._edit_agent_dialog(agent)
def _edit_agent_dialog(self, agent: co4e.CustomAgent) -> None:
"""Mở hộp thoại sửa agent; bấm OK thì lưu và nạp lại cột bên trái."""
from ...ui.co4e_agent_dialog import Co4EAgentDialog
dlg = Co4EAgentDialog(self.ctx, agent, _skill_names(), self)
@@ -30,6 +34,7 @@ class Co4EAgentsMixin:
co4e.save_custom_agent(dlg.result_agent())
self._reload_sidebar()
def _delete_agent(self) -> None:
"""Xoá agent đang chọn; chưa chọn gì thì nhắc người dùng chọn."""
item = self.agent_list.currentItem()
cid = item.data(Qt.UserRole + 1) if item else None
if not cid:
@@ -38,11 +43,16 @@ class Co4EAgentsMixin:
co4e.delete_custom_agent(cid)
self._reload_sidebar()
def _manage_skills(self) -> None:
"""Mở trình quản lý Skill, đóng xong thì nạp lại cột bên trái."""
from ...ui.skills_dialog import SkillsDialog
SkillsDialog(self, self.ctx).exec()
self._reload_sidebar()
def _skill_map(self) -> Dict[str, str]:
"""Bảng ``{tên skill: nội dung chỉ dẫn}`` để truyền cho một lượt chạy luồng.
Cắt bỏ dòng tiêu đề của khối chỉ dẫn, chỉ giữ phần nội dung.
"""
out = {}
for name in _skill_names():
block = skills_mod.skill_prefix_for(name)
+67
View File
@@ -44,6 +44,16 @@ __all__ = ["Co4ECanvas", "CO4E_MIME"]
class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
"""Khung vẽ luồng Co4E: node là bước, cạnh là thứ tự chạy.
Lớp này giữ *dữ liệu đồ thị* (thêm/xoá node, nối cạnh, định tuyến đường
nối). Phần thao tác chuột/bàn phím — phóng to, kéo màn, kéo-thả từ bảng
nguyên liệu — nằm ở ``_CanvasInteractionMixin`` để file này không vượt
hạn mức 400 dòng.
Mọi thay đổi làm đồ thị khác đi đều phát ``graph_changed`` để lớp trên
tự lưu.
"""
node_selected = Signal(str) # a node was clicked (→ config panel)
node_activated = Signal(str) # double-clicked
graph_changed = Signal() # nodes/edges/positions changed (autosave)
@@ -51,6 +61,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
_ZOOM_MIN, _ZOOM_MAX = 0.3, 3.0
def __init__(self):
"""Khung vẽ workflow: kéo chọn theo vùng, thu phóng lấy con trỏ làm tâm."""
super().__init__()
self.setObjectName("co4eCanvas") # themed frame (see theme.py)
self._scene = QGraphicsScene(self)
@@ -73,6 +84,12 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
# ---- load / serialize -------------------------------------------------
def load(self, nodes, edges) -> None:
"""Nạp lại toàn bộ đồ thị, xoá sạch khung cũ.
Cạnh trỏ tới node không tồn tại thì bỏ lặng lẽ — file luồng sửa tay
hoặc luồng cũ có thể còn cạnh mồ côi, và treo cả màn vì một cạnh hỏng
thì tệ hơn là bỏ nó đi.
"""
self._scene.clear()
self._nodes.clear()
self._edges.clear()
@@ -89,14 +106,22 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self._reposition_edges()
def nodes(self):
"""Danh sách node (dạng dữ liệu, không phải item đồ hoạ) để đem đi lưu."""
return [it.node for it in self._nodes.values()]
def edges(self):
"""Danh sách cạnh (dạng dữ liệu) để đem đi lưu."""
return [it.edge for it in self._edges]
# ---- mutation ---------------------------------------------------------
def add_node(self, step: Step, x: float = 60.0, y: float = 60.0,
connect_from: str = "") -> str:
"""Thêm một bước vào khung và trả về id node vừa tạo.
``connect_from`` trỏ tới node nào thì nối luôn một cạnh từ đó sang; id
không tồn tại thì bỏ qua phần nối. Node mới luôn được chọn ngay để bảng
thuộc tính bên phải mở đúng bước vừa thêm.
"""
node = Node(id=new_node_id(), x=x, y=y, data=step)
item = _NodeItem(node, self)
self._nodes[node.id] = item
@@ -123,13 +148,24 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
return tails[-1] if tails else (next(reversed(self._nodes), "") if self._nodes else "")
def add_palette_step(self, step: Step, pos: QPointF) -> None:
"""Thả một bước từ bảng nguyên liệu xuống đúng vị trí con trỏ.
Tự nối vào đuôi chuỗi hiện có, để kéo liên tiếp vài bước là thành một
luồng chạy được mà không phải nối tay từng cạnh.
"""
tail = self._chain_tail()
self.add_node(step, x=pos.x(), y=pos.y(), connect_from=tail)
def begin_connect(self, source_id: str) -> None:
"""Bắt đầu nối cạnh bằng menu chuột phải: ghi nhớ node nguồn."""
self._connect_from = source_id
def _finish_connect(self, target_id: str) -> None:
"""Kết thúc lượt nối bằng menu: tạo cạnh và xoá trạng thái đang nối.
Tự nối vào chính mình thì không tạo cạnh, nhưng vẫn xoá trạng thái —
nếu không, lần bấm kế tiếp sẽ nối nhầm từ node cũ.
"""
src = self._connect_from
self._connect_from = None
if src and src != target_id:
@@ -137,6 +173,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
# ---- manual drag-to-connect (from a node's output port) ---------------
def begin_port_drag(self, source_id: str, scene_pt: QPointF) -> None:
"""Bắt đầu kéo cạnh từ cổng ra của một node: dựng đường nét đứt tạm."""
self._port_src = source_id
self._port_src_pt = scene_pt
self._temp_edge = QGraphicsPathItem()
@@ -146,11 +183,17 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self._scene.addItem(self._temp_edge)
def update_port_drag(self, scene_pt: QPointF) -> None:
"""Vẽ lại đường nét đứt theo con trỏ trong lúc kéo."""
if self._temp_edge is None or self._port_src_pt is None:
return
self._temp_edge.setPath(_ortho_path(self._port_src_pt, scene_pt))
def finish_port_drag(self, scene_pt: QPointF) -> None:
"""Thả chuột: nối cạnh nếu rơi trúng một node khác, và luôn dọn đường tạm.
Thả vào chỗ trống hay vào chính node nguồn đều không tạo cạnh — nhưng
trạng thái kéo vẫn phải được xoá, nếu không đường nét đứt sẽ dính lại.
"""
src = self._port_src
if self._temp_edge is not None:
self._scene.removeItem(self._temp_edge)
@@ -162,12 +205,18 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self._make_edge(src, tgt)
def _node_at(self, scene_pt: QPointF) -> Optional[str]:
"""Id node nằm dưới một điểm trên khung; ``None`` nếu là chỗ trống."""
for it in self._scene.items(scene_pt):
if isinstance(it, _NodeItem):
return it.node.id
return None
def _make_edge(self, source: str, target: str) -> None:
"""Tạo cạnh nguồn → đích.
Bỏ qua cạnh tự nối và cạnh trùng cặp đã có — kéo hai lần cùng một
hướng không được sinh ra hai đường chồng lên nhau.
"""
if source == target:
return
if any(e.edge.source == source and e.edge.target == target for e in self._edges):
@@ -178,11 +227,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self.graph_changed.emit()
def _add_edge_item(self, edge: Edge) -> None:
"""Đưa một cạnh vào khung vẽ và vào danh sách quản lý."""
item = _EdgeItem(edge, self)
self._edges.append(item)
self._scene.addItem(item)
def delete_edge(self, edge: Edge) -> None:
"""Xoá cạnh, so khớp theo cặp nguồn/đích chứ không chỉ theo danh tính đối
tượng — cạnh có thể đã được dựng lại sau một lần nạp.
"""
for e in list(self._edges):
if e.edge is edge or (e.edge.source == edge.source and e.edge.target == edge.target):
self._scene.removeItem(e)
@@ -190,6 +243,7 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self.graph_changed.emit()
def delete_node(self, node_id: str) -> None:
"""Xoá một node và mọi cạnh dính vào nó. Id không tồn tại thì bỏ qua."""
item = self._nodes.pop(node_id, None)
if item is None:
return
@@ -202,23 +256,31 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
self.graph_changed.emit()
def delete_selected(self) -> None:
"""Xoá mọi node và cạnh đang được chọn.
Xoá node trước: node kéo theo cạnh của nó, nên vòng lặp cạnh phía sau
chỉ còn phải xử lý các cạnh được chọn riêng lẻ.
"""
for nid in [it.node.id for it in self._nodes.values() if it.isSelected()]:
self.delete_node(nid)
for e in [it.edge for it in self._edges if it.isSelected()]:
self.delete_edge(e)
def update_node_status(self, node_id: str, status: str) -> None:
"""Đổi màu trạng thái của một node lúc luồng đang chạy (đang chạy/xong/lỗi)."""
item = self._nodes.get(node_id)
if item is not None:
item.status = status
item.update()
def reset_statuses(self) -> None:
"""Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng."""
for it in self._nodes.values():
it.status = "idle"
it.update()
def refresh_node(self, node_id: str) -> None:
"""Vẽ lại một node sau khi nội dung bước của nó bị sửa."""
item = self._nodes.get(node_id)
if item is not None:
item.update()
@@ -236,6 +298,11 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
return out
def _reposition_edges(self) -> None:
"""Định tuyến lại mọi đường nối.
Cạnh luôn đi từ cổng ra (giữa cạnh phải) sang cổng vào (giữa cạnh trái)
và lách qua các node khác, nên luồng đọc được từ trái sang phải.
"""
for e in self._edges:
s = self._nodes.get(e.edge.source)
t = self._nodes.get(e.edge.target)
+27
View File
@@ -20,6 +20,11 @@ from ...presentation.co4e.co4e_chat_view import ChatPanel
class Co4EChatMixin:
"""Khung chat của Co4E Studio: mỗi luồng có nhật ký hội thoại RIÊNG.
Hai luồng chạy song song không được trộn tin nhắn vào nhau, nên mọi thứ ở
đây đều lấy nhật ký qua :meth:`_active_log` thay vì dùng một biến chung.
"""
def _build_chat(self) -> QWidget:
"""Widget construction lives in ``ChatPanel`` (presentation/co4e/
co4e_chat_view.py); this method just wires the panel's public
@@ -95,6 +100,7 @@ class Co4EChatMixin:
self.chat_stack.addWidget(log)
return log
def _active_log(self) -> ChatView:
"""Nhật ký hội thoại của luồng đang mở, tạo lười nếu chưa có."""
wf = getattr(self, "_wf", None)
return self._ensure_flow_log(wf.id if wf is not None else "__none__")
@property
@@ -104,11 +110,18 @@ class Co4EChatMixin:
return self._active_log()
@property
def _plan_bubble(self):
"""Bong bóng kế hoạch của luồng ĐANG mở; ``None`` nếu lượt này chưa có kế hoạch.
Cất trên chính nhật ký của luồng chứ không trên mixin, để hai luồng chạy song
song không ghi đè kế hoạch của nhau.
"""
return getattr(self._active_log(), "_co4e_plan_bubble", None)
@_plan_bubble.setter
def _plan_bubble(self, value) -> None:
"""Gắn bong bóng kế hoạch vào nhật ký của luồng đang mở."""
self._active_log()._co4e_plan_bubble = value
def _chat_send(self) -> None:
"""Gửi tin nhắn trong khung chat Co4E; đang chạy dở thì bỏ qua."""
text = self.chat_input.text().strip()
if not text or self._chat_worker is not None:
return
@@ -179,6 +192,10 @@ class Co4EChatMixin:
self._co4e_routed_provider = None
return ""
def _extract_agent_directive(self, text: str):
"""Tách lệnh ``/agent:<tên>`` khỏi câu người dùng gõ.
Trả về (tên agent, phần câu còn lại).
"""
m = re.search(r"(?<!\S)/agent:([\w\-.]+)", text)
if not m:
return "", text
@@ -186,6 +203,7 @@ class Co4EChatMixin:
rest = (text[:m.start()] + " " + text[m.end():]).strip()
return name, rest
def _resolve_agent(self, name: str):
"""Tìm agent theo slug hoặc tên, ưu tiên agent dựng sẵn rồi tới agent tự tạo."""
low = name.lower()
for a in BUILTIN_AGENTS:
if a.slug == low or a.name.lower() == low:
@@ -195,6 +213,10 @@ class Co4EChatMixin:
return (f"You are the {ca.role} agent — {ca.name}.\n{ca.instructions}", ca.model)
return None
def _run_chat_turn(self, system_parts: List[str], request: str, model: str) -> None:
"""Chạy một lượt chat ở luồng nền, ghi kết quả vào nhật ký của ĐÚNG luồng đã
phát lệnh — bắt giữ tham chiếu nhật ký ngay từ đầu, vì người dùng có thể
chuyển sang luồng khác trong lúc chờ.
"""
self.chat_send_btn.setEnabled(False)
log = self.chat_log # THIS flow's conversation (captured)
log._co4e_plan_bubble = None # a fresh plan for this turn
@@ -209,6 +231,7 @@ class Co4EChatMixin:
flow_label = wf.name if wf is not None else "flow"
def job(worker: AgentWorker):
"""Chạy nền: gọi agent Cowork và ghi nhận token đã dùng."""
from ...core import agent_roles, usage_tracker as ut
from ...core.chat_agent import run_cowork
from ...core.co4e_runner import _usage_delta
@@ -220,6 +243,7 @@ class Co4EChatMixin:
base = ut.accumulated()
def _emit(ev):
"""Chuyển tiếp sự kiện từ agent, lọc bỏ thứ khung chat này không dùng."""
if not isinstance(ev, dict):
return
t = ev.get("type")
@@ -240,6 +264,7 @@ class Co4EChatMixin:
return {"text": "", "usage": usage}
def on_event(ev):
"""Vẽ dần từng mẩu trả lời vào bong bóng của lượt này."""
if ev.get("type") == "text":
state["text"] += ev.get("delta", "")
assistant.set_markdown(state["text"])
@@ -248,6 +273,7 @@ class Co4EChatMixin:
self._append_plan(ev.get("steps") or [], log=log)
def done(result: dict):
"""Lượt xong: chốt nội dung cuối và mở khoá nút gửi."""
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
final = result.get("text") or state["text"]
@@ -256,6 +282,7 @@ class Co4EChatMixin:
log.scroll_to_bottom()
def failed(err: str):
"""Lượt lỗi: hiện lỗi ngay trong nhật ký và mở khoá nút gửi."""
self._chat_worker = None
self.chat_send_btn.setEnabled(True)
self._append_chat("error", f"[error: {err}]", log=log)
+15
View File
@@ -55,6 +55,7 @@ from ...ui.routing_toggle import RoutingToggle
def _skill_names() -> List[str]:
"""Tên mọi skill dùng được (tự tạo + dựng sẵn); lỗi đọc thì trả list rỗng."""
try:
return [s.name for s in skills_mod.list_skills() + skills_mod.builtin_skills()]
except Exception: # noqa: BLE001
@@ -62,6 +63,7 @@ def _skill_names() -> List[str]:
def _agent_names() -> List[str]:
"""Tên mọi agent dùng được: agent tự tạo trước, rồi tới agent dựng sẵn chưa trùng tên."""
names = [a.name for a in co4e.list_custom_agents()]
names += [a.name for a in BUILTIN_AGENTS if a.name not in names]
return names
@@ -89,6 +91,7 @@ class _ChatInput(QLineEdit):
submit = Signal()
def __init__(self, parent=None):
"""Ô soạn của khung chat Co4E, kèm danh sách gợi ý nổi lên khi gõ lệnh."""
super().__init__(parent)
self._popup = QListWidget()
self._popup.setWindowFlags(Qt.Tool | Qt.FramelessWindowHint
@@ -99,6 +102,7 @@ class _ChatInput(QLineEdit):
self.textEdited.connect(self._maybe_popup)
def _maybe_popup(self, *_a) -> None:
"""Mở bảng gợi ý khi con trỏ đang đứng trên một lệnh ``/skill`` hoặc ``/agent``."""
tok = _directive_token(self.text(), self.cursorPosition())
if tok is None:
self._popup.hide()
@@ -126,12 +130,14 @@ class _ChatInput(QLineEdit):
self._popup.show()
def _add_row(self, label: str, replacement: str, tip: str) -> None:
"""Thêm một dòng vào bảng gợi ý, kèm chuỗi sẽ chèn và tooltip mô tả."""
it = QListWidgetItem(label)
it.setData(Qt.UserRole, replacement)
it.setToolTip(tip)
self._popup.addItem(it)
def _accept(self) -> None:
"""Chọn một mục: chèn lệnh tương ứng vào ô nhập và đóng bảng gợi ý."""
item = self._popup.currentItem()
self._popup.hide()
if item is None:
@@ -147,11 +153,15 @@ class _ChatInput(QLineEdit):
self.setFocus()
def focusOutEvent(self, e): # noqa: N802
"""Bấm ra chỗ khác thì ẩn bảng gợi ý — trừ khi chuột đang ở trên chính bảng."""
if not self._popup.underMouse():
self._popup.hide()
super().focusOutEvent(e)
def keyPressEvent(self, e): # noqa: N802
"""Bảng gợi ý đang mở thì lên/xuống chọn mục, Enter nhận, Esc đóng;
không thì Enter gửi tin nhắn như bình thường.
"""
if self._popup.isVisible():
k = e.key()
n = self._popup.count()
@@ -199,6 +209,11 @@ class ChatPanel(QWidget):
"""
def __init__(self, ctx) -> None:
"""Khung chat của Co4E Studio.
Tiêu đề "Tin nhắn" nằm TRÊN hộp chat và đóng vai công tắc: bấm vào là ẩn/hiện
cả khối bên dưới, để lấy chỗ cho khung vẽ.
"""
super().__init__()
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
+14
View File
@@ -15,6 +15,11 @@ from ...ui.icons import icon
class Co4EFlowTabsMixin:
"""Dải tab kiểu trình duyệt của Co4E Studio: mỗi luồng đang mở là một tab.
Tab chỉ số 0 luôn là trang Flow Status được ghim, nên luồng thứ ``i`` nằm ở
tab ``i + 1`` — mọi phép đổi chỉ số trong file này đều theo quy ước đó.
"""
def _open_flow(self, wf: co4e.Workflow) -> None:
"""Open ``wf`` in a tab — reuse its tab if already open (like a browser),
else add a new one and switch to it. Bar index 0 is the pinned Runs tab,
@@ -76,6 +81,11 @@ class Co4EFlowTabsMixin:
self.flow_bar.setCurrentIndex(target)
def _on_flow_tab_changed(self, idx: int) -> None:
# save the outgoing flow (active_flow_idx is a FLOWS-list index) first
"""Đổi tab: lưu luồng vừa rời đi rồi nạp luồng mới lên khung vẽ.
Phải lưu TRƯỚC khi chuyển, nếu không thay đổi chưa lưu của luồng cũ sẽ bị
khung vẽ ghi đè khi nạp luồng mới.
"""
if 0 <= self._active_flow_idx < len(self._flows) and (self._active_flow_idx + 1) != idx:
self._sync_wf_from_canvas()
if idx <= 0: # the Runs page
@@ -111,11 +121,13 @@ class Co4EFlowTabsMixin:
btn.clicked.connect(lambda: self._close_flow_tab_button(btn))
self.flow_bar.setTabButton(idx, QTabBar.RightSide, btn)
def _close_flow_tab_button(self, btn) -> None:
"""Tìm tab ứng với nút ✕ vừa bấm rồi đóng tab đó."""
for i in range(self.flow_bar.count()):
if self.flow_bar.tabButton(i, QTabBar.RightSide) is btn:
self._close_flow_tab(i)
return
def _close_flow_tab(self, idx: int) -> None:
"""Đóng một tab luồng. Tab Flow Status (chỉ số 0) được ghim, không đóng được."""
if idx <= 0: # Runs tab is pinned
return
flow_idx = idx - 1
@@ -145,6 +157,7 @@ class Co4EFlowTabsMixin:
self.flow_bar.blockSignals(False)
self._on_flow_tab_changed(new_bar)
def _sync_active_flow_tab_text(self) -> None:
"""Cập nhật nhãn tab đang mở theo tên luồng; không bao giờ đổi tên tab Flow Status."""
i = self.flow_bar.currentIndex()
if i >= 1: # never rename the Runs tab
self.flow_bar.setTabText(i, self._wf.name or tr("co4e.untitled"))
@@ -176,5 +189,6 @@ class Co4EFlowTabsMixin:
runs never seed each other's context)."""
return self._flow_outputs.setdefault(wf_id, {})
def _update_run_btn(self) -> None:
"""Nhãn nút Chạy đổi theo trạng thái luồng đang mở: đang chạy thì thành "Dừng"."""
self.run_btn.setText(tr("co4e.interrupt") if self._cur_run_id() is not None
else tr("co4e.run"))
+5
View File
@@ -22,7 +22,11 @@ from ...presentation.co4e.co4e_run_control_widget import RunsPagePanel
class Co4ELayoutMixin:
"""Bố cục ba cột của Co4E Studio: cột trái, khung vẽ ở giữa, bảng thuộc tính
bên phải — cùng dải tab luồng phía trên.
"""
def _build_center(self) -> QWidget:
"""Dựng vùng giữa: dải tab luồng, khung vẽ và trang Flow Status xếp chồng."""
from PySide6.QtWidgets import QStackedWidget, QTabBar
page = QWidget()
lay = QVBoxLayout(page)
@@ -235,6 +239,7 @@ class Co4ELayoutMixin:
if narrow != self._config_collapsed:
self._toggle_config()
def _toggle_config(self) -> None:
"""Gập/mở bảng thuộc tính bên phải."""
self._config_collapsed = not self._config_collapsed
v = self._cfg_vlayout
if self._config_collapsed:
@@ -56,6 +56,11 @@ class RunsPagePanel(QWidget):
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""Trang danh sách lượt chạy.
Tự mang nút quay lại vì trang này phủ kín thanh công cụ — nút đã mở nó ra
nằm ngoài màn hình.
"""
super().__init__(parent)
v = QVBoxLayout(self)
hdr = QHBoxLayout()
+30
View File
@@ -20,10 +20,19 @@ from ...theme import current_palette
class Co4ERunsMixin:
"""Phần chạy luồng của Co4E Studio: nút Chạy, ba chế độ, và bảng Flow Status.
Nhiều luồng chạy song song được, nên mọi thứ ở đây đều đánh khoá theo
luồng: ``_flow_runs[wf_id]`` là run của từng luồng, ``_run_logs[run_id]``
là nhật ký nhận sự kiện của run đó. Khung vẽ chỉ phản chiếu run của luồng
ĐANG hiện, còn các run khác vẫn chạy nền bình thường.
"""
def _current_mode(self) -> str:
"""Chế độ chạy đang chọn: 'auto' (mặc định), 'plan' hay 'manual'."""
return self.mode_combo.currentData() or "auto"
def _on_mode_changed(self, *_a) -> None:
# switching mode resets any in-progress manual sequence
"""Đổi chế độ thì huỷ chuỗi chạy thủ công đang dở và trả nhãn nút về 'Chạy'."""
self._manual_active = False
self._manual_order = []
self._manual_idx = 0
@@ -31,6 +40,11 @@ class Co4ERunsMixin:
self.run_btn.setText(tr("co4e.run"))
def _on_run_clicked(self) -> None:
# THIS flow's run is active → interrupt it (other flows keep running).
"""Bấm nút Chạy: luồng NÀY đang chạy thì dừng nó, chưa chạy thì bắt đầu theo
chế độ đang chọn.
Chỉ dừng run của luồng đang mở — các luồng khác không bị đụng tới.
"""
cur = self._cur_run_id()
if cur is not None:
self.manager.stop(cur)
@@ -42,6 +56,12 @@ class Co4ERunsMixin:
self._start_canvas_run(plan_mode=(mode == "plan"))
def _start_canvas_run(self, *, plan_mode: bool, only: Optional[set] = None,
seed: Optional[Dict[str, str]] = None) -> None:
"""Bắt đầu chạy luồng trên khung vẽ.
``only`` giới hạn ở một nhóm bước (chạy lại một nhánh); để trống thì chạy
cả luồng và xoá sạch trạng thái/đầu ra cũ trước. ``seed`` là đầu ra sẵn có
đưa vào làm ngữ cảnh, để chạy tiếp một nhánh không mất kết quả phía trên.
"""
self._sync_wf_from_canvas()
if not self._wf.nodes:
self.status_message.emit(tr("co4e.no_steps"))
@@ -65,11 +85,13 @@ class Co4ERunsMixin:
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only={node_id}, seed=dict(self._outputs_for(self._wf.id)))
def _run_from(self, node_id: str) -> None:
"""Chạy lại từ một bước trở đi — tức bước đó và mọi bước phía sau nó."""
if self._cur_run_id() is not None:
return
self._start_canvas_run(plan_mode=(self._current_mode() == "plan"),
only=self._downstream(node_id), seed=dict(self._outputs_for(self._wf.id)))
def _downstream(self, node_id: str) -> set:
"""Tập id các bước nằm sau ``node_id`` trên đồ thị (kể cả chính nó)."""
adj: Dict[str, List[str]] = {}
for e in self.canvas.edges():
adj.setdefault(e.source, []).append(e.target)
@@ -82,6 +104,7 @@ class Co4ERunsMixin:
stack.extend(adj.get(cur, []))
return seen
def _manual_run_or_advance(self) -> None:
"""Chế độ thủ công: lần bấm đầu khởi tạo chuỗi bước, các lần sau đi tiếp một bước."""
if not self._manual_active:
self._sync_wf_from_canvas()
if not self._wf.nodes:
@@ -96,6 +119,7 @@ class Co4ERunsMixin:
self._append_chat("system", tr("co4e.manual_started", name=self._wf.name))
self._manual_step()
def _manual_step(self) -> None:
"""Chạy đúng một bước trong chuỗi thủ công, hoặc kết thúc nếu đã hết bước."""
if self._manual_idx >= len(self._manual_order):
self._manual_active = False
self.run_btn.setText(tr("co4e.run"))
@@ -113,6 +137,7 @@ class Co4ERunsMixin:
self._run_logs[run_id] = self.chat_log
self.run_btn.setText(tr("co4e.interrupt"))
def _topo_order(self) -> List[str]:
"""Thứ tự chạy các bước: theo lớp phụ thuộc trước, trong cùng lớp thì theo"""
nodes = self.canvas.nodes()
edges = self.canvas.edges()
waves = co4e.compute_waves(nodes, edges)
@@ -122,6 +147,7 @@ class Co4ERunsMixin:
# Per-flow routing: every run's events go to ITS OWN flow log (so parallel
# runs never mix), and the canvas mirrors ONLY the run whose flow is the
# one currently shown. Flow Status refreshes on its own via `changed`.
"""Nhận sự kiện của một run và chuyển về đúng nơi."""
h = self.manager.get(run_id)
run_wf = h.wf_id if h is not None else None
log = self._run_logs.get(run_id) or self.chat_log
@@ -199,6 +225,7 @@ class Co4ERunsMixin:
box.show()
def _refresh_runs(self) -> None:
# Rebuild the always-fresh Runs table from the manager (single source of truth).
"""Dựng lại bảng Flow Status từ dữ liệu của manager."""
if not hasattr(self, "runs_table"):
return
p = current_palette()
@@ -241,6 +268,7 @@ class Co4ERunsMixin:
if head is not None:
head.setText(("▾ " if head.isChecked() else "▸ ") + label.upper())
def _stop_selected_run(self) -> None:
"""Dừng run đang chọn; không chọn gì thì dừng toàn bộ run của workspace này."""
row = self.runs_table.currentRow()
it = self.runs_table.item(row, 0) if row >= 0 else None
if it is None:
@@ -262,6 +290,7 @@ class Co4ERunsMixin:
self._run_logs.pop(run_id, None)
self.manager.remove(run_id) # emits `changed` → _refresh_runs
def _runs_context_menu(self, pos) -> None:
"""Menu chuột phải trên bảng Flow Status: mở, đổi tên, chạy lại, dừng, xoá."""
from PySide6.QtWidgets import QMenu
item = self.runs_table.itemAt(pos)
if item is None:
@@ -321,6 +350,7 @@ class Co4ERunsMixin:
if self._wf.id == h.wf_id and self.name_edit.text() != new:
self.name_edit.setText(new) # updates _wf.name + active tab text
def _run_selected_in_background(self) -> None:
"""Chạy luồng đang chọn trong danh sách ở chế độ nền, không mở nó lên khung vẽ."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
+19
View File
@@ -20,6 +20,7 @@ from ...presentation.co4e.skills_list_panel import SkillsListPanel
class Co4ESidebarMixin:
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
def _build_sidebar(self) -> QWidget:
# ONE COLUMN, four named sections — no icon tabs. Every list is on screen
# at once, so "what can I drag onto the canvas" is answered by looking
@@ -28,6 +29,14 @@ class Co4ESidebarMixin:
# lists otherwise squeeze down to one visible row each. The splitter
# hands out the available height by weight and lets the user re-balance
# it by dragging; each list keeps a small minimum so none disappears.
"""Dựng cột trái: MỘT cột, bốn mục có tên, không dùng tab icon.
Mọi danh sách hiện cùng lúc nên câu hỏi "kéo được gì lên khung vẽ" trả lời
bằng cách NHÌN, không phải bấm qua ba tab không nhãn. Dùng splitter dọc chứ
không xếp cứng: cửa sổ thấp thì bốn danh sách chồng nhau sẽ bị bóp còn đúng
một dòng mỗi cái; splitter chia chiều cao theo trọng số và cho người dùng
tự cân lại.
"""
self._sections: dict = {}
self.sidebar = QWidget()
outer_col = QVBoxLayout(self.sidebar)
@@ -42,9 +51,15 @@ class Co4ESidebarMixin:
"""Adapter so the section builders below read the same as before."""
def __init__(self, split):
"""Bọc một ``QSplitter`` để phần còn lại thao tác với nó như một cột."""
self._split = split
def addWidget(self, w, stretch=1):
"""Thêm một mục vào splitter dọc kèm trọng số chia chiều cao.
Lớp bọc nhỏ này cho phép dùng splitter ở chỗ mã cũ đang gọi theo API của
layout, nên phần dựng mục không phải sửa.
"""
self._split.addWidget(w)
self._split.setStretchFactor(self._split.count() - 1, stretch)
@@ -203,14 +218,17 @@ class Co4ESidebarMixin:
box.setMaximumHeight(box.layout().itemAt(0).sizeHint().height() + 4)
self._sync_section_arrow(key)
def _sync_section_arrow(self, key: str) -> None:
"""Cập nhật mũi tên gập/mở và nhãn viết hoa của một mục ở cột trái."""
head, _body, _s = self._sections[key]
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key))
b.setFixedWidth(34)
b.clicked.connect(slot)
return b
def _reload_sidebar(self) -> None:
"""Nạp lại cả bốn danh sách ở cột trái: luồng, agent, skill và Flow Status."""
self.wf_list.clear()
for wf in co4e.list_workflows():
tag = tr("co4e.template") if wf.is_template else tr("co4e.saved")
@@ -246,6 +264,7 @@ class Co4ESidebarMixin:
self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
"""Dựng một mục trong bảng nguyên liệu, mang sẵn payload để kéo-thả lên khung vẽ."""
it = QListWidgetItem(icon(icon_name), text)
it.setData(Qt.UserRole, payload)
return it
+11
View File
@@ -36,6 +36,17 @@ mà mọi bước tách widget trước đó đã cố tình né. Trước khi
toàn riêng (characterization đầy đủ cho ``Co4ERunManager``) — xem
``tests/characterization/test_co4e_run_manager_behavior.py`` hiện có cho một
phần hành vi, chưa phủ hết 24 điểm gọi này.
SEAM · dựng 2026-08-25 · chưa nối dây (F-05)
------------------------------------------------------------
Được nối khi: ``presentation/shell/bootstrap.py`` gọi ``build_co4e_tab`` thay cho việc dựng thẳng ``ui.co4e_tab.Co4ETab``.
Để dormant thì sao: Hạn lắp đã ghi trong file là 28/08 và đã qua. Factory
không ai gọi thì chữ ký của nó không còn được kiểm chứng bởi bất cứ đường
chạy thật nào.
Cổng ``scripts/check_orphan_modules.py`` đếm tuổi seam từ ngày trên
và nhắc khi quá ``SEAM_MAX_AGE_DAYS``. Đổi nội dung dòng đó thì cổng
đọc theo — đừng sửa ngày để làm im lời nhắc.
"""
from __future__ import annotations
+32
View File
@@ -16,7 +16,18 @@ from ...presentation.co4e.co4e_chat_view import _skill_names
class Co4EWorkflowCrudMixin:
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
Tách khỏi ``Co4ETab`` vì đây là nhóm việc trên *danh sách luồng đã lưu*,
không phải trên khung vẽ đang mở — dù hai bên nói chuyện với nhau liên tục.
"""
def _apply_workflow(self, wf: co4e.Workflow) -> None:
"""Đưa một luồng lên khung vẽ và chuyển hội thoại sang nhật ký của chính nó.
KHÔNG xoá ``_flow_outputs``: mỗi luồng giữ ngữ cảnh tích luỹ riêng, đổi
tab mà xoá là mất kết quả của luồng khác. Luồng cũ xếp dọc thì tự bẻ lại
thành trái→phải cho khớp bố cục hiện tại.
"""
self._wf = wf
# Per-flow outputs are kept in self._flow_outputs[wf.id] — do NOT clear
# here (switching tabs must not wipe another flow's accumulated context).
@@ -31,6 +42,13 @@ class Co4EWorkflowCrudMixin:
self._update_run_btn() # reflect THIS flow's run state
self._refresh_usage_total() # show THIS flow's token/cost total
def _new_workflow(self) -> None:
"""Tạo luồng trống mới, đưa con trỏ vào ô tên và báo trạng thái.
Trước đây bấm nút này khi khung vẽ đã có sẵn một luồng trống chưa đặt tên
sẽ tạo ra một luồng trống y hệt — đúng logic nhưng nhìn không thấy gì đổi,
nên nút bị hiểu là hỏng. Giờ nói rõ vừa xảy ra chuyện gì và đặt con trỏ
vào đúng việc tiếp theo: đặt tên.
"""
self._open_flow(co4e.new_workflow(tr("co4e.untitled")))
# Pressing this while the canvas already holds an empty untitled flow
# produced an identical empty untitled flow — correct, and completely
@@ -47,16 +65,19 @@ class Co4EWorkflowCrudMixin:
_kind, ident = item.data(Qt.UserRole)
return co4e.get_workflow(ident)
def _load_selected_workflow(self, *_a) -> None:
"""Mở luồng đang chọn trong danh sách (hoặc chuyển sang tab của nó nếu đã mở)."""
wf = self._selected_wf()
if wf is not None:
self._open_flow(wf) # open (or focus) its browser-style tab
def _edit_selected_workflow(self) -> None:
"""Mở luồng đang chọn để sửa; chưa chọn gì thì nhắc người dùng chọn."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
return
self._open_flow(wf)
def _duplicate_selected_workflow(self) -> None:
"""Nhân bản luồng đang chọn thành một luồng mới rồi nạp lại danh sách."""
wf = self._selected_wf()
if wf is None:
self.status_message.emit(tr("co4e.select_flow"))
@@ -65,6 +86,7 @@ class Co4EWorkflowCrudMixin:
self._reload_sidebar()
self.status_message.emit(tr("co4e.duplicated_msg", name=dup.name))
def _wf_context_menu(self, pos) -> None:
"""Menu chuột phải trên danh sách luồng: sửa, đổi tên, nhân bản, chạy nền, xoá."""
lw = self.wf_list
item = lw.itemAt(pos)
if item is None:
@@ -106,6 +128,7 @@ class Co4EWorkflowCrudMixin:
self._reload_sidebar()
self.status_message.emit(tr("co4e.renamed_msg", name=name))
def _delete_selected_workflow(self) -> None:
"""Xoá luồng đang chọn khỏi đĩa rồi nạp lại danh sách."""
item = self.wf_list.currentItem()
if item is None:
return
@@ -113,26 +136,34 @@ class Co4EWorkflowCrudMixin:
co4e.delete_workflow(ident)
self._reload_sidebar()
def _sync_wf_from_canvas(self) -> None:
"""Chép node, cạnh và tên từ khung vẽ về đối tượng luồng trước khi lưu."""
self._wf.nodes = self.canvas.nodes()
self._wf.edges = self.canvas.edges()
self._wf.name = self.name_edit.text().strip() or tr("co4e.untitled")
def _save(self, as_template: bool) -> None:
"""Lưu luồng đang mở; ``as_template`` đánh dấu nó là mẫu dùng lại được."""
self._sync_wf_from_canvas()
self._wf.is_template = as_template
co4e.save_workflow(self._wf)
self._reload_sidebar()
self.status_message.emit(tr("co4e.saved_msg", name=self._wf.name))
def _autosave(self) -> None:
"""Tự lưu sau mỗi thay đổi trên khung vẽ — nhưng chỉ với luồng ĐÃ tồn tại
trên đĩa, để luồng nháp chưa đặt tên không tự nhảy vào danh sách.
"""
if co4e.get_workflow(self._wf.id) is not None:
self._sync_wf_from_canvas()
co4e.save_workflow(self._wf)
def _on_name_changed(self, text: str) -> None:
"""Gõ tên mới thì cập nhật luôn nhãn tab của luồng đang mở."""
self._wf.name = text.strip() or tr("co4e.untitled")
self._sync_active_flow_tab_text()
def _add_blank_step(self) -> None:
"""Thêm một bước trống vào giữa khung nhìn hiện tại."""
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập."""
for n in self.canvas.nodes():
if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names())
@@ -140,6 +171,7 @@ class Co4EWorkflowCrudMixin:
self._toggle_config()
return
def _on_config_changed(self) -> None:
"""Sửa thuộc tính bước thì vẽ lại mọi node (nhãn/model có thể đổi) rồi tự lưu."""
for n in self.canvas.nodes():
self.canvas.refresh_node(n.id)
self._autosave()
@@ -64,6 +64,7 @@ class _StepConfigActionsMixin:
return names
def _add_subagent(self) -> None:
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
if self._step is None:
return
from PySide6.QtWidgets import QInputDialog
@@ -102,6 +103,7 @@ class _StepConfigActionsMixin:
self.changed.emit()
def _del_subagent(self) -> None:
"""Xoá sub-agent đang chọn khỏi bước."""
if self._step is None:
return
row = self.sub_list.currentRow()
@@ -111,6 +113,7 @@ class _StepConfigActionsMixin:
self.changed.emit()
def _add_attachment(self) -> None:
"""Đính kèm tệp vào bước đang chọn."""
if self._step is None:
return
from pathlib import Path as _P
@@ -127,6 +130,7 @@ class _StepConfigActionsMixin:
self.changed.emit()
def _del_attachment(self) -> None:
"""Gỡ tệp đính kèm đang chọn khỏi bước."""
if self._step is None:
return
row = self.attach_list.currentRow()
@@ -156,11 +160,13 @@ class _StepConfigActionsMixin:
ctx = self.ctx
def job(worker: AgentWorker):
"""Chạy nền: nhờ model soạn thử prompt cho agent theo tên, vai trò và gợi ý."""
from ...core.ai_task_planner import generate_agent_prompt
return {"text": generate_agent_prompt(ctx.build_active_provider(), name, role, hint,
cancel=worker.is_cancelled)}
def done(result: dict):
"""Đổ prompt vừa soạn vào ô chỉ dẫn (``_on_edit`` sẽ tự lưu lại)."""
self.gen_btn.setEnabled(True)
if result.get("text"):
self.instructions_edit.setPlainText(result["text"]) # _on_edit persists it
@@ -172,6 +178,7 @@ class _StepConfigActionsMixin:
w.start()
def _load_models(self) -> None:
"""Nạp danh sách model đang chạy được vào ô chọn của bước, ở luồng nền."""
if self.ctx is None:
return
from ...core import preview_ai
@@ -181,9 +188,11 @@ class _StepConfigActionsMixin:
ctx = self.ctx
def job(_w):
"""Chạy nền: hỏi mọi provider danh sách model đang sống."""
return preview_ai.fetch_live_models(ctx)
def done(result: dict):
"""Gộp model của mọi provider vào ô chọn, giữ nguyên thứ đang chọn."""
self.load_models_btn.setEnabled(True)
models = []
for lst in (result or {}).values():
+15
View File
@@ -46,12 +46,20 @@ from .step_config_section import _add_section
class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
"""Bảng thuộc tính bên phải khung vẽ Co4E: sửa nhãn, vai trò, model, chỉ dẫn,
skill, sub-agent và tệp đính kèm của một bước.
"""
changed = Signal() # any field edited → repaint node + autosave
run_node = Signal(str) # "Run this step" (node id)
run_from = Signal(str) # "Run from here"
delete_node = Signal(str) # "Delete step"
def __init__(self, ctx=None):
"""Panel cấu hình cho bước đang chọn.
``_loading`` chặn tín hiệu trong lúc đổ dữ liệu vào form: không có nó thì
chính việc đổ dữ liệu sẽ bị hiểu là người dùng vừa sửa.
"""
super().__init__()
self.ctx = ctx
self._step: Optional[Step] = None
@@ -229,6 +237,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# ---- load a step ------------------------------------------------------
def load_step(self, node_id: str, step: Step, skill_names: List[str]) -> None:
"""Nạp một bước lên bảng.
Bật cờ ``_loading`` trong lúc điền để việc đặt giá trị không bị hiểu nhầm là
người dùng vừa sửa và kích hoạt tự lưu.
"""
self._loading = True
self._node_id = node_id
self._step = step
@@ -269,12 +282,14 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self._loading = False
def clear_step(self) -> None:
"""Xoá bảng khi không có bước nào được chọn."""
self._step = None
self._node_id = ""
self.setEnabled(False)
# ---- edits write back to the Step -------------------------------------
def _on_edit(self, *_a) -> None:
"""Người dùng sửa một trường: ghi vào bước rồi báo ra ngoài để vẽ lại node và tự lưu."""
if self._loading or self._step is None:
return
s = self._step
+4
View File
@@ -36,12 +36,16 @@ class _PaletteList(QListWidget):
Workflow rows also keep a (kind, id) tuple in Qt.UserRole for load/delete."""
def __init__(self, parent=None, payload_role=Qt.UserRole):
"""Danh sách nguyên liệu chỉ để kéo ra khung vẽ — không nhận thả vào."""
super().__init__(parent)
self._payload_role = payload_role
self.setDragEnabled(True)
self.setDragDropMode(QListWidget.DragOnly)
def startDrag(self, _actions): # noqa: N802
"""Bắt đầu kéo: gói dữ liệu của mục đang chọn vào MIME riêng của Co4E, để khung
vẽ nhận ra và dựng đúng loại node.
"""
item = self.currentItem()
if item is None:
return
+5
View File
@@ -44,6 +44,11 @@ class SkillsListPanel(QWidget):
"""
def __init__(self, parent: QWidget | None = None) -> None:
"""Danh sách skill ở cột trái Co4E Studio, kèm nút quản lý.
Không nối ``clicked`` ở đây: bên gọi (``Co4ETab``) tự quyết định nút ấy mở
cái gì.
"""
super().__init__(parent)
self.manage_btn = QPushButton(tr("co4e.manage_skills"))
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills"))
+12
View File
@@ -40,6 +40,7 @@ class _SectionHeader(QLabel):
clicked = Signal()
def mousePressEvent(self, event) -> None: # noqa: N802
"""Bấm trái vào tiêu đề mục thì gập/mở mục đó."""
if event.button() == Qt.LeftButton:
self.clicked.emit()
super().mousePressEvent(event)
@@ -52,6 +53,13 @@ class _SectionHeader(QLabel):
# _add_section is briefly wrong (too tall) until something else
# triggers a relayout. Recomputing here, every time the label
# actually becomes visible, means the first paint is never stale.
"""Tính lại chiều cao cố định mỗi lần nhãn thật sự hiện ra.
``fontMetrics()`` lúc dựng chỉ phản ánh cỡ chữ trong QSS nếu style đã được
áp xong — ở lần vẽ ĐẦU TIÊN của màn Co4E thì đôi khi chưa, nên chiều cao
tính trong ``_add_section`` bị sai (quá cao) cho tới khi có gì đó buộc bố
cục tính lại. Tính lại ở đây thì lần vẽ đầu không bao giờ còn lệch.
"""
self.setFixedHeight(self.fontMetrics().height())
super().showEvent(event)
@@ -105,6 +113,9 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
is_open = False
def _on_finished() -> None:
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
trường không bị cắt cụt.
"""
if is_open:
# Uncapped once open, so switching to a step whose fields make
# this section taller/shorter (e.g. a parallel node's sub-agent
@@ -116,6 +127,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
anim.finished.connect(_on_finished)
def _toggle() -> None:
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
nonlocal is_open
is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}")
+20 -3
View File
@@ -25,9 +25,17 @@ from cowork_local.ui.icons import icon
class DashboardTab(QWidget):
"""Màn Bảng điều khiển: ghép thẻ số liệu, biểu đồ và bảng thói quen dùng model.
Một ``DashboardQueryService`` duy nhất được dựng ở đây và dùng chung cho cả
ba thẻ con, nên đơn giá và tiền tệ luôn nhất quán trên toàn màn hình.
"""
status_message = Signal(str)
def __init__(self, ctx: AppContext):
"""Dựng vỏ Bảng điều khiển và ghép ba thẻ con, dùng chung một
``DashboardQueryService``.
"""
super().__init__()
self.ctx = ctx
self._query = DashboardQueryService(ctx)
@@ -52,12 +60,19 @@ class DashboardTab(QWidget):
head.addWidget(self.refresh_btn)
root.addLayout(head)
self.token_cards = TokenUsageCardWidget(ctx, self._query)
root.addWidget(self.token_cards)
self.chart = UsageChartWidget(ctx, self._query)
self.chart.period_changed.connect(self.refresh)
self.chart.currency_changed.connect(self.refresh)
# Hàng bộ chọn kỳ đặt NGAY DƯỚI TIÊU ĐỀ, trên các thẻ số liệu — đúng
# chỗ của nó trước refactor. Nó lọc cả ba thẻ con chứ không riêng biểu đồ,
# nên để nó nằm dưới các thẻ số liệu là bắt người dùng đọc con số trước
# khi thấy con số đó tính cho kỳ nào (F-07).
root.addWidget(self.chart.detach_controls_bar())
self.token_cards = TokenUsageCardWidget(ctx, self._query)
root.addWidget(self.token_cards)
root.addWidget(self.chart)
self.habits = HabitsWidget(ctx, self._query)
@@ -74,6 +89,7 @@ class DashboardTab(QWidget):
self.refresh()
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn, rồi vẽ lại số liệu."""
self._title.setText(tr("dashboard.title"))
self.refresh_btn.setToolTip(tr("dashboard.refresh_tooltip"))
self.token_cards.retranslate()
@@ -82,6 +98,7 @@ class DashboardTab(QWidget):
self.refresh()
def refresh(self, *_a) -> None:
"""Đọc lại số liệu cho kỳ đang chọn và cập nhật cả ba thẻ con."""
start, end = self.chart.period_range()
self.token_cards.refresh(start, end)
self.chart.refresh()
+17
View File
@@ -20,9 +20,19 @@ from cowork_local.ui.widgets import fmt_tokens
class HabitsWidget(QWidget):
"""Thẻ "Thói quen dùng model" của Dashboard, kèm phần AI phân tích.
Chỉ gửi SỐ LIỆU đã tổng hợp lên provider, không bao giờ gửi nội dung
prompt thật — xem :meth:`_ai_analyze`.
"""
status_message = Signal(str)
def __init__(self, ctx, query: DashboardQueryService, parent=None):
"""Bảng thói quen dùng model, kèm nút nhờ model tự nhận xét.
Kỳ đang xem được nhớ lại ở mỗi lần ``refresh()`` để nút nhận xét dùng đúng
khoảng thời gian đang hiện trên màn hình.
"""
super().__init__(parent)
self.ctx = ctx
self._query = query
@@ -64,6 +74,7 @@ class HabitsWidget(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
self.ai_analyze_btn.setToolTip(tr("dashboard.ai_analyze_tooltip"))
self.apply_strategy_btn.setText(tr("dashboard.strategy_btn"))
@@ -71,6 +82,7 @@ class HabitsWidget(QWidget):
self._habits_title.setText(tr("dashboard.habits_title"))
def refresh(self, start: date, end: date) -> None:
"""Vẽ lại bảng thói quen dùng model cho một kỳ, và nhớ kỳ đó cho nút AI phân tích."""
self._period_range = (start, end)
summary = self._query.summary(start, end)
s, events = summary["stats"], summary["events"]
@@ -134,6 +146,9 @@ class HabitsWidget(QWidget):
ctx = self.ctx
def job(worker: AgentWorker):
"""Chạy nền: gửi SỐ LIỆU đã tổng hợp (không bao giờ gửi nội dung prompt thật)
cho model và xin nhận xét cùng gợi ý tiết kiệm token.
"""
from cowork_local.core import usage_tracker as ut
from cowork_local.i18n import get_language
@@ -144,6 +159,7 @@ class HabitsWidget(QWidget):
return {"text": (reply.get("content") or "").strip()}
def done(result: dict) -> None:
"""Hiện nhận xét của AI và mở nút "Áp dụng chiến lược tiết kiệm"."""
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
@@ -156,6 +172,7 @@ class HabitsWidget(QWidget):
self.apply_strategy_btn.setVisible(True)
def failed(err: str) -> None:
"""Phân tích lỗi: hiện lý do và mở khoá lại nút."""
self._ai_worker = None
self.ai_analyze_btn.setEnabled(True)
self.ai_analyze_btn.setText(tr("dashboard.ai_analyze_btn"))
@@ -22,11 +22,16 @@ class TokenUsageCardWidget(QWidget):
tiles — with six equal cards nothing said which number mattered."""
def __init__(self, ctx, query: DashboardQueryService, parent=None):
"""Lưới các thẻ số liệu token và chi phí."""
super().__init__(parent)
self.ctx = ctx
self._query = query
cards_grid = QGridLayout(self)
# Lề 0 như hai thẻ con còn lại (``habits_widget``, ``usage_chart_widget``):
# bản trước refactor đổ lưới thẻ THẲNG vào layout của màn hình, nên bọc nó
# vào một widget kèm lề mặc định 9px đẩy cả hàng thẻ xuống 9px so với bản vẽ.
cards_grid.setContentsMargins(0, 0, 0, 0)
cards_grid.setSpacing(8)
self.card_total = StatCard()
self.card_in = StatCard()
@@ -50,10 +55,12 @@ class TokenUsageCardWidget(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
def refresh(self, start: date, end: date) -> None:
"""Cập nhật mọi thẻ theo số liệu của một kỳ."""
summary = self._query.summary(start, end)
s, pricing, costs = summary["stats"], summary["pricing"], summary["costs"]
from cowork_local.core import usage_tracker as ut
@@ -81,6 +88,7 @@ class TokenUsageCardWidget(QWidget):
self._refresh_budget()
def _refresh_budget(self) -> None:
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt."""
from cowork_local.core import model_pricing as mp
from cowork_local.core import usage_tracker as ut
+48 -6
View File
@@ -24,18 +24,30 @@ from cowork_local.ui.widgets import fmt_tokens
class UsageChartWidget(QWidget):
"""Thẻ biểu đồ chi phí/token của Dashboard: hàng điều khiển + đồ thị spline.
Đây cũng là nơi giữ *kỳ đang xem* (độ mịn tuần/tháng/năm + độ lệch kỳ)
cho cả màn hình: các thẻ khác không tự chọn kỳ mà nghe tín hiệu
``period_changed`` rồi hỏi lại :meth:`period_range`.
"""
period_changed = Signal() # granularity or offset changed — re-run every widget
currency_changed = Signal() # display currency changed — same, cost text depends on it
def __init__(self, ctx, query: DashboardQueryService, parent=None):
"""Dựng hàng điều khiển kỳ và biểu đồ spline."""
super().__init__(parent)
self.ctx = ctx
self._query = query
self._chart_offset = 0 # 0 = current period; <0 = a past period
root = QVBoxLayout(self)
root.setContentsMargins(0, 0, 0, 0)
controls = QHBoxLayout()
self._root = QVBoxLayout(self)
self._root.setContentsMargins(0, 0, 0, 0)
# Hàng điều khiển nằm trong một widget riêng chứ không đổ thẳng vào layout,
# để vỏ ngoài gỡ được nó ra và đặt lên đầu màn hình — xem
# :meth:`detach_controls_bar`.
self.controls_bar = QWidget(self)
controls = QHBoxLayout(self.controls_bar)
controls.setContentsMargins(0, 0, 0, 0)
controls.setSpacing(6)
self.chart_prev_btn = QPushButton()
self.chart_prev_btn.setIcon(icon("chevron-left"))
@@ -79,19 +91,36 @@ class UsageChartWidget(QWidget):
self.currency_combo.setCurrentIndex(max(0, idx))
self.currency_combo.currentIndexChanged.connect(self._on_currency_changed)
controls.addWidget(self.currency_combo)
root.addLayout(controls)
self._root.addWidget(self.controls_bar)
chart_head = QHBoxLayout()
self._chart_title = QLabel()
self._chart_title.setStyleSheet("font-weight:600;")
chart_head.addWidget(self._chart_title, 1)
root.addLayout(chart_head)
self._root.addLayout(chart_head)
self.chart = SplineChart()
root.addWidget(self.chart)
self._root.addWidget(self.chart)
self.retranslate()
def detach_controls_bar(self) -> QWidget:
"""Gỡ hàng điều khiển ra khỏi thẻ biểu đồ và trả về cho chỗ khác đặt.
Hàng này chọn kỳ cho **cả màn hình** chứ không riêng biểu đồ: thẻ số
liệu và bảng thói quen đều lọc theo nó. Bản trước refactor vì thế đặt nó
ngay dưới tiêu đề, trên các thẻ số liệu; đợt tách widget (R08-T13) kéo
nó xuống theo biểu đồ, thành ra người dùng đọc các thẻ số liệu trước khi
nhìn thấy thứ quyết định những con số ấy (F-07).
Trạng thái kỳ và toàn bộ dây tín hiệu vẫn thuộc widget này — chỉ có chỗ
đặt là đổi. Gọi hay không gọi đều chạy đúng: không gọi thì hàng điều khiển
ở nguyên trong thẻ biểu đồ (widget dùng độc lập trong test vẫn đủ bộ).
"""
self._root.removeWidget(self.controls_bar)
return self.controls_bar
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self._chart_title.setText(tr("dashboard.chart_title"))
@@ -100,29 +129,42 @@ class UsageChartWidget(QWidget):
# ---- public: the period selector every other widget follows ------------- #
def granularity(self) -> str:
"""Độ mịn đang chọn: 'week' | 'month' | 'year'. Mặc định 'week'."""
return self.gran_combo.currentData() or "week"
@property
def chart_offset(self) -> int:
"""Độ lệch kỳ: 0 là kỳ hiện tại, số âm là lùi về quá khứ."""
return self._chart_offset
def period_range(self) -> Tuple:
"""Khoảng thời gian (đầu, cuối) của kỳ đang xem — thứ mọi thẻ khác dùng để lọc dữ liệu."""
return self._query.period_range(self.granularity(), self._chart_offset)
# ---- navigation ------------------------------------------------------------ #
def _on_gran_changed(self, *_a) -> None:
"""Đổi độ mịn thì nhảy về kỳ hiện tại: giữ nguyên độ lệch cũ sẽ nhảy sang
một mốc thời gian khác hẳn (lùi 3 tuần ≠ lùi 3 tháng).
"""
self._chart_offset = 0 # period size changed → back to current
self.period_changed.emit()
def _chart_prev(self) -> None:
"""Lùi một kỳ."""
self._chart_offset -= 1
self.period_changed.emit()
def _chart_next(self) -> None:
"""Tiến một kỳ, chặn ở kỳ hiện tại — không cho xem tương lai."""
self._chart_offset = min(0, self._chart_offset + 1) # never past the present
self.period_changed.emit()
def _on_currency_changed(self, _idx: int) -> None:
"""Đổi tiền tệ hiển thị và ghi vào cấu hình ngay.
Dashboard và Giám sát dùng chung khoá ``usage.currency`` nên đổi ở đây
là mọi chỗ hiện tiền đều đổi theo.
"""
cur = self.currency_combo.currentData()
if not cur:
return
@@ -36,6 +36,11 @@ class AiEditModelResolver:
"""
def __init__(self, ctx, model_combo, on_status, confirm_switch) -> None:
"""Bộ chọn model của panel AI-Edit.
``on_status`` và ``confirm_switch`` được tiêm vào để lớp này không tự mở hộp
thoại — nhờ vậy test chạy được nó mà không cần giao diện.
"""
self.ctx = ctx
self._combo = model_combo
self._on_status = on_status
@@ -50,10 +55,12 @@ class AiEditModelResolver:
@property
def models(self) -> List[str]:
"""Danh sách model đã nạp cho provider hiện tại."""
return self._models
@property
def models_provider(self) -> str:
"""Provider mà danh sách model đang thuộc về; '' nếu chưa nạp lần nào."""
return self._models_provider
@property
@@ -64,6 +71,7 @@ class AiEditModelResolver:
@property
def routed_model(self) -> Optional[str]:
"""Model do định tuyến tự động chọn cho lượt này; ``None`` nếu dùng model người dùng chọn."""
return self._routed_model
def should_refresh(self) -> bool:
@@ -80,6 +88,7 @@ class AiEditModelResolver:
setting_model = self.ctx.config.provider_conf(name).get("model", "")
def job(worker):
"""Chạy nền: hỏi provider danh sách model, lỗi thì trả về list rỗng."""
prov = self.ctx.build_provider_for(name)
try:
models = list(getattr(prov, "list_models", lambda: [])() or [])
@@ -88,6 +97,11 @@ class AiEditModelResolver:
return {"models": models}
def done(res):
"""Đổ danh sách vào ô chọn, giữ nguyên model đang chọn.
Luôn đưa model cấu hình trong Settings lên đầu, kể cả khi provider không
liệt kê được — để ô chọn không bao giờ chỉ có mỗi "(tự động)".
"""
fetched = list(res.get("models", []))
# Always offer the Settings-configured model as an explicit
# choice, even when the provider can't list models.
@@ -111,6 +125,11 @@ class AiEditModelResolver:
self._scan_all_image_models()
def _scan_all_image_models(self, then_suggest: bool = False) -> None:
"""Dò khắp mọi provider đã cấu hình xem cái nào có model sinh ảnh.
Chạy sẵn ở nền để lúc cần gợi ý là có ngay. Đang dò dở mà bị gọi lại thì
chỉ ghi nhận yêu cầu gợi ý, không dò chồng lên nhau.
"""
if self._img_scan_worker is not None:
if then_suggest:
self._pending_img_suggest = True
@@ -120,6 +139,7 @@ class AiEditModelResolver:
if (c.get("base_url") or c.get("api_key"))]
def job(worker):
"""Chạy nền: duyệt từng provider, lọc ra model sinh ảnh được."""
from cowork_local.core import image_gen
found = []
for key in candidates:
@@ -134,6 +154,7 @@ class AiEditModelResolver:
return {"found": found}
def done(res):
"""Ghi lại kết quả dò; có yêu cầu gợi ý đang chờ thì trả lời luôn."""
self._img_scan_worker = None
self._all_image_models = list(res.get("found", []))
if self._pending_img_suggest:
@@ -149,6 +170,9 @@ class AiEditModelResolver:
w.start()
def _on_image_scan_failed(self, _err) -> None:
"""Dò model ảnh thất bại: chỉ xoá cờ đang chạy, không báo lỗi ra màn hình —
đây là việc chạy ngầm mà người dùng không yêu cầu.
"""
self._img_scan_worker = None
def provider(self) -> Any:
@@ -208,6 +232,10 @@ class AiEditModelResolver:
self._scan_all_image_models(then_suggest=True)
def _suggest_cross_provider_image(self) -> None:
"""Gợi ý một model sinh ảnh, kể cả khi nó thuộc provider KHÁC provider đang chọn.
Đây là chỗ duy nhất trong màn Thư mục biết tới nhiều provider cùng lúc.
"""
from cowork_local.config import PROVIDER_LABELS
if not self._all_image_models:
picked = self._combo.currentData()
+28
View File
@@ -36,6 +36,11 @@ class AiEditPipeline:
"""
def __init__(self, owner) -> None:
"""Vòng chạy một lượt sửa tệp bằng AI.
``pending`` giữ nội dung model đề xuất cho tới khi người dùng đồng ý: không
bao giờ ghi đè tệp trước khi có xác nhận.
"""
self._owner = owner
self.worker: Optional[AgentWorker] = None
self.pending: Optional[dict] = None # proposed content awaiting confirmation
@@ -72,6 +77,7 @@ class AiEditPipeline:
# ---- usage accounting (like Cowork's per-message footer) --------------- #
def _add_usage(self, usage) -> None:
"""Cộng dồn token của một lượt gọi vào tổng của cả phiên sửa file."""
if not isinstance(usage, dict):
return
tot = self._prompt_usage
@@ -81,6 +87,7 @@ class AiEditPipeline:
tot["cost"] += float(usage.get("cost_usd", 0.0) or 0.0)
def _show_usage(self, bubble) -> None:
"""Gắn dòng token/chi phí dưới bong bóng trả lời; chưa dùng token nào thì bỏ qua."""
tot = self._prompt_usage
if bubble is None or not (tot["in"] or tot["out"]):
return
@@ -96,12 +103,14 @@ class AiEditPipeline:
# ---- phase 1: plan ------------------------------------------------------- #
def _run_plan(self) -> None:
"""Bước 1 — nhờ model lập kế hoạch sửa trước khi động vào nội dung."""
o = self._owner
c = self._ctx
plan_bubble = o.ai_chat.add_plan(tr("folder.ai_planning"))
o.ai_chat.scroll_to_bottom()
def job(worker):
"""Chạy nền: gọi model sinh kế hoạch và đo token đã dùng."""
from cowork_local.core import usage_tracker as ut
from cowork_local.core.co4e_runner import _usage_delta
provider = c["provider"]
@@ -131,6 +140,7 @@ class AiEditPipeline:
worker.start()
def _plan_done(self, result, plan_bubble) -> None:
"""Có kế hoạch: hiện ra rồi chuyển sang bước sửa thật."""
self._add_usage((result or {}).get("usage"))
plan = ((result or {}).get("plan") or "").strip()
self._ctx["plan"] = plan
@@ -140,6 +150,7 @@ class AiEditPipeline:
# ---- phase 2: execute (edit the file) ------------------------------------ #
def _run_edit(self) -> None:
"""Bước 2 — sinh nội dung mới, phát dần vào bong bóng trả lời."""
o = self._owner
c = self._ctx
bubble = o.ai_chat.add_assistant(tr("folder.ai_edit"))
@@ -178,6 +189,7 @@ class AiEditPipeline:
pass
def job(worker):
"""Chạy nền: gọi model sinh nội dung sửa, kèm ngữ cảnh tệp đang mở."""
provider = c["provider"]
open_note = (f"the currently-open file '{c['filename']}'" if c["filename"]
else "no file is open")
@@ -199,6 +211,7 @@ class AiEditPipeline:
messages.append({"role": "user", "content": cur + f"Request: {c['instruction']}"})
def on_text(piece: str) -> None:
"""Đẩy từng mẩu nội dung ra ngoài để giao diện vẽ dần."""
worker.emit_event({"type": "text", "delta": piece})
from cowork_local.core import usage_tracker as ut
@@ -221,11 +234,17 @@ class AiEditPipeline:
worker.start()
def _stream(self, ev, bubble) -> None:
"""Vẽ từng mẩu nội dung đang phát dần và cuộn theo."""
if isinstance(ev, dict) and ev.get("type") == "text":
bubble.append_delta(ev.get("delta", ""))
self._owner.ai_chat.scroll_to_bottom()
def _done(self, result, bubble) -> None:
"""Sinh xong: tách phần mã khỏi phần giải thích và dựng bản xem trước.
KHÔNG bao giờ ghi thẳng ra tệp ở đây — chỉ ``_finalize_apply`` mới chạm đĩa,
và chỉ sau khi người dùng bấm Áp dụng.
"""
o = self._owner
self.worker = None
o.set_busy(False)
@@ -281,6 +300,10 @@ class AiEditPipeline:
self._finalize_apply(p)
def _generate_then_finalize(self, p: dict) -> None:
"""Lượt sửa có yêu cầu sinh ảnh: tạo ảnh trước rồi mới ghi nội dung.
Phải theo thứ tự đó vì nội dung có thể tham chiếu tới đường dẫn ảnh vừa tạo.
"""
o = self._owner
imgs = p.get("image_gens") or []
root = os.path.normpath(o.preview.root)
@@ -289,6 +312,7 @@ class AiEditPipeline:
o.status_message.emit(tr("folder.ai_generating"))
def job(worker):
"""Chạy nền: sinh lần lượt từng ảnh được yêu cầu."""
from cowork_local.core import image_gen
results = []
for prompt, rel in imgs:
@@ -314,6 +338,7 @@ class AiEditPipeline:
worker.start()
def _images_done(self, res: dict, p: dict) -> None:
"""Ảnh đã sinh xong: ghi nội dung ra đĩa."""
o = self._owner
self.worker = None
o.set_busy(False)
@@ -329,6 +354,7 @@ class AiEditPipeline:
o.preview.open_file(created[0], reset_ai=False)
def _finalize_apply(self, p: dict, images_done: bool = False) -> None:
"""Ghi nội dung đã được duyệt xuống đĩa và làm mới khung xem."""
o = self._owner
content = p.get("content")
target = p.get("target")
@@ -352,6 +378,7 @@ class AiEditPipeline:
o.flag_done()
def discard(self) -> None:
"""Bỏ bản đề xuất đang chờ, không chạm vào tệp."""
o = self._owner
self.pending = None
o.show_confirm_row(False)
@@ -361,6 +388,7 @@ class AiEditPipeline:
o.maybe_dequeue() # discarding resolves the gate → run the next queued edit
def _failed(self, err, bubble) -> None:
"""Lượt sửa lỗi: hiện lỗi trong bong bóng và mở khoá lại panel."""
o = self._owner
self.worker = None
bubble.set_markdown(tr("folder.ai_error", err=err))
@@ -49,6 +49,10 @@ class AiFileEditorDialog(QWidget):
badge_changed = Signal(str) # "" | " ⏳" | " ✓" — the shell mirrors this onto its toggle button
def __init__(self, ctx, preview, cowork=None, parent=None):
"""Hộp thoại sửa tệp bằng AI.
``resolver`` dựng sau, vì nó cần chính ô chọn model mà hàm này mới tạo.
"""
super().__init__(parent)
self.ctx = ctx
self.preview = preview
@@ -118,6 +122,7 @@ class AiFileEditorDialog(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._ai_title.setText(tr("folder.ai_edit"))
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_send_btn.setText(tr("folder.ai_send"))
@@ -161,6 +166,7 @@ class AiFileEditorDialog(QWidget):
# ---- send / queue -------------------------------------------------------- #
def _ai_send(self) -> None:
"""Gửi một yêu cầu sửa; chưa chọn thư mục làm việc thì báo lỗi ngay."""
if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file"))
return
@@ -179,6 +185,7 @@ class AiFileEditorDialog(QWidget):
self.pipeline.start(instruction)
def _update_queue_status(self) -> None:
"""Cập nhật dòng trạng thái theo số yêu cầu còn xếp hàng."""
n = len(self._ai_queue)
if n:
self._ai_status.setText("⏳ " + tr("folder.ai_status_running")
@@ -197,6 +204,7 @@ class AiFileEditorDialog(QWidget):
# ---- pipeline callbacks (see ai_edit_pipeline.py) ------------------------- #
def set_busy(self, busy: bool) -> None:
"""Khoá/mở ô nhập và nút gửi trong lúc một lượt sửa đang chạy."""
self.ai_input.setEnabled(not busy)
self.ai_send_btn.setEnabled(not busy)
if busy:
@@ -219,9 +227,11 @@ class AiFileEditorDialog(QWidget):
self.badge_changed.emit(" ✓")
def show_confirm_row(self, visible: bool) -> None:
"""Hiện/ẩn hàng nút Áp dụng · Huỷ cho bản đề xuất đang chờ."""
self._ai_confirm_row.setVisible(visible)
def set_review_status(self, text: str, color) -> None:
"""Đặt dòng trạng thái kèm màu (xanh khi xong, đỏ khi lỗi)."""
self._ai_status.setText(text)
if color:
self._ai_status.setStyleSheet(f"color:{color};")
+30
View File
@@ -17,6 +17,7 @@ _MAX_HIGHLIGHT_CHARS = 300_000 # skip colouring huge files (keeps typing sna
# ── VS-Code-Dark+-ish token palette ────────────────────────────────────────
def _fmt(color: str, *, italic: bool = False, bold: bool = False) -> QTextCharFormat:
"""Dựng một định dạng ký tự cho bộ tô màu cú pháp."""
f = QTextCharFormat()
f.setForeground(QColor(color))
if italic:
@@ -31,6 +32,11 @@ class PygmentsHighlighter(QSyntaxHighlighter):
full text (debounced) so multi-line strings/comments colour correctly."""
def __init__(self, document):
"""Tô màu cú pháp bằng Pygments.
Tô lại sau một nhịp trễ (``QTimer`` chạy một lần) thay vì ngay mỗi phím: gõ
nhanh trong file lớn mà tô đồng bộ thì giao diện khựng.
"""
super().__init__(document)
from pygments.lexers.special import TextLexer
self._lexer = TextLexer(stripnl=False)
@@ -44,6 +50,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
@staticmethod
def _build_rules():
"""Dựng bảng ánh xạ loại token của Pygments sang màu theo theme đang dùng."""
from pygments.token import (
Comment, Error, Keyword, Name, Number, Operator, Punctuation, String,
)
@@ -67,6 +74,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
]
def set_filename(self, filename: str, text: str = "") -> None:
"""Chọn bộ phân tích cú pháp theo đuôi tệp; không nhận ra thì tắt tô màu."""
from pygments.lexers import get_lexer_for_filename, guess_lexer
from pygments.lexers.special import TextLexer
from pygments.util import ClassNotFound
@@ -80,12 +88,18 @@ class PygmentsHighlighter(QSyntaxHighlighter):
self._retokenize()
def _fmt_for(self, tok):
"""Định dạng của một token; leo dần lên token cha cho tới khi khớp một luật."""
for ttype, fmt in self._rules:
if tok in ttype:
return fmt
return None
def _retokenize(self) -> None:
"""Phân tích lại toàn bộ tài liệu và ghi nhớ định dạng cho từng dòng.
Pygments phân tích theo cả tệp chứ không theo từng dòng, nên không thể
tô đúng nếu chỉ nhìn một dòng — ví dụ chuỗi nhiều dòng hay khối chú thích.
"""
from pygments import lex
text = self.document().toPlainText()
self._ranges = []
@@ -99,6 +113,7 @@ class PygmentsHighlighter(QSyntaxHighlighter):
self.rehighlight()
def highlightBlock(self, text: str) -> None: # noqa: N802 - Qt override
"""Áp định dạng đã tính sẵn cho một dòng. Qt gọi hàm này cho từng dòng hiện trên màn."""
if not self._ranges:
return
bstart = self.currentBlock().position()
@@ -113,14 +128,18 @@ class PygmentsHighlighter(QSyntaxHighlighter):
class _LineNumbers(QWidget):
"""Máng số dòng vẽ bên trái ô soạn thảo."""
def __init__(self, editor):
"""Dải số dòng bên trái ô soạn mã."""
super().__init__(editor)
self._editor = editor
def sizeHint(self) -> QSize:
"""Bề rộng máng số dòng, do ô soạn thảo tính theo số chữ số của dòng cuối."""
return QSize(self._editor.line_number_width(), 0)
def paintEvent(self, event): # noqa: N802
"""Nhờ ô soạn thảo vẽ — nó mới biết dòng nào đang hiện ở đâu."""
self._editor.paint_line_numbers(event)
@@ -129,6 +148,11 @@ class CodeEditor(QPlainTextEdit):
the Sublime/VS-Code look for viewing & editing source files."""
def __init__(self):
"""Ô soạn mã: phông đều, không tự xuống dòng, tab rộng 4 ký tự.
Không tự xuống dòng là cố ý — mã bị bẻ dòng thì lệch thụt đầu dòng và khó
đọc hơn là phải cuộn ngang.
"""
super().__init__()
self.setObjectName("codeEditor")
self.setLineWrapMode(QPlainTextEdit.NoWrap)
@@ -144,13 +168,16 @@ class CodeEditor(QPlainTextEdit):
self._update_gutter_width()
def line_number_width(self) -> int:
"""Bề rộng cần cho máng số dòng, tính theo số chữ số của dòng cuối cùng."""
digits = max(2, len(str(max(1, self.blockCount()))))
return 12 + self.fontMetrics().horizontalAdvance("9") * digits
def _update_gutter_width(self) -> None:
"""Chừa lề trái đúng bằng bề rộng máng số dòng."""
self.setViewportMargins(self.line_number_width(), 0, 0, 0)
def _on_update_request(self, rect, dy: int) -> None:
"""Cuộn hoặc vẽ lại vùng nào thì máng số dòng cuộn/vẽ lại đúng vùng đó."""
if dy:
self._gutter.scroll(0, dy)
else:
@@ -159,11 +186,13 @@ class CodeEditor(QPlainTextEdit):
self._update_gutter_width()
def resizeEvent(self, event): # noqa: N802
"""Đổi kích thước thì đặt lại hình chữ nhật của máng số dòng."""
super().resizeEvent(event)
cr = self.contentsRect()
self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height()))
def paint_line_numbers(self, event) -> None:
"""Vẽ số của các dòng đang hiện trên màn, bỏ qua dòng bị gập hoặc nằm ngoài khung."""
p = current_palette()
painter = QPainter(self._gutter)
painter.fillRect(event.rect(), QColor(p.code_gutter_bg))
@@ -183,6 +212,7 @@ class CodeEditor(QPlainTextEdit):
num += 1
def load_file(self, path: str, text: str) -> None:
"""Nạp nội dung tệp vào ô soạn thảo và bật tô màu theo đuôi tệp."""
self.setPlainText(text)
self._highlighter.set_filename(path, text)
@@ -57,6 +57,11 @@ class DocumentPreviewManager(QWidget):
ai_reset_requested = Signal() # a DIFFERENT file was opened by the user
def __init__(self, root: str, parent=None):
"""Khung xem tài liệu.
Mọi lượt đọc tệp đi qua ``FileWorkspaceService`` nên không thoát ra ngoài
thư mục gốc, kể cả khi đường dẫn có ``..``.
"""
super().__init__(parent)
self._root = root
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
@@ -123,6 +128,7 @@ class DocumentPreviewManager(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho các nút và ô trống."""
self.save_btn.setText(tr("folder.save"))
self.ext_btn.setText(tr("folder.open_external"))
if not self._current_file:
@@ -130,23 +136,28 @@ class DocumentPreviewManager(QWidget):
self._retranslate_mode_btn()
def _retranslate_mode_btn(self) -> None:
"""Nhãn nút chuyển chế độ đổi theo trạng thái: đang xem thì ghi "Sửa" và ngược lại."""
self.mode_btn.setText(tr("folder.edit") if not self.mode_btn.isChecked()
else tr("folder.preview"))
# ---- public API used by the shell / AI panel --------------------------- #
@property
def current_file(self) -> Optional[str]:
"""Đường dẫn tệp đang mở; ``None`` nếu chưa mở tệp nào."""
return self._current_file
@property
def edit_kind(self) -> Optional[str]:
"""Loại nội dung đang sửa ('pptx', 'html', 'text'…); ``None`` nếu đang chỉ xem."""
return self._edit_kind
@property
def root(self) -> str:
"""Thư mục gốc mà mọi thao tác ghi phải nằm bên trong."""
return self._root
def set_root(self, root: str) -> None:
"""Đổi thư mục gốc và dựng lại dịch vụ ghi tệp gắn với phạm vi mới."""
self._root = root
self._file_service = FileWorkspaceService(WorkspaceSession.unscoped(Path(root)))
@@ -155,6 +166,13 @@ class DocumentPreviewManager(QWidget):
# (reset_ai=False when the AI itself just CREATED this file — keep
# that chat). Whether/how to reset is the AI panel's own business —
# this class only announces that a genuine file switch happened.
"""Mở một tệp, tự chọn cách hiển thị theo đuôi và kích thước.
Thứ tự ưu tiên: ảnh → HTML → PowerPoint → Excel → tài liệu Office → nhị
phân (quá lớn hoặc không phải văn bản) → mã nguồn. Đổi sang tệp KHÁC thì
báo cho panel AI biết để nó bắt đầu hội thoại mới; ``reset_ai=False`` dùng
khi chính AI vừa tạo ra tệp này — giữ nguyên đoạn chat đang dở.
"""
if reset_ai and path != self._current_file:
self.ai_reset_requested.emit()
self._current_file = path
@@ -206,6 +224,7 @@ class DocumentPreviewManager(QWidget):
return False
def save(self) -> None:
"""Lưu nội dung đang sửa xuống tệp; pptx ghi ngược vào bản trình chiếu."""
if not self._current_file:
return
try:
@@ -261,6 +280,7 @@ class DocumentPreviewManager(QWidget):
return dest
def open_external(self) -> None:
"""Mở tệp đang xem bằng ứng dụng mặc định của hệ điều hành."""
if self._current_file:
from cowork_local.ui.osutil import open_location
open_location(self._current_file)
@@ -278,6 +298,7 @@ class DocumentPreviewManager(QWidget):
# ---- simple renderers (HTML/PPTX/Excel/PDF/office live in
# office_document_renderer.py) --------------------------------------------- #
def _show_code(self, path: str) -> None:
"""Hiện tệp trong ô soạn thảo có tô màu cú pháp, cho phép sửa và lưu."""
text = read_text(path)
self.editor.setReadOnly(False)
self.editor.load_file(path, text)
@@ -285,6 +306,7 @@ class DocumentPreviewManager(QWidget):
self.stack.setCurrentWidget(self.editor)
def _show_image(self, path: str) -> None:
"""Hiện ảnh; ảnh hỏng hoặc không đọc được thì rơi về khung nhị phân."""
from PySide6.QtGui import QPixmap
pix = QPixmap(path)
if pix.isNull():
@@ -296,6 +318,7 @@ class DocumentPreviewManager(QWidget):
self.stack.setCurrentWidget(self._img_scroll)
def _show_binary(self, path: str) -> None:
"""Hiện thông báo "tệp nhị phân" kèm nút mở bằng ứng dụng ngoài."""
self._placeholder.setText(tr("folder.binary_file"))
self.ext_btn.setVisible(True)
self.stack.setCurrentWidget(self._placeholder)
+9
View File
@@ -30,6 +30,7 @@ class FolderTab(QWidget):
status_message = Signal(str)
def __init__(self, ctx: AppContext, cowork=None):
"""Ghép ba phần của tab Thư mục: cây tệp, khung xem và terminal."""
super().__init__()
self.ctx = ctx
self._root = str(ctx.config.cowork_output_dir())
@@ -90,6 +91,11 @@ class FolderTab(QWidget):
# ---- public API ---------------------------------------------------------
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc của cả ba widget con.
``WorkspaceFileTree`` lặng lẽ từ chối đường dẫn không hợp lệ, nên chỉ lan
sang khung xem và terminal khi cây thật sự đã nhận.
"""
self.tree.set_root(path)
# WorkspaceFileTree silently no-ops on an invalid path (same guard
# the original single-class _root setter had) — mirror that here by
@@ -100,6 +106,7 @@ class FolderTab(QWidget):
self.terminal.set_cwd(path)
def _toggle_ai_panel(self) -> None:
"""Gập/mở panel AI-Edit; mở ra thì báo cho panel biết để nó nạp model lần đầu."""
show = self.ai_btn.isChecked()
self.ai_panel.setVisible(show)
if show:
@@ -107,9 +114,11 @@ class FolderTab(QWidget):
self.ai_panel.on_opened()
def _on_ai_badge_changed(self, suffix: str) -> None:
"""Cập nhật huy hiệu trên nút AI-Edit (số việc đang chờ)."""
self.ai_btn.setText(tr("folder.ai_edit") + suffix)
def _retranslate(self) -> None:
"""Chuyển lệnh dịch lại xuống cho cả ba widget con."""
self.tree.retranslate()
self.preview.retranslate()
self.ai_panel.retranslate()
@@ -44,6 +44,11 @@ class OfficeDocumentRenderer:
"""
def __init__(self, owner) -> None:
"""Bộ hiện tài liệu Office.
Có cache PDF vì mỗi lần chuyển đổi phải gọi LibreOffice — xem lại cùng một
tệp mà chuyển lại từ đầu thì chờ vài giây mỗi lượt.
"""
self._owner = owner
self._engine = None
self._pdf_view = None
@@ -54,6 +59,7 @@ class OfficeDocumentRenderer:
self._xlsx_view = None
def show_html(self, path: str, mode_preview: bool) -> None:
"""Hiện tệp HTML: xem đã dựng hình hoặc sửa mã nguồn, tuỳ ``mode_preview``."""
o = self._owner
o._edit_kind = "html"
o.mode_btn.setVisible(True)
@@ -111,6 +117,7 @@ class OfficeDocumentRenderer:
return self._engine
def toggle_edit_mode(self) -> None:
"""Lật giữa Xem và Sửa cho tệp đang mở (HTML và PowerPoint)."""
o = self._owner
if not o.current_file:
return
@@ -194,9 +201,14 @@ class OfficeDocumentRenderer:
src, out_dir = path, self._pdf_tmp
def job(worker):
"""Chạy nền: chuyển tài liệu Office sang PDF bằng LibreOffice."""
return {"src": src, "mtime": mtime, "pdf": convert_to_pdf(src, out_dir)}
def done(result):
"""Hiện PDF vừa chuyển và ghi vào bộ nhớ đệm theo (đường dẫn, thời điểm sửa).
Bỏ kết quả nếu người dùng đã chuyển sang tệp khác trong lúc chờ chuyển đổi.
"""
if result.get("src") != o.current_file:
return # user moved on to another file
pdf = result.get("pdf")
@@ -213,6 +225,9 @@ class OfficeDocumentRenderer:
worker.start()
def _ensure_pdf_view(self):
"""Dựng khung xem PDF một lần duy nhất; không có ``QtPdf`` thì trả ``None``
để chỗ gọi rơi về cách hiện văn bản thuần.
"""
if not HAS_PDF:
return None
if self._pdf_view is None:
@@ -230,6 +245,7 @@ class OfficeDocumentRenderer:
return self._pdf_view
def _render_pdf(self, pdf_path: str) -> None:
"""Nạp và hiện một tệp PDF; thiếu ``QtPdf`` thì rơi về trích văn bản."""
view = self._ensure_pdf_view()
if view is None:
self.show_document_text(pdf_path)
@@ -238,6 +254,10 @@ class OfficeDocumentRenderer:
self._owner.stack.setCurrentWidget(view)
def show_document_text(self, path: str) -> None:
"""Cách dự phòng cuối: trích văn bản từ tài liệu và hiện dưới dạng chữ thuần.
Dùng khi không có LibreOffice để chuyển PDF, hoặc không có khung xem PDF.
"""
from cowork_local.core.doc_extract import extract_text
o = self._owner
try:
@@ -29,6 +29,7 @@ class WorkspaceFileTree(QWidget):
root_changed = Signal(str) # absolute path of the new root
def __init__(self, initial_root: str, parent=None):
"""Cây tệp của thư mục làm việc, có ô đường dẫn ở trên làm tiêu đề màn hình."""
super().__init__(parent)
self._root = initial_root
@@ -65,14 +66,20 @@ class WorkspaceFileTree(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._open_btn.setToolTip(tr("folder.path_placeholder"))
self._open_btn.setText(tr("folder.open_folder"))
@property
def root(self) -> str:
"""Thư mục gốc đang hiện."""
return self._root
def set_root(self, path: str) -> None:
"""Đổi thư mục gốc; đường dẫn rỗng hoặc không tồn tại thì BỎ QUA lặng lẽ.
Chỗ gọi dựa vào việc ``root`` không đổi để biết cây đã từ chối đường dẫn.
"""
p = str(path or "").strip()
if not p or not os.path.isdir(p):
return
@@ -84,11 +91,13 @@ class WorkspaceFileTree(QWidget):
self.root_changed.emit(p)
def _pick_root(self) -> None:
"""Mở hộp thoại chọn thư mục gốc."""
chosen = QFileDialog.getExistingDirectory(self, tr("folder.open_folder"), self._root)
if chosen:
self.set_root(chosen)
def _on_tree_clicked(self, index) -> None:
"""Bấm vào một mục: là tệp thì phát tín hiệu mở, là thư mục thì để cây tự bung."""
path = self.model.filePath(index)
if path and os.path.isfile(path):
self.file_selected.emit(path)
+81
View File
@@ -0,0 +1,81 @@
"""Xuất khung đồ thị đang xem ra file PNG — tách khỏi ``graph_renderer.py``.
GraphRAG có hai khung xem: cảnh Qt 2D và trang D3 chạy trong WebEngine. Hai
khung ấy chụp ảnh theo hai cách hoàn toàn khác nhau (``QWidget.grab()`` so
với một lượt gọi JavaScript trả về data URL), nên chỗ này gom cả hai lại sau
một hàm duy nhất và tự chọn đường đi.
"""
from __future__ import annotations
import base64
from typing import Callable, Optional
from PySide6.QtWidgets import QFileDialog, QWidget
from cowork_local.i18n import tr
#: Bên gọi truyền vào để hiện kết quả trên thanh trạng thái.
StatusFn = Callable[[str], None]
def ask_export_path(parent: QWidget) -> str:
"""Hỏi người dùng nơi lưu ảnh; trả về '' nếu họ bấm Huỷ."""
path, _ = QFileDialog.getSaveFileName(
parent, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
return path or ""
def export_widget_grab(widget: QWidget, path: str, status: StatusFn) -> None:
"""Chụp thẳng widget đang hiện ra PNG.
Đây cũng là đường lui khi xuất từ D3 thất bại: chụp widget luôn cho ra
một tấm ảnh, dù không sắc nét bằng bản vẽ vector của D3.
"""
if widget.grab().save(path, "PNG"):
status(tr("structure.export_done", path=path))
else:
status(tr("structure.export_failed", err="grab() returned no image"))
def export_d3_png(web, path: str, fallback: QWidget, status: StatusFn) -> None:
"""Xuất PNG từ trang D3 bằng cách nhờ chính trang đó vẽ ra data URL.
``runJavaScript`` chạy bất đồng bộ nên kết quả về trong hàm gọi lại. Mọi
đường hỏng — trang chưa nạp xong, ``window.exportPng`` không tồn tại,
chuỗi trả về không phải data URL — đều quay sang chụp widget, để người
dùng bấm Xuất vẫn luôn nhận được một file thay vì im lặng không có gì.
"""
def on_result(data_url) -> None:
"""Nhận data URL từ trang D3 và ghi ra file; hỏng ở bất cứ đâu thì quay sang
chụp widget.
"""
if not isinstance(data_url, str) or "," not in data_url:
export_widget_grab(fallback, path, status)
return
try:
with open(path, "wb") as f:
f.write(base64.b64decode(data_url.split(",", 1)[1]))
status(tr("structure.export_done", path=path))
except (OSError, ValueError) as exc:
status(tr("structure.export_failed", err=str(exc)))
web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
def export_png(parent: QWidget, showing: QWidget, web: Optional[object],
is_web_visible: bool, status: StatusFn) -> None:
"""Hỏi đường dẫn rồi xuất khung ĐANG hiện — D3 hay cảnh Qt tuỳ tab đang mở.
Chỉ dùng đường D3 khi trang D3 thật sự đang hiển thị; đang xem cảnh Qt mà
lại chụp D3 thì ảnh ra không khớp với thứ người dùng nhìn thấy.
"""
path = ask_export_path(parent)
if not path:
return
if web is not None and is_web_visible:
export_d3_png(web, path, showing, status)
else:
export_widget_grab(showing, path, status)
__all__ = ["export_png", "export_d3_png", "export_widget_grab", "ask_export_path"]
+11
View File
@@ -22,7 +22,15 @@ from cowork_local.i18n import tr
class GraphMessagesView:
"""Tab "Tin nhắn" của GraphRAG: cây hội thoại gom theo ngày, bấm vào xem JSON.
Một helper thuần (không phải widget) do ``GraphRenderer`` sở hữu; nó tự dựng
cây và đưa vào stack dùng chung của chủ sở hữu.
"""
def __init__(self, owner) -> None:
"""Dựng cây tin nhắn và đưa vào stack dùng chung của chủ sở hữu — helper thuần,
không phải widget độc lập.
"""
self._owner = owner
self.widget = QTreeWidget()
self.widget.setHeaderHidden(True)
@@ -76,6 +84,9 @@ class GraphMessagesView:
day_item.setExpanded(True)
def _show_msg_json(self, item, _col: int = 0) -> None:
"""Bấm một hội thoại trong cây: hiện toàn bộ nội dung của nó dưới dạng JSON đã
thoát HTML ở khung chi tiết.
"""
import html
import json
+23
View File
@@ -41,6 +41,11 @@ class GraphQaWidget(QWidget):
collapse_changed = Signal(bool) # so the shell can resize its own splitter
def __init__(self, ctx, renderer, parent=None):
"""Panel Hỏi đáp bên phải màn GraphRAG.
Có cache trích xuất vì cùng một node hay bị hỏi lại nhiều lần trong một
phiên, mà mỗi lần trích lại phải đọc tệp.
"""
super().__init__(parent)
self.ctx = ctx
self._renderer = renderer
@@ -97,6 +102,7 @@ class GraphQaWidget(QWidget):
self.retranslate()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, ô hỏi và tooltip."""
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
self._label.setText(tr("structure.agent_header"))
self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
@@ -107,25 +113,30 @@ class GraphQaWidget(QWidget):
# ---- collapse ------------------------------------------------------------- #
def _set_collapsed(self, collapsed: bool) -> None:
"""Gập/mở panel hỏi-đáp và báo ra ngoài để vỏ chỉnh lại splitter."""
self._panel.setVisible(not collapsed)
self._strip.setVisible(collapsed)
self.collapse_changed.emit(collapsed)
# ---- reacting to the renderer ----------------------------------------------- #
def _on_node_selected(self, data) -> None:
"""Chọn một node trên đồ thị: hiện chi tiết của node đó ở khung dưới."""
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
self._detail_mode = "node"
def _show_raw_json(self, html_text: str) -> None:
"""Hiện JSON thô của một hội thoại được chọn ở tab Tin nhắn."""
self.detail.setHtml(html_text)
def _preserve_answer(self) -> None:
"""Vẽ lại câu trả lời AI sau khi khung chi tiết bị dùng cho việc khác — nếu"""
if self._detail_mode == "answer" and self._answer.strip():
self._render_answer()
# ---- Q&A -------------------------------------------------------------------- #
@staticmethod
def _graph_context(graph) -> str:
"""Tóm tắt đồ thị thành văn bản gọn để nhét vào prompt: gom node theo loại,"""
from collections import defaultdict
by_kind = defaultdict(list)
for n in graph.nodes:
@@ -143,6 +154,7 @@ class GraphQaWidget(QWidget):
return "\n".join(lines)[:7000]
def _matched_sources(self, text: str):
"""Các node có tên xuất hiện trong câu trả lời — dùng để gắn link nguồn kiểm"""
graph = self._renderer.graph
if graph is None or not text:
return []
@@ -176,6 +188,7 @@ class GraphQaWidget(QWidget):
return text
def _render_answer(self) -> None:
"""Vẽ câu trả lời dạng markdown kèm danh sách nguồn bấm mở được."""
text = self._answer
sources = self._matched_sources(text)
if sources:
@@ -189,6 +202,9 @@ class GraphQaWidget(QWidget):
self.detail.setMarkdown(text)
def _on_detail_link(self, url: QUrl) -> None:
"""Bấm một link trong khung chi tiết: là tệp thì mở tệp, còn lại mở bằng trình
duyệt.
"""
if url.isLocalFile():
p = url.toLocalFile()
if Path(p).is_file():
@@ -197,6 +213,7 @@ class GraphQaWidget(QWidget):
open_folder(p)
def _ask(self) -> None:
"""Gửi câu hỏi về đồ thị tới model, chạy ở luồng nền."""
question = self.ask_edit.text().strip()
if not question:
return
@@ -225,6 +242,7 @@ class GraphQaWidget(QWidget):
selected_context = self._selection_context(selected_nodes, graph)
def job(worker: AgentWorker):
"""Chạy nền: ghép ngữ cảnh đồ thị + nội dung tệp đã trích rồi gọi model."""
provider = self.ctx.build_active_provider()
system = self._system_prompt(skill_prefix, active_project_id)
user_content = f"Graph context:\n{context}"
@@ -261,6 +279,7 @@ class GraphQaWidget(QWidget):
@staticmethod
def _selection_context(selected_nodes, graph) -> str:
"""Phần ngữ cảnh thêm cho các node người dùng đang chọn — câu hỏi "cái này là"""
if not selected_nodes:
return ""
node_lines = []
@@ -284,6 +303,7 @@ class GraphQaWidget(QWidget):
@staticmethod
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
"""Prompt hệ thống cho khung hỏi-đáp."""
system = ("You answer questions about a code/document knowledge graph. Use the provided "
"graph context AND the extracted file contents to retrieve, synthesize and "
"explain the answer. Be concise. Answer ONLY from what is provided (graph "
@@ -321,6 +341,7 @@ class GraphQaWidget(QWidget):
return system
def _on_ask_event(self, ev: dict) -> None:
"""Nhận từng mẩu trả lời đang phát dần và vẽ dần vào khung chi tiết."""
if ev.get("type") == "text":
if self._answer == "":
self.detail.clear()
@@ -330,6 +351,7 @@ class GraphQaWidget(QWidget):
def _on_ask_done(self, result: dict) -> None:
# Keep the (temporary) extracted content so repeated questions reuse
# it without re-extracting — dropped when leaving the tab.
"""Trả lời xong: giữ lại nội dung tệp đã trích để câu hỏi sau không phải trích"""
if isinstance(result, dict):
self._extract_cache.update(result.get("extracted", {}) or {})
self._render_answer()
@@ -352,6 +374,7 @@ class GraphQaWidget(QWidget):
return out
def _extract_tmp_dir(self) -> Path:
"""Thư mục tạm chứa nội dung tệp đã trích, tạo lười ở lần cần đầu tiên."""
if self._extract_dir is None:
import tempfile
from cowork_local.config import CONFIG_DIR
+60 -60
View File
@@ -13,7 +13,6 @@ AI-edit panel either.
"""
from __future__ import annotations
import math
from pathlib import Path
from typing import List, Optional
@@ -26,7 +25,9 @@ from PySide6.QtWidgets import (
from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.graph import graph_export
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
from cowork_local.presentation.graph.graph_scene_builder import build_scene
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
from cowork_local.presentation.shared import HAS_WEB_ENGINE
from cowork_local.state import AppContext
@@ -35,6 +36,7 @@ from cowork_local.ui.icons import icon
class GraphRenderer(QWidget):
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
status_message = Signal(str)
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit)
@@ -42,6 +44,12 @@ class GraphRenderer(QWidget):
project_changed = Signal() # a DIFFERENT project was selected (or cleared)
def __init__(self, ctx: AppContext):
"""Dựng thanh công cụ, khung xem 2D và chỗ chờ cho khung D3.
Khung D3 KHÔNG được dựng ở đây: ``QWebEngineView`` mất vài trăm mili giây
để khởi tạo, mà phần lớn người dùng mở màn này chỉ để xem cảnh Qt. Nó được
dựng muộn trong :meth:`_ensure_web`.
"""
super().__init__()
self.ctx = ctx
self._worker: Optional[AgentWorker] = None
@@ -118,6 +126,7 @@ class GraphRenderer(QWidget):
on_language_changed(self._retranslate)
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho thanh công cụ và tên tab."""
self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
self._pick_btn.setText(tr("structure.browse"))
self._scan_btn.setText(tr("structure.scan"))
@@ -131,17 +140,23 @@ class GraphRenderer(QWidget):
# ---- public read API for GraphQaWidget ----------------------------------- #
@property
def graph(self):
"""Đồ thị đã quét gần nhất; ``None`` nếu chưa quét lần nào."""
return self._graph
@property
def active_project_id(self) -> str:
"""Id dự án đang chọn trong bộ chọn; '' nếu chưa chọn."""
return self._active_project_id
def selected_node_data(self) -> list:
"""Dữ liệu của các node đang được chọn trên cảnh — panel Hỏi đáp đọc cái này
để biết người dùng đang hỏi về cái gì.
"""
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
# ---- project sandbox lock ------------------------------------------------- #
def _refresh_project_combo(self) -> None:
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
from cowork_local.core.projects import list_projects
keep = self._active_project_id
@@ -157,6 +172,7 @@ class GraphRenderer(QWidget):
self.project_combo.blockSignals(False)
def set_project(self, project_id: str) -> None:
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
pid = project_id or ""
self._refresh_project_combo()
target = self.project_combo.findData(pid)
@@ -168,6 +184,7 @@ class GraphRenderer(QWidget):
self.project_combo.setCurrentIndex(target)
def _on_project_changed(self, _idx: int) -> None:
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
from cowork_local.core.projects import load_project
pid = self.project_combo.currentData() or ""
@@ -188,11 +205,13 @@ class GraphRenderer(QWidget):
# ---- helpers ---------------------------------------------------------------- #
def _pick(self) -> None:
"""Mở hộp thoại chọn thư mục gốc để quét."""
chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
if chosen:
self.path_edit.setText(chosen)
def schedule_rescan(self, path: str = "") -> None:
"""Hẹn quét lại sau khi có thay đổi trên đĩa."""
if self._graph is None:
self._needs_scan = True
return
@@ -200,6 +219,7 @@ class GraphRenderer(QWidget):
# ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- #
def _on_view_tab(self, index: int) -> None:
"""Đổi giữa tab đồ thị và tab Tin nhắn."""
self._messages.on_view_tab(index)
def _toggle_messages(self) -> None:
@@ -217,6 +237,11 @@ class GraphRenderer(QWidget):
self._scan()
def _ensure_web(self) -> None:
"""Dựng khung D3 nếu máy có WebEngine và chưa dựng.
Gọi được nhiều lần — lần thứ hai trở đi không làm gì. Máy không có
WebEngine thì bỏ qua im lặng và người dùng ở lại với cảnh Qt.
"""
if self.web is not None or not HAS_WEB_ENGINE:
return
from PySide6.QtWebChannel import QWebChannel
@@ -235,6 +260,7 @@ class GraphRenderer(QWidget):
self._render_d3()
def auto_scan_and_fit(self) -> None:
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần."""
self._ensure_web()
if not self.path_edit.text().strip():
return
@@ -251,6 +277,11 @@ class GraphRenderer(QWidget):
# ---- scan --------------------------------------------------------------------- #
def _scan(self) -> None:
"""Quét thư mục gốc ở luồng nền rồi vẽ kết quả.
Mỗi lượt quét mang một số thứ tự ``_scan_seq``; kết quả về mà số không còn
khớp thì bị bỏ, để lượt quét cũ không đè lên lượt mới.
"""
path = self.path_edit.text().strip() or str(Path.cwd())
mode = "files"
use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
@@ -263,6 +294,11 @@ class GraphRenderer(QWidget):
self.status_message.emit(tr("structure.scanning"))
def job(worker: AgentWorker):
"""Chạy nền: dựng đồ thị từ thư mục rồi tính toạ độ.
Ưu tiên đọc từ codebase-memory nếu có sẵn — nhanh hơn nhiều so với quét lại
cả cây thư mục; không có thì quét trực tiếp.
"""
from cowork_local.core.structure_graph import (
build_from_codebase_memory, build_from_directory, force_layout,
)
@@ -283,6 +319,12 @@ class GraphRenderer(QWidget):
w.start()
def _render(self, result: dict) -> None:
"""Vẽ kết quả quét ra khung Qt 2D (và ra D3 nếu đang bật).
Lượt quét cũ về muộn thì bỏ: ``seq`` của nó không còn khớp
``_scan_seq``. Không chặn ở đây thì người dùng đổi dự án nhanh sẽ thấy
đồ thị của dự án TRƯỚC đè lên dự án đang chọn.
"""
if result.get("seq") is not None and result["seq"] != self._scan_seq:
return
graph = result.get("graph")
@@ -291,36 +333,8 @@ class GraphRenderer(QWidget):
return
self._graph = graph
self.scene.clear()
self.scene.setBackgroundBrush(QColor(current_palette().bg))
self._node_items = []
self._edge_items = []
degree = {n.id: 0 for n in graph.nodes}
for e in graph.edges:
if e.source in degree:
degree[e.source] += 1
if e.target in degree:
degree[e.target] += 1
items = {}
sx = sy = 0.0
for node in graph.nodes:
radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
item = _Node(node, radius)
x, y = pos.get(node.id, (0, 0))
item.setPos(x, y)
self.scene.addItem(item)
items[node.id] = item
self._node_items.append(item)
sx += x
sy += y
for edge in graph.edges:
a, b = items.get(edge.source), items.get(edge.target)
if a and b:
e = _Edge(a, b, getattr(edge, "type", ""))
self.scene.addItem(e)
self._edge_items.append(e)
n = max(1, len(self._node_items))
self._centroid = QPointF(sx / n, sy / n)
self._node_items, self._edge_items, self._centroid = build_scene(
self.scene, graph, pos, current_palette().bg)
self._fit()
if self.web is not None:
@@ -332,6 +346,9 @@ class GraphRenderer(QWidget):
self.graph_rendered.emit()
def _render_d3(self) -> None:
"""Nạp lại trang D3 từ đồ thị hiện có. Lỗi dựng HTML chỉ báo lên thanh trạng
thái chứ không ném ra — cảnh Qt vẫn xem được.
"""
if self.web is None or self._graph is None:
return
from cowork_local.core.d3_graph import build_html
@@ -342,12 +359,14 @@ class GraphRenderer(QWidget):
# ---- native interactions ------------------------------------------------------- #
def _on_selection(self) -> None:
"""Chọn một node trên cảnh thì báo cho panel Hỏi đáp biết."""
for item in self.scene.selectedItems():
if isinstance(item, _Node):
self.node_selected.emit(item.data)
return
def _fit(self) -> None:
"""Canh toàn bộ đồ thị vừa khung nhìn."""
if self.web is not None and self._stack.currentWidget() is self.web:
self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
return
@@ -356,36 +375,17 @@ class GraphRenderer(QWidget):
self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
def _export(self) -> None:
path, _ = QFileDialog.getSaveFileName(
self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
if not path:
return
showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
if showing_d3:
self._export_d3_png(path)
else:
self._export_widget_grab(path)
"""Xuất khung đang xem ra PNG.
def _export_d3_png(self, path: str) -> None:
def on_result(data_url) -> None:
if not isinstance(data_url, str) or "," not in data_url:
self._export_widget_grab(path)
return
import base64
try:
with open(path, "wb") as f:
f.write(base64.b64decode(data_url.split(",", 1)[1]))
self.status_message.emit(tr("structure.export_done", path=path))
except (OSError, ValueError) as exc:
self.status_message.emit(tr("structure.export_failed", err=str(exc)))
self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
def _export_widget_grab(self, path: str) -> None:
ok = self._stack.currentWidget().grab().save(path, "PNG")
if ok:
self.status_message.emit(tr("structure.export_done", path=path))
else:
self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
Cách chụp tuỳ khung nào đang hiện — xem ``graph_export.py``.
"""
graph_export.export_png(
parent=self,
showing=self._stack.currentWidget(),
web=self.web,
is_web_visible=self._stack.currentWidget() is self.web,
status=self.status_message.emit,
)
__all__ = ["GraphRenderer"]
+95
View File
@@ -0,0 +1,95 @@
"""Dựng các item Qt cho khung đồ thị 2D — tách khỏi ``graph_renderer.py``.
Đây là phần "biến dữ liệu thành hình" của GraphRAG: nhận một đồ thị đã quét
cùng bảng toạ độ, đổ ``_Node``/``_Edge`` vào ``QGraphicsScene`` rồi trả lại
danh sách item và trọng tâm cho bên gọi.
Tách ra vì nó không cần biết gì về widget: không đọc thuộc tính nào của
``GraphRenderer``, không phát tín hiệu, không chạm cấu hình. Nhờ thế mà thử
được bằng một ``QGraphicsScene`` trần, và ``graph_renderer.py`` bớt đi phần
duy nhất trong nó có tính toán hình học thật sự.
"""
from __future__ import annotations
import math
from typing import Dict, List, Tuple
from PySide6.QtCore import QPointF
from PySide6.QtGui import QColor
from PySide6.QtWidgets import QGraphicsScene
from cowork_local.presentation.graph.graph_scene_items import _Edge, _Node
def node_degrees(graph) -> Dict[str, int]:
"""Số cạnh chạm vào từng node.
Dùng để định cỡ node: node càng nhiều liên kết thì vẽ càng to, nên mắt
người nhìn vào là thấy ngay đâu là đầu mối của cả đồ thị.
"""
degree = {n.id: 0 for n in graph.nodes}
for edge in graph.edges:
if edge.source in degree:
degree[edge.source] += 1
if edge.target in degree:
degree[edge.target] += 1
return degree
def node_radius(degree: int) -> int:
"""Bán kính vẽ của một node theo bậc của nó.
Lấy căn bậc hai chứ không lấy tuyến tính: bậc tăng gấp bốn thì bán kính
mới gấp đôi, nhờ vậy DIỆN TÍCH mới tỉ lệ với bậc — đó mới là thứ mắt
người thật sự so sánh. Chặn trên ở 28 để một node trung tâm không nuốt
mất phần còn lại của đồ thị.
"""
return int(8 + min(20, 2.2 * math.sqrt(degree)))
def build_scene(
scene: QGraphicsScene, graph, pos: Dict[str, Tuple[float, float]], bg: str
) -> Tuple[List[_Node], List[_Edge], QPointF]:
"""Xoá sạch ``scene`` rồi dựng lại toàn bộ node và cạnh của ``graph``.
``pos`` là toạ độ đã tính sẵn ở luồng nền (``{node_id: (x, y)}``); node
không có trong đó rơi về gốc toạ độ thay vì bị bỏ, để không im lặng đánh
mất dữ liệu.
Cạnh chỉ được vẽ khi CẢ HAI đầu đều có item — đồ thị bị cắt bớt
(``truncated``) hay dữ liệu lệch có thể trỏ tới node không tồn tại, và
một ``_Edge`` treo lơ lửng sẽ làm hỏng cả phép tính khung nhìn.
Trả về ``(danh sách node, danh sách cạnh, trọng tâm)``. Trọng tâm là
trung bình cộng toạ độ các node, dùng làm tâm khi thu phóng.
"""
scene.clear()
scene.setBackgroundBrush(QColor(bg))
degree = node_degrees(graph)
items: Dict[str, _Node] = {}
node_items: List[_Node] = []
sx = sy = 0.0
for node in graph.nodes:
item = _Node(node, node_radius(degree.get(node.id, 0)))
x, y = pos.get(node.id, (0, 0))
item.setPos(x, y)
scene.addItem(item)
items[node.id] = item
node_items.append(item)
sx += x
sy += y
edge_items: List[_Edge] = []
for edge in graph.edges:
a, b = items.get(edge.source), items.get(edge.target)
if a and b:
e = _Edge(a, b, getattr(edge, "type", ""))
scene.addItem(e)
edge_items.append(e)
n = max(1, len(node_items))
return node_items, edge_items, QPointF(sx / n, sy / n)
__all__ = ["build_scene", "node_degrees", "node_radius"]
+34
View File
@@ -22,12 +22,25 @@ class _Bridge(QObject):
@Slot(str)
def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
"""Cầu nối JavaScript → Python: Shift+click một node trên khung D3 thì mở thư
mục hoặc link lưu trữ của nó.
"""
if path:
open_location(path)
class _Edge(QGraphicsLineItem):
"""Một cạnh trên khung Qt 2D, kèm nhãn tên quan hệ ở điểm giữa.
Màu cạnh lấy theo LOẠI quan hệ (contains/defines/method…) để nhìn đồ thị
là biết mỗi liên kết nghĩa là gì; cạnh không có loại thì mượn màu nhạt của
node nguồn. Nằm ở z=-1, tức dưới mọi node.
"""
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
"""Một cạnh trên khung đồ thị, tô màu theo LOẠI quan hệ (chứa/định nghĩa/phương
thức…) để nhìn là biết liên kết ấy nghĩa gì; cạnh không có loại thì lấy màu
của node nguồn.
"""
super().__init__()
self.a, self.b = a, b
self.type = type_
@@ -54,6 +67,7 @@ class _Edge(QGraphicsLineItem):
self.adjust()
def adjust(self) -> None:
"""Vẽ lại đường thẳng theo vị trí hai node và đặt nhãn vào đúng điểm giữa."""
pa, pb = self.a.scenePos(), self.b.scenePos()
self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
if self._label is not None:
@@ -63,7 +77,18 @@ class _Edge(QGraphicsLineItem):
class _Node(QGraphicsEllipseItem):
"""Một node hình tròn; bán kính do chỗ gọi tính theo bậc của node.
``NODE_KIND_COLORS`` là mã màu theo LOẠI dữ liệu (mỗi loại một sắc), cố ý
giữ nguyên ở cả theme sáng lẫn tối để một loại luôn là một màu. Chỉ phần
khung viền/chữ mới đi theo theme.
"""
def __init__(self, data, radius: int):
"""Một node hình tròn, bán kính theo số liên kết.
Màu theo loại node là mã hoá dữ liệu chứ không phải trang trí, nên cố định
qua mọi giao diện — cùng một loại luôn cùng một màu.
"""
super().__init__(-radius, -radius, 2 * radius, 2 * radius)
self.data = data
self.edges = []
@@ -85,6 +110,7 @@ class _Node(QGraphicsEllipseItem):
label.setPos(radius + 3, -8)
def itemChange(self, change, value): # noqa: N802
"""Kéo node thì kéo theo mọi cạnh dính vào nó."""
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
for edge in self.edges:
edge.adjust()
@@ -92,17 +118,23 @@ class _Node(QGraphicsEllipseItem):
class _GraphView(QGraphicsView):
"""Khung xem đồ thị Qt 2D: lăn chuột để phóng, kéo nền để dời khung."""
def __init__(self, scene):
"""Khung xem đồ thị: kéo bằng chuột giữa để di chuyển, không kéo chọn vùng."""
super().__init__(scene)
self.setDragMode(QGraphicsView.NoDrag)
self._panning = False
self._pan_start = QPointF()
def wheelEvent(self, e): # noqa: N802
"""Lăn chuột phóng to/thu nhỏ một nấc 1,15 lần."""
self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
def mousePressEvent(self, e): # noqa: N802
"""Nhấn trái vào chỗ TRỐNG thì bắt đầu kéo khung; nhấn trúng node thì để Qt
xử lý như chọn/kéo node bình thường.
"""
if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
self._panning = True
self._pan_start = e.position()
@@ -112,6 +144,7 @@ class _GraphView(QGraphicsView):
super().mousePressEvent(e)
def mouseMoveEvent(self, e): # noqa: N802
"""Đang kéo khung: dời hai thanh cuộn ngược chiều con trỏ."""
if self._panning:
delta = e.position() - self._pan_start
self._pan_start = e.position()
@@ -122,6 +155,7 @@ class _GraphView(QGraphicsView):
super().mouseMoveEvent(e)
def mouseReleaseEvent(self, e): # noqa: N802
"""Thả chuột: kết thúc kéo khung, trả con trỏ về bình thường."""
if self._panning:
self._panning = False
self.setCursor(Qt.ArrowCursor)
@@ -21,9 +21,18 @@ _COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
class StructureGraphView(QWidget):
"""Màn GraphRAG: ghép khung đồ thị (:class:`GraphRenderer`) và khung hỏi-đáp
(:class:`GraphQaWidget`) vào một splitter.
Vỏ này chỉ lắp ráp và chuyển tiếp bốn phương thức mà ``app.py`` và
``ui/workspace_tab.py`` gọi tới; toàn bộ phần việc thật nằm ở hai widget con.
"""
status_message = Signal(str)
def __init__(self, ctx: AppContext):
"""Ghép hai nửa của màn GraphRAG: bên trái là đồ thị, bên phải là panel Hỏi
đáp.
"""
super().__init__()
self.ctx = ctx
@@ -44,10 +53,14 @@ class StructureGraphView(QWidget):
on_language_changed(self._retranslate)
def _retranslate(self) -> None:
"""Chuyển lệnh dịch lại xuống cho cả hai widget con."""
self.renderer._retranslate()
self.qa.retranslate()
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
"""Gập/mở khung hỏi-đáp: gập thì thu về đúng bề rộng dải nắm, mở thì trả
splitter về tỉ lệ mặc định.
"""
strip_w = CollapseStrip.WIDTH + 2
if collapsed:
self.qa.setMaximumWidth(strip_w)
@@ -60,19 +73,24 @@ class StructureGraphView(QWidget):
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
def schedule_rescan(self, path: str = "") -> None:
"""Hẹn quét lại đồ thị sau khi thư mục có thay đổi."""
self.renderer.schedule_rescan(path)
def auto_scan_and_fit(self) -> None:
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần."""
self.renderer.auto_scan_and_fit()
def set_project(self, project_id: str) -> None:
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
self.renderer.set_project(project_id)
def prewarm(self) -> None:
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
self.renderer.prewarm()
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
self.qa.clear_extracts()
super().hideEvent(e)
+32
View File
@@ -33,6 +33,12 @@ _UNBOUNDED_PAGE_SIZE = 100_000
class MonitoringTab(QWidget):
"""Vỏ màn Giám sát: dải 8 tab và phần dữ liệu dùng chung cho chúng.
Nhật ký kiểm toán được đọc MỘT lần mỗi nhịp làm mới rồi chia cho các tab,
nên quyết định "đọc từ nguồn cục bộ hay nguồn chia sẻ" chỉ xảy ra đúng một
lần chứ không phải ở từng tab.
"""
status_message = Signal(str)
# ---- cầu tương thích sau khi tách 8 tab (R08-T08) --------------------
@@ -66,6 +72,14 @@ class MonitoringTab(QWidget):
}
def __getattr__(self, name):
"""Cầu tương thích cho 55 control cũ mang tiền tố ``ov_``.
R08-T08 chuyển chúng về đúng tab/thẻ của mình và bỏ tiền tố, nhưng các
checker trong ``tools/`` vẫn hỏi theo tên cũ. Định tuyến theo TIỀN TỐ chứ
không dò mờ: ``overview_tab`` và ``permissions_card`` có tên trùng nhau
(``network_lbl`` ở cả hai — một cái là mức dùng mạng, một cái là quyền truy
cập mạng), dò mờ sẽ vớ nhầm cái tìm thấy trước.
"""
lay_khac = self._KHAC.get(name)
if lay_khac is not None:
return lay_khac(self)
@@ -84,6 +98,11 @@ class MonitoringTab(QWidget):
raise AttributeError(name)
def __init__(self, ctx: AppContext, cowork=None, structure=None, task_scheduler=None):
"""Ghép các tab con của màn Giám sát.
Các widget được truyền vào đều có thể là None (test dựng tab lẻ) — mọi chỗ
dùng phải kiểm trước.
"""
super().__init__()
self.ctx = ctx
self._cowork = cowork
@@ -174,6 +193,7 @@ class MonitoringTab(QWidget):
return out
def select_subtab(self, index: int) -> None:
"""Chuyển sang tab con thứ ``index`` (thanh menu bên trái gọi vào đây)."""
if 0 <= index < self.tabs.count():
self.tabs.setCurrentIndex(index)
@@ -182,18 +202,24 @@ class MonitoringTab(QWidget):
self.tabs.tabBar().hide()
def _show_action_logs_tab(self) -> None:
"""Nhảy tới tab Nhật ký hành động (dùng khi bấm vào phần trích ở Tổng quan)."""
self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_tab))
def _tab_visible(self, key: str) -> bool:
"""Tab này có được hiện không: admin thấy hết, còn lại theo cấu hình
``monitoring_visibility``.
"""
return self.ctx.role == "admin" or bool(self.ctx.config.monitoring_visibility.get(key, True))
def _set_tab_text_if_present(self, widget, text: str) -> None:
"""Đổi nhãn một tab, bỏ qua nếu tab đó đang bị ẩn theo phân quyền."""
idx = self.tabs.indexOf(widget)
if idx >= 0:
self.tabs.setTabText(idx, text)
# ---- i18n ---------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề và nhãn từng tab."""
self._title.setText(tr("monitoring.title"))
if self.tabs.count():
self.tabs.setTabText(0, tr("monitoring.tab_overview"))
@@ -229,6 +255,11 @@ class MonitoringTab(QWidget):
self.overview_tab.refresh(self._load_events())
def _load_events(self) -> List[dict]:
"""Đọc nhật ký kiểm toán, ưu tiên nguồn chia sẻ rồi mới tới nguồn cục bộ.
Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
"""
shared_dir = self.ctx.config.shared_dir
if shared_dir:
from ...core import telemetry_shared
@@ -252,6 +283,7 @@ class MonitoringTab(QWidget):
service = MonitoringQueryService(repository)
def _events_for(kind) -> List[dict]:
"""Lấy toàn bộ sự kiện của một loại, không phân trang."""
page = service.query(kind=kind, page_size=_UNBOUNDED_PAGE_SIZE)
return [e.to_dict() for e in page.items]
@@ -0,0 +1,3 @@
"""Mảnh dùng chung của các tab Giám sát: bảng sự kiện, panel chi tiết, huy hiệu,
bộ lọc và các hàm định dạng.
"""
@@ -0,0 +1,67 @@
"""Màu, huy hiệu và avatar của agent quản trị theo LOẠI VIỆC nó làm.
Bảng Agents Admin xếp agent theo ``task_kind`` (search / monitor / cowork /
graphrag / schedule / security / help). Ở đây gom toàn bộ phần "loại việc ấy
trông thế nào": màu định danh cho avatar, tông huy hiệu cho ô Vai trò, tông
huy hiệu cho ô Trạng thái.
Khác với ``formatters.py::agent_avatar_colour`` — chỗ đó đoán màu bằng cách
dò từ khoá trong TÊN agent, dùng cho các bảng chỉ có tên. Bảng quản trị biết
chính xác loại việc nên tra thẳng, không phải đoán.
Bảy loại, bảy tông riêng: không hai loại nào trùng màu huy hiệu, để phân biệt
được bằng màu chứ không phải chỉ bằng chữ. Màu cố định, không đổi theo giao
diện sáng/tối — cùng quy ước với avatar từng agent ở ``monitoring_tab.py``.
"""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from .formatters import agent_initials
#: Màu vòng tròn avatar theo loại việc.
KIND_COLOUR = {
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
"help": "#E3008C",
}
#: Tên đối tượng huy hiệu (định nghĩa trong ``theme.py``) cho ô Vai trò.
KIND_BADGE = {
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
"help": "badgePink",
}
#: Tông huy hiệu cho ô Trạng thái, theo kết quả lần kiểm tra gần nhất.
STATUS_BADGE = {
"unchecked": "badgeNeutral", "checking": "badgeWarn",
"ok": "badgeSuccess", "bad": "badgeDanger",
}
def kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
"""Avatar tròn cho một agent: nền là màu của loại việc, chữ là tên viết tắt.
Loại lạ (file cấu hình sửa tay, loại mới thêm mà quên khai màu) rơi về màu
xanh mặc định thay vì không vẽ gì — thiếu màu đúng vẫn hơn thiếu avatar.
"""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(KIND_COLOUR.get(kind, "#0078D4")))
p.drawEllipse(0, 0, size, size)
font = QFont()
font.setPixelSize(max(7, size // 2))
font.setBold(True)
p.setFont(font)
p.setPen(QColor("#FFFFFF"))
p.drawText(pm.rect(), Qt.AlignCenter, agent_initials(name))
p.end()
return QIcon(pm)
__all__ = ["KIND_COLOUR", "KIND_BADGE", "STATUS_BADGE", "kind_avatar_icon"]
@@ -13,12 +13,17 @@ from PySide6.QtWidgets import QLineEdit, QPushButton
def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc bảng sự kiện.
Ô tìm rỗng hoặc đang chạy dở thì bỏ qua.
"""
query = search.text().strip()
if not query or state.get("worker") is not None:
return
ai_btn.setEnabled(False)
def job(worker):
"""Chạy nền: hỏi model từ khoá lọc tương ứng câu người dùng gõ."""
provider = ctx.build_active_provider()
reply = provider.chat([
{"role": "system", "content":
@@ -29,11 +34,15 @@ def start_ai_filter(ctx, search: QLineEdit, ai_btn: QPushButton, state: dict) ->
return {"keyword": (reply.get("content") or "").strip().splitlines()[0][:60]}
def done(result: dict) -> None:
"""Đổ từ khoá model trả về vào ô tìm và áp bộ lọc."""
state["worker"] = None
ai_btn.setEnabled(True)
search.setText(result.get("keyword") or query)
def failed(_err: str) -> None:
"""Lọc bằng AI lỗi: chỉ mở khoá lại nút, giữ nguyên bộ lọc đang có — đây là
tiện ích phụ, hỏng thì không được làm phiền người dùng.
"""
state["worker"] = None
ai_btn.setEnabled(True)
+11
View File
@@ -29,6 +29,7 @@ _ACTION_LABEL_KEYS = {
def action_label(name: str) -> str:
"""Nhãn đã dịch của một loại hành động; không có trong bảng thì trả nguyên tên."""
key = _ACTION_LABEL_KEYS.get(name)
return tr(key) if key else name
@@ -44,6 +45,7 @@ _STATUS_DEFAULT = ("badgePurple", "monitoring.status_blocked")
def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
"""Cặp (tên style, khoá dịch) cho huy hiệu trạng thái của một sự kiện."""
if name in _STATUS_INFO:
return _STATUS_INFO[name]
if kind == "security_block":
@@ -58,6 +60,11 @@ def status_info(name: str, kind: str = "security_block", ok: bool = False) -> Tu
def severity_info(name: str, kind: str = "security_block", ok: bool = False) -> Tuple[str, str]:
# An unapproved shell command is the one CRITICAL case; everything else
# blocked is MEDIUM.
"""Cặp (tên style, khoá dịch) cho huy hiệu mức nghiêm trọng.
Lệnh shell chạy mà chưa được duyệt là trường hợp NGHIÊM TRỌNG duy nhất; mọi
thứ bị chặn khác đều ở mức trung bình.
"""
if name in ("dangerous_command", "run_command"):
return "badgeDanger", "monitoring.severity_critical"
if kind == "security_block":
@@ -85,6 +92,10 @@ def agent_badge_name(name: str) -> str:
def apply_badge(label: QLabel, object_name: str) -> None:
"""Gán style cho một nhãn huy hiệu và ép Qt vẽ lại.
Phải ``unpolish``/``polish`` vì Qt không tự áp lại QSS khi ``objectName`` đổi.
"""
label.setObjectName(object_name)
label.style().unpolish(label)
label.style().polish(label)
@@ -33,6 +33,7 @@ class EventDetailPanel(QWidget):
closed = Signal()
def __init__(self):
"""Panel chi tiết hiện đầy đủ một sự kiện đang chọn trong bảng."""
super().__init__()
self.setObjectName("monSection")
self._detail_text = ""
@@ -64,12 +65,14 @@ class EventDetailPanel(QWidget):
self._rows: Dict[str, Tuple[QLabel, QLabel]] = {}
def _section(key: str) -> None:
"""Thêm một dòng tiêu đề mục vào panel."""
lbl = QLabel()
lbl.setObjectName("detailSectionHdr")
self._body_lay.addWidget(lbl)
self._section_hdrs.append((key, lbl))
def _field(key: str) -> QLabel:
"""Thêm một hàng nhãn–giá trị và ghi nhớ để cập nhật sau."""
lbl, val = kv_row(self._body_lay)
self._rows[key] = (lbl, val)
return val
@@ -121,6 +124,7 @@ class EventDetailPanel(QWidget):
outer.addLayout(footer)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self._title_lbl.setText(tr("monitoring.security_detail_title"))
self._close_btn.setToolTip(tr("monitoring.security_detail_close"))
self._footer_close_btn.setText(tr("monitoring.security_detail_close"))
@@ -146,6 +150,7 @@ class EventDetailPanel(QWidget):
self._reset_copy_btn()
def show_event(self, ev: dict, row: int) -> None:
"""Hiện chi tiết một sự kiện; trường trống hiển thị dấu "—" thay vì để rỗng."""
na = "—"
self._rows["time"][1].setText(fmt_event_time_full(ev.get("ts", "")) or na)
@@ -179,11 +184,13 @@ class EventDetailPanel(QWidget):
apply_badge(self._severity_val, severity_badge)
def _copy_detail(self) -> None:
"""Chép toàn bộ chi tiết vào clipboard và đổi nhãn nút để báo đã chép."""
QGuiApplication.clipboard().setText(self._detail_text)
self._copy_btn.setText(tr("monitoring.detail_copied"))
self._copy_btn.setIcon(icon("check"))
QTimer.singleShot(1500, self._reset_copy_btn)
def _reset_copy_btn(self) -> None:
"""Trả nút Sao chép về nhãn ban đầu sau vài giây."""
self._copy_btn.setText(tr("monitoring.detail_copy"))
self._copy_btn.setIcon(icon("document"))
@@ -26,10 +26,18 @@ class _TimeItem(QTableWidgetItem):
ISO ``ts`` each item is built from instead of its displayed text."""
def __init__(self, raw_ts: str, display: str):
"""Ô thời gian giữ luôn chuỗi gốc: hiện ra là chữ đã rút gọn, nhưng sắp xếp
phải theo mốc thật chứ không theo thứ tự chữ cái.
"""
super().__init__(display)
self._raw_ts = raw_ts
def __lt__(self, other):
"""So sánh theo mốc thời gian gốc, không theo chuỗi hiển thị.
Sắp theo chuỗi đã định dạng sẽ ra thứ tự sai ngay khi định dạng có chữ
("5 phút trước" đứng trước "hôm qua").
"""
if isinstance(other, _TimeItem):
return self._raw_ts < other._raw_ts
return super().__lt__(other)
@@ -56,6 +64,11 @@ class EventTable(QTableWidget):
def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát.
``show_result`` tắt cột Kết quả cho màn Sự kiện bảo mật — ở đó mọi dòng đều
là thất bại nên cột ấy chỉ tốn chỗ.
"""
self._show_result = show_result
super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers)
@@ -74,6 +87,7 @@ class EventTable(QTableWidget):
header.setSectionResizeMode(col, QHeaderView.ResizeToContents)
def retranslate(self) -> None:
"""Áp lại tên cột theo ngôn ngữ đang chọn."""
cols = [tr("monitoring.col_time"),
tr("monitoring.col_agent") if not self._show_result else tr("monitoring.col_role"),
tr("monitoring.col_account"), tr("monitoring.col_machine")]
@@ -85,6 +99,11 @@ class EventTable(QTableWidget):
self.setHorizontalHeaderLabels(cols)
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai.
"""
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS]
self.setSortingEnabled(False)
self.setRowCount(len(events))
@@ -132,6 +151,7 @@ class EventTable(QTableWidget):
self.apply_filter(getattr(self, "_filter_needle", ""))
def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
self._filter_needle = (needle or "").strip().lower()
for row in range(self.rowCount()):
if not self._filter_needle:
@@ -144,6 +164,7 @@ class EventTable(QTableWidget):
self.setRowHidden(row, not match)
def event_at_row(self, row: int) -> Optional[dict]:
"""Bản ghi sự kiện gắn với một dòng; ``None`` nếu dòng trống."""
item = self.item(row, 0)
return item.data(Qt.UserRole) if item else None
@@ -156,12 +177,14 @@ class ClickOutsideCloser(QObject):
splitter's handle)."""
def __init__(self, table: "EventTable", panel: QWidget, container: QWidget):
"""Bấm ra ngoài panel chi tiết thì đóng nó lại."""
super().__init__(container)
self._table = table
self._panel = panel
self._container = container
def eventFilter(self, obj, event) -> bool:
"""Bắt cú bấm chuột trong toàn khung: rơi ngoài cả bảng lẫn panel thì đóng panel."""
if event.type() == QEvent.MouseButtonPress and self._panel.isVisible():
global_pos = event.globalPosition().toPoint()
top_left = self._container.mapToGlobal(self._container.rect().topLeft())
@@ -30,6 +30,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
# currentRow() alone is not enough: clearSelection() (used by the panel's
# close button) drops the selection but leaves the current cell in
# place, so a stale currentRow() would keep the panel open.
"""Đồng bộ panel chi tiết với dòng đang chọn trong bảng.
Chỉ nhìn ``currentRow()`` là chưa đủ: ``clearSelection()`` (nút đóng của panel
dùng) bỏ vùng chọn nhưng GIỮ nguyên ô hiện tại, nên chỉ dựa vào
``currentRow()`` thì panel sẽ không bao giờ đóng.
"""
row = table.currentRow()
has_selection = bool(table.selectedItems())
ev = table.event_at_row(row) if (has_selection and row >= 0) else None
@@ -44,6 +50,12 @@ def build_filter_scaffold(
with_detail: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]:
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
nút lọc bằng AI và panel chi tiết.
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
này được dựng một lần và dùng chung.
"""
lay = QVBoxLayout(page)
lay.setContentsMargins(0, 0, 0, 0)
parts: Dict[str, object] = {}
@@ -18,6 +18,7 @@ from ....i18n import tr
def _parse_iso(ts: str) -> Optional[datetime]:
"""Đọc chuỗi thời gian ISO; sai định dạng thì trả ``None``."""
try:
return datetime.fromisoformat(ts)
except (TypeError, ValueError):
@@ -25,6 +26,7 @@ def _parse_iso(ts: str) -> Optional[datetime]:
def fmt_bytes(n: float) -> str:
"""Đổi số byte sang chuỗi dễ đọc (B / KB / MB / GB)."""
for unit in ("B", "KB", "MB", "GB"):
if n < 1024:
return f"{n:.0f} {unit}"
@@ -10,6 +10,11 @@ from PySide6.QtWidgets import QWidget
def open_settings_and_notify(ctx, parent: QWidget, on_changed: Callable[[], None]) -> None:
"""Mở hộp thoại Cài đặt rồi gọi ``on_changed`` sau khi đóng.
Các thẻ trong màn Giám sát dùng chung hàm này để làm mới sau khi người dùng
sửa cấu hình an toàn.
"""
from ....ui.settings_dialog import SettingsDialog
dlg = SettingsDialog(ctx, parent)
+3
View File
@@ -0,0 +1,3 @@
"""Tám tab của màn Giám sát, mỗi tab một file: Tổng quan, Sandbox, Sự kiện bảo
mật, Lịch sử MCP, Nhật ký hành động, Trạng thái Agent, Quản trị Agent, Công cụ.
"""
@@ -15,7 +15,9 @@ from ..shared.filter_scaffold import build_filter_scaffold
class ActionLogsTab(QWidget):
"""Tab "Nhật ký hành động": bảng mọi thao tác đọc/ghi tệp và chạy lệnh của agent."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Nhật ký hành động: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
@@ -32,9 +34,11 @@ class ActionLogsTab(QWidget):
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
@@ -42,4 +46,5 @@ class ActionLogsTab(QWidget):
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -35,6 +35,11 @@ class AgentEditDialog(QDialog):
def __init__(self, parent=None, ctx: Optional[AppContext] = None,
agent: Optional[admin_agents.AdminAgent] = None,
default_model_hint: str = ""):
"""Form thêm/sửa một agent quản trị.
Danh sách model của từng provider được nạp nền và nhớ lại, để đổi qua đổi
lại giữa các provider không phải gọi mạng lần nữa.
"""
super().__init__(parent)
self.ctx = ctx
self._existing = agent
@@ -96,15 +101,18 @@ class AgentEditDialog(QDialog):
form.addRow(buttons)
def _load_live_models(self) -> None:
"""Nạp danh sách model đang sống của mọi provider, ở luồng nền."""
if self.ctx is None:
return
self.load_models_btn.setEnabled(False)
ctx = self.ctx
def job(_worker: AgentWorker):
"""Chạy nền: hỏi từng provider danh sách model."""
return preview_ai.fetch_live_models(ctx)
def done(result: dict) -> None:
"""Ghi nhớ danh sách model rồi nạp vào ô chọn theo provider đang chọn."""
self.load_models_btn.setEnabled(True)
self._live_models = result or {}
self._refresh_model_combo()
@@ -113,6 +121,7 @@ class AgentEditDialog(QDialog):
tr("agents_admin.load_models_empty"))
def failed(err: str) -> None:
"""Nạp model lỗi: hiện cảnh báo và mở khoá lại nút."""
self.load_models_btn.setEnabled(True)
QMessageBox.warning(self, tr("agents_admin.add_title"), err)
@@ -123,6 +132,7 @@ class AgentEditDialog(QDialog):
w.start()
def _refresh_model_combo(self) -> None:
"""Nạp lại ô chọn model theo provider đang chọn, giữ nguyên thứ đang gõ."""
provider_key = self.provider_combo.currentData()
current_text = self.model_combo.currentText().strip()
models = self._live_models.get(provider_key, []) if provider_key else []
@@ -133,6 +143,7 @@ class AgentEditDialog(QDialog):
self.model_combo.blockSignals(False)
def result_fields(self) -> Dict[str, str]:
"""Các trường agent lấy từ form, để chỗ gọi dựng bản ghi."""
return {
"name": self.name_edit.text().strip(),
"task_kind": self.kind_combo.currentData(),
@@ -18,8 +18,10 @@ from ..shared.formatters import agent_avatar_icon
class AgentStatusTab(QWidget):
"""Tab "Trạng thái Agent": đang có bao nhiêu lượt chat, task và luồng chạy."""
def __init__(self, ctx, on_refresh_all: Callable[[], None],
cowork=None, structure=None, task_scheduler=None):
"""Tab Tình trạng agent. Các widget truyền vào có thể là None khi dựng tab lẻ."""
super().__init__()
self._ctx = ctx
self._cowork = cowork
@@ -45,6 +47,7 @@ class AgentStatusTab(QWidget):
self.title_refresh_btn = parts["title_refresh_btn"]
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.table.setHorizontalHeaderLabels([
@@ -52,6 +55,7 @@ class AgentStatusTab(QWidget):
])
def refresh(self) -> None:
"""Đếm lại số việc đang chạy ở từng phân hệ và cập nhật bảng."""
cowork_n = len(self._cowork.active_workers()) if self._cowork is not None else 0
task_n = self._task_scheduler.running_count() if self._task_scheduler is not None else 0
ask_worker = getattr(self._structure, "_ask_worker", None)
@@ -12,88 +12,45 @@ The header's "Kiểm tra tất cả" icon probes each agent's effective provider
(``check_agent``) and shows the result as the Trạng thái pill (OK / error /
checking…) — separate from the per-row Kích hoạt switch, which only toggles
the config flag.
Màu, huy hiệu và avatar theo loại việc nằm ở
``../shared/agent_kind_visuals.py``. File này từng giữ bản sao riêng của ba
hàm dùng chung — ``_initials``, ``_fmt_updated``, ``_kind_avatar_icon`` —
giống hệt ``shared/formatters.py`` đến từng dòng; nay dùng thẳng bản chung
để định dạng thời gian và avatar không lệch nhau giữa các bảng Giám sát.
"""
from __future__ import annotations
from .agent_edit_dialog import AgentEditDialog
from datetime import datetime
from typing import Dict, List, Optional
from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtGui import QColor, QFont, QIcon, QPainter, QPixmap
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QHeaderView, QLabel, QLineEdit, QMessageBox, QPlainTextEdit, QPushButton,
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
from ....config import PROVIDER_LABELS
from ....core import admin_agents, preview_ai
from ....core import admin_agents
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog
from ..shared.agent_kind_visuals import KIND_BADGE, STATUS_BADGE, kind_avatar_icon
from ..shared.formatters import fmt_event_time
_PROVIDER_DEFAULT = "" # "" = each machine's own active provider (unchanged default)
# Identity colour (avatar circle) + badge tone per task_kind — same "fixed
# colour regardless of theme" convention as monitoring_tab.py's per-agent
# avatars, plus a badge object name (theme.py) for the Vai trò pill. Seven
# kinds, seven distinct tones — no two kinds share a badge colour.
_KIND_COLOUR = {
"search": "#8A8886", "monitor": "#FFB900", "cowork": "#0078D4",
"graphrag": "#8764B8", "schedule": "#107C10", "security": "#D13438",
"help": "#E3008C",
}
_KIND_BADGE = {
"search": "badgeNeutral", "monitor": "badgeWarn", "cowork": "badge",
"graphrag": "badgePurple", "schedule": "badgeSuccess", "security": "badgeDanger",
"help": "badgePink",
}
_STATUS_BADGE = {
"unchecked": "badgeNeutral", "checking": "badgeWarn",
"ok": "badgeSuccess", "bad": "badgeDanger",
}
def _initials(name: str) -> str:
return "".join(w[0] for w in name.split() if w)[:2].upper()
def _fmt_updated(ts: str) -> str:
""""dd/MM hh:mm" — same Cập nhật/Thời gian format as Monitoring's
Bảo mật/MCP/Hành động tables (``_fmt_event_time`` in monitoring_tab.py)."""
try:
dt = datetime.fromisoformat(ts)
except (TypeError, ValueError):
return ts
return dt.strftime("%d/%m %H:%M")
def _kind_avatar_icon(kind: str, name: str, size: int = 20) -> QIcon:
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(Qt.NoPen)
p.setBrush(QColor(_KIND_COLOUR.get(kind, "#0078D4")))
p.drawEllipse(0, 0, size, size)
font = QFont()
font.setPixelSize(max(7, size // 2))
font.setBold(True)
p.setFont(font)
p.setPen(QColor("#FFFFFF"))
p.drawText(pm.rect(), Qt.AlignCenter, _initials(name))
p.end()
return QIcon(pm)
class AgentsAdminTab(QWidget):
"""Tab "Quản trị Agent": cấu hình prompt, provider và model cho từng agent chuyên trách, kèm nút kiểm tra tình trạng hoạt động."""
def __init__(self, ctx: AppContext):
"""Dựng bảng agent quản trị.
Kết quả kiểm tra lần gần nhất được giữ trong ``_status`` và sống qua các lần
vẽ lại — người dùng bấm Kiểm tra một lần rồi lọc/sắp xếp vẫn thấy kết quả.
"""
super().__init__()
self.ctx = ctx
# Last operational-health result per agent_id → (ok, message). Populated
@@ -178,14 +135,17 @@ class AgentsAdminTab(QWidget):
# ---- storage ---------------------------------------------------------
def _dir(self):
"""Thư mục chứa agent quản trị (dùng thư mục chia sẻ nếu có cấu hình)."""
return admin_agents.agents_admin_dir(self.ctx.config.shared_dir)
def _default_model_hint(self) -> str:
"""Model mặc định gợi ý cho agent mới — lấy từ provider đang chọn."""
conf = self.ctx.config.provider_conf(self.ctx.config.active_provider)
return conf.get("model", "")
# ---- CRUD -------------------------------------------------------------
def _add(self) -> None:
"""Thêm một agent quản trị mới qua hộp thoại."""
dlg = AgentEditDialog(self, ctx=self.ctx, default_model_hint=self._default_model_hint())
if not dlg.exec():
return
@@ -201,6 +161,7 @@ class AgentsAdminTab(QWidget):
self.refresh()
def _edit_agent(self, agent_id: str) -> None:
"""Sửa một agent; id không còn tồn tại thì bỏ qua."""
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
@@ -224,6 +185,7 @@ class AgentsAdminTab(QWidget):
self.refresh()
def _delete_agent(self, agent_id: str) -> None:
"""Xoá một agent sau khi hỏi xác nhận."""
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
@@ -235,6 +197,7 @@ class AgentsAdminTab(QWidget):
self.refresh()
def _set_enabled(self, agent_id: str, enabled: bool) -> None:
"""Bật/tắt một agent; trạng thái không đổi thì không ghi đĩa."""
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None or agent.enabled == enabled:
return
@@ -248,7 +211,7 @@ class AgentsAdminTab(QWidget):
# ---- view --------------------------------------------------------------
def _status_cell(self, agent_id: str) -> tuple:
"""(state_key, display_text, tooltip) for the Trạng thái pill —
state_key indexes _STATUS_BADGE for the badge's colour tone."""
state_key indexes STATUS_BADGE for the badge's colour tone."""
res = self._status.get(agent_id)
if res is None:
return ("unchecked", tr("agents_admin.status_unchecked").lstrip("— ").strip(),
@@ -259,6 +222,7 @@ class AgentsAdminTab(QWidget):
return ("ok" if ok else "bad"), (tr("agents_admin.status_ok") if ok else tr("agents_admin.status_bad")), msg
def _toggle_widget(self, agent_id: str, enabled: bool) -> QWidget:
"""Dựng ô công tắc bật/tắt cho một dòng trong bảng."""
container = QWidget()
container.setStyleSheet("background: transparent;")
lay = QHBoxLayout(container)
@@ -271,6 +235,7 @@ class AgentsAdminTab(QWidget):
return container
def _row_actions_widget(self, agent_id: str) -> QWidget:
"""Dựng cụm nút sửa/xoá cho một dòng trong bảng."""
container = QWidget()
container.setStyleSheet("background: transparent;")
lay = QHBoxLayout(container)
@@ -295,6 +260,7 @@ class AgentsAdminTab(QWidget):
def refresh(self) -> None:
# Make sure the built-in in-app Help assistant exists, so the Admin can
# manage its provider/model here (the floating Help widget uses it).
"""Vẽ lại bảng agent."""
admin_agents.ensure_help_agent(self._dir())
agents = admin_agents.list_agents(self._dir())
self.table.setRowCount(len(agents))
@@ -307,10 +273,10 @@ class AgentsAdminTab(QWidget):
model = tr("agents_admin.default_model", model=default_model or "—")
name_item = QTableWidgetItem(agent.name)
name_item.setIcon(_kind_avatar_icon(agent.task_kind, agent.name))
name_item.setIcon(kind_avatar_icon(agent.task_kind, agent.name))
self.table.setItem(row, 0, name_item)
kind_tone = _KIND_BADGE.get(agent.task_kind, "badge")
kind_tone = KIND_BADGE.get(agent.task_kind, "badge")
self.table.setCellWidget(
row, 1, badge_pill_widget(tr(f"agents_admin.kind.{agent.task_kind}"), kind_tone))
@@ -318,12 +284,12 @@ class AgentsAdminTab(QWidget):
self.table.setCellWidget(row, 3, self._toggle_widget(agent.agent_id, agent.enabled))
state_key, status_text, status_tip = self._status_cell(agent.agent_id)
status_widget = badge_pill_widget(status_text, _STATUS_BADGE[state_key])
status_widget = badge_pill_widget(status_text, STATUS_BADGE[state_key])
if status_tip:
status_widget.setToolTip(status_tip)
self.table.setCellWidget(row, 4, status_widget)
self.table.setItem(row, 5, QTableWidgetItem(_fmt_updated(agent.updated)))
self.table.setItem(row, 5, QTableWidgetItem(fmt_event_time(agent.updated)))
self.table.setCellWidget(row, 6, self._row_actions_widget(agent.agent_id))
# ResizeToContents doesn't measure a cell WIDGET's real width (only
@@ -349,14 +315,17 @@ class AgentsAdminTab(QWidget):
ctx = self.ctx
def job(_worker: AgentWorker) -> dict:
"""Chạy nền: gọi thử từng agent để kiểm tra tình trạng hoạt động."""
return {a.agent_id: admin_agents.check_agent(ctx, a) for a in agents}
def done(result: dict) -> None:
"""Ghi kết quả kiểm tra vào bảng trạng thái rồi vẽ lại."""
self.check_btn.setEnabled(True)
self._status.update(result or {})
self.refresh()
def failed(err: str) -> None:
"""Kiểm tra lỗi: đánh dấu mọi agent là hỏng kèm lý do (cắt ở 200 ký tự)."""
self.check_btn.setEnabled(True)
for a in agents:
self._status[a.agent_id] = (False, err[:200])
@@ -369,6 +338,7 @@ class AgentsAdminTab(QWidget):
w.start()
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, gợi ý và tên cột."""
self._title_lbl.setText(tr("agents_admin.page_title"))
self._hint.setText(tr("agents_admin.hint"))
self.table.setHorizontalHeaderLabels([
+5
View File
@@ -15,7 +15,9 @@ from ..shared.filter_scaffold import build_filter_scaffold
class McpTab(QWidget):
"""Tab "Lịch sử MCP": bảng các lượt gọi tool MCP kèm độ trễ và kết quả."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Lịch sử MCP: bảng sự kiện kèm bộ lọc dùng chung."""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
@@ -32,9 +34,11 @@ class McpTab(QWidget):
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
@@ -42,4 +46,5 @@ class McpTab(QWidget):
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -25,10 +25,21 @@ from .sandbox_tab import SandboxDetailsCard
class OverviewTab(QWidget):
"""Tab "Tổng quan" của màn Giám sát: tài nguyên máy, thẻ token/chi phí, ngân
sách, trạng thái sandbox, hoạt động gần đây và trích nhật ký kiểm toán.
Một cột dọc duy nhất, mỗi phần là một mục có tiêu đề — bố cục này là chủ ý
để mắt quét từ trên xuống thay vì phải nhảy giữa hai cột.
"""
def __init__(self, ctx, *, on_status_message: Callable[[str], None],
on_settings_changed: Callable[[], None],
on_view_all_action_logs: Callable[[], None],
action_logs_tab_visible: bool):
"""Tab Tổng quan.
Mọi việc ra ngoài tab (báo trạng thái, mở màn khác) đều đi qua callback
truyền vào, nên tab này không cần biết cửa sổ chính.
"""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
@@ -64,6 +75,7 @@ class OverviewTab(QWidget):
# ---- Token Usage & Cost ------------------------------------------------
def _build_usage_section(self, root: QVBoxLayout) -> None:
"""Dựng mục thẻ token/chi phí kèm thẻ ngân sách."""
self.usage_group = QGroupBox()
self.usage_group.setObjectName("monSection")
usage_lay = QGridLayout(self.usage_group)
@@ -88,6 +100,7 @@ class OverviewTab(QWidget):
# ---- Recent Activity ----------------------------------------------------
def _build_activity_section(self) -> None:
"""Dựng mục "Hoạt động gần đây" (thêm vào cột ở dưới, cạnh nhật ký kiểm toán)."""
self.activity_group = QGroupBox()
self.activity_group.setObjectName("monSection")
act_lay = QVBoxLayout(self.activity_group)
@@ -98,12 +111,16 @@ class OverviewTab(QWidget):
# ---- Resource Usage -------------------------------------------------------
def _build_resource_section(self, root: QVBoxLayout) -> None:
"""Dựng mục tài nguyên: CPU, bộ nhớ, đĩa, mạng và trạng thái sandbox."""
self.resource_group = QGroupBox()
self.resource_group.setObjectName("monSection")
res_lay = QHBoxLayout(self.resource_group)
res_lay.setSpacing(6)
def _pair():
"""Thêm một cặp nhãn–giá trị vào hàng tài nguyên, tự chèn dấu phân cách giữa
các cặp.
"""
if not self._res_first:
sep = QLabel(chr(0xB7))
sep.setObjectName("hint")
@@ -117,6 +134,7 @@ class OverviewTab(QWidget):
return lbl, val
def _bar_row():
"""Thêm một cặp nhãn–giá trị kèm thanh tiến độ (CPU, bộ nhớ)."""
lbl, val = _pair()
bar = QProgressBar()
bar.setVisible(False)
@@ -132,6 +150,7 @@ class OverviewTab(QWidget):
# ---- Audit Log preview --------------------------------------------------
def _build_audit_section(self, root: QVBoxLayout, action_logs_tab_visible: bool) -> None:
"""Dựng mục trích nhật ký kiểm toán; ẩn hẳn khi tab Nhật ký hành động bị tắt."""
self.audit_group = QGroupBox()
self.audit_group.setObjectName("monSection")
audit_lay = QVBoxLayout(self.audit_group)
@@ -156,6 +175,10 @@ class OverviewTab(QWidget):
self._refresh_budget()
def _refresh_budget(self) -> None:
"""Cập nhật thẻ ngân sách: còn lại / tổng, phần trăm đã dùng, cảnh báo khi vượt 85%.
Không ghi đè ô nhập khi người dùng đang gõ trong đó.
"""
from ....core import model_pricing as mp
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
status = ut.budget_status(self.ctx.config)
@@ -175,6 +198,12 @@ class OverviewTab(QWidget):
# ---- resource usage --------------------------------------------------------
def _refresh_resource_usage(self) -> None:
"""Đọc CPU/RAM/đĩa/mạng của CHÍNH tiến trình app.
Đĩa và mạng là tốc độ tức thời, tính bằng hiệu hai lần lấy mẫu chia cho
khoảng thời gian giữa chúng — nên lần đo đầu tiên luôn hiện "—". Không có
``psutil`` thì cả mục chuyển sang "—" chứ không làm vỡ màn hình.
"""
try:
import psutil
except ImportError:
@@ -233,6 +262,7 @@ class OverviewTab(QWidget):
self.network_val.setText(na)
def _set_resource_na(self) -> None:
"""Đặt mọi chỉ số tài nguyên về "—" (khi thiếu ``psutil``)."""
na = tr("monitoring.na")
self.cpu_bar.setValue(0)
self.cpu_val.setText(na)
@@ -243,6 +273,7 @@ class OverviewTab(QWidget):
# ---- usage cards -----------------------------------------------------------
def _activity_line(self, event: dict) -> str:
"""Một dòng hoạt động: dấu ✓/!/✗ theo kết quả, tên việc, và thời gian tương đối."""
ok = event.get("ok", True)
if ok:
mark = f"<span style='color:{DOT_GREEN};'>✓</span>"
@@ -257,6 +288,11 @@ class OverviewTab(QWidget):
return f"{mark} {name}{suffix}"
def _refresh_usage_cards(self) -> None:
"""Cập nhật các thẻ token và chi phí.
Đồng bộ bảng đơn giá trước rồi mới tính tiền, để con số chi phí luôn khớp
với bảng giá người dùng vừa sửa.
"""
from ....core import model_pricing as mp
mp.sync_to_usage(self.ctx.config) # cost total comes straight from the price table
pricing = {**ut.DEFAULT_PRICING, **(self.ctx.config.data.get("usage") or {})}
@@ -297,6 +333,7 @@ class OverviewTab(QWidget):
self.audit_lbl.setText(tr("monitoring.overview_no_activity"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho mọi tiêu đề mục và nhãn."""
self.usage_group.setTitle(tr("monitoring.overview_usage_title").upper().replace("&", "&&"))
self.budget_card.apply_btn.setToolTip(tr("usage.budget_apply_tooltip"))
self.budget_card.budget_spin.setToolTip(tr("usage.budget_spin_tooltip"))
@@ -19,7 +19,13 @@ from ....ui.icons import icon
class PricingPanel(QGroupBox):
"""Bảng đơn giá model trong màn Giám sát: nhập, xuất, tự dò và sửa tay.
Đơn giá ở đây là thứ Dashboard dùng để quy token ra tiền, nên sửa ở đây là
mọi con số chi phí trong app đổi theo.
"""
def __init__(self, ctx, on_status_message: Callable[[str], None]):
"""Bảng đơn giá theo model, sửa được ngay tại chỗ."""
super().__init__()
self.ctx = ctx
self._on_status_message = on_status_message
@@ -68,6 +74,7 @@ class PricingPanel(QGroupBox):
self._reload_table()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên cột."""
self.setTitle(tr("monitoring.pricing_title").upper())
self.ccy_lbl.setText(tr("monitoring.pricing_currency"))
self.import_btn.setText(tr("monitoring.pricing_import"))
@@ -81,6 +88,7 @@ class PricingPanel(QGroupBox):
tr("monitoring.pricing_col_output")])
def _reload_table(self, *_a) -> None:
"""Nạp lại toàn bộ bảng đơn giá từ cấu hình."""
from ....core import model_pricing as mp
to_ccy = self.ccy.currentData() or "USD"
entries = mp.list_entries(self.ctx.config)
@@ -95,6 +103,7 @@ class PricingPanel(QGroupBox):
self.table.setItem(r, c, QTableWidgetItem(str(v)))
def _import_pricing(self) -> None:
"""Nhập đơn giá từ file Excel/CSV, gộp vào bảng hiện có."""
from PySide6.QtWidgets import QFileDialog, QMessageBox
from ....core import model_pricing as mp
@@ -117,6 +126,7 @@ class PricingPanel(QGroupBox):
self._on_status_message(tr("monitoring.pricing_imported", n=len(imported)))
def _export_pricing(self) -> None:
"""Xuất file mẫu để điền đơn giá rồi nhập ngược lại."""
from PySide6.QtWidgets import QFileDialog
from ....core import model_pricing as mp
@@ -128,6 +138,7 @@ class PricingPanel(QGroupBox):
self._on_status_message(tr("monitoring.pricing_exported"))
def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog
from ....core import model_pricing as mp
@@ -143,6 +154,7 @@ class PricingPanel(QGroupBox):
self._reload_table()
def _autolink_pricing(self) -> None:
"""Nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá, chạy ở luồng nền."""
from ....core import model_pricing as mp
from ....core.worker import AgentWorker
if self._worker is not None:
@@ -152,9 +164,11 @@ class PricingPanel(QGroupBox):
ccy = self.ccy.currentData() or "USD"
def job(_w):
"""Chạy nền: nhờ AI dò đơn giá cho các model đang dùng mà chưa có giá."""
return {"entries": mp.auto_link(ctx, default_ccy=ccy)}
def done(r):
"""Đổ đơn giá vừa dò được vào bảng và lưu lại."""
self._worker = None
self.link_btn.setEnabled(True)
self.ctx.save()
@@ -162,6 +176,7 @@ class PricingPanel(QGroupBox):
self._on_status_message(tr("monitoring.pricing_linked", n=len(r.get("entries", []))))
def failed(_e):
"""Dò giá lỗi: chỉ mở khoá lại nút, giữ nguyên bảng đang có."""
self._worker = None
self.link_btn.setEnabled(True)
@@ -172,6 +187,7 @@ class PricingPanel(QGroupBox):
w.start()
def _delete_pricing_row(self) -> None:
"""Xoá dòng đơn giá đang chọn."""
from ....core import model_pricing as mp
row = self.table.currentRow()
entries = mp.list_entries(self.ctx.config)
@@ -26,7 +26,11 @@ from .security_settings_tab import PermissionsCard
class SandboxDetailsCard(QGroupBox):
"""Thẻ "Trạng thái sandbox": backend cách ly đang dùng, giới hạn tài nguyên và
trạng thái chặn mạng.
"""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thông tin sandbox: cơ chế cách ly đang dùng và các giới hạn của nó."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
@@ -77,6 +81,7 @@ class SandboxDetailsCard(QGroupBox):
detail_lay.addWidget(self.permissions_card)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def _sync_more_label(self, *_a) -> None:
@@ -85,6 +90,7 @@ class SandboxDetailsCard(QGroupBox):
self.more_btn.setText(("▾ " if open_ else "▸ ") + tr("monitoring.overview_sbx_detail"))
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_sandbox_details_title").upper().replace("&", "&&"))
self.id_lbl.setText(tr("monitoring.overview_sandbox_id"))
self.status_lbl.setText(tr("monitoring.overview_status"))
@@ -96,6 +102,7 @@ class SandboxDetailsCard(QGroupBox):
self._sync_more_label()
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng trạng thái."""
sec = self._ctx.config.agent_security
net_blocked = bool(sec.get("block_network"))
@@ -15,7 +15,13 @@ from ..shared.filter_scaffold import build_filter_scaffold
class SecurityEventsTab(QWidget):
"""Tab "Sự kiện bảo mật": các lượt bị chặn hoặc phải xin phê duyệt."""
def __init__(self, ctx, on_refresh_all: Callable[[], None]):
"""Tab Sự kiện bảo mật.
Bỏ cột Kết quả (mọi dòng đều là bị chặn) và thay bằng cột Hành động có tô
màu theo mức nghiêm trọng.
"""
super().__init__()
self._ctx = ctx
self._ai_state: dict = {}
@@ -34,9 +40,11 @@ class SecurityEventsTab(QWidget):
self.detail_panel = parts["detail_panel"]
def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng."""
self.table.set_events(events)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.table.retranslate()
self.filter_edit.setPlaceholderText(tr("monitoring.filter_placeholder"))
self.detail_panel.retranslate()
@@ -44,4 +52,5 @@ class SecurityEventsTab(QWidget):
self.title_refresh_btn.setText(tr("monitoring.refresh"))
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
start_ai_filter(self._ctx, search, ai_btn, self._ai_state)
@@ -20,7 +20,9 @@ from ..shared.open_settings import open_settings_and_notify
class PermissionsCard(QGroupBox):
"""Thẻ "Quyền": agent đang được phép làm gì với tệp, mạng và lệnh shell."""
def __init__(self, ctx, on_settings_changed: Callable[[], None]):
"""Thẻ thiết lập quyền: agent được đụng tới gì mà không phải hỏi."""
super().__init__()
self._ctx = ctx
self._on_settings_changed = on_settings_changed
@@ -38,9 +40,11 @@ class PermissionsCard(QGroupBox):
perm_lay.addWidget(self.edit_btn, 0, Qt.AlignLeft)
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt tới nhóm an toàn, đóng xong thì làm mới thẻ."""
open_settings_and_notify(self._ctx, self, self._on_settings_changed)
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn."""
self.setTitle(tr("monitoring.overview_permissions_title").upper())
self.fs_lbl.setText(tr("monitoring.overview_perm_fs"))
self.fs_val.setText(tr("monitoring.overview_perm_fs_value"))
@@ -52,6 +56,7 @@ class PermissionsCard(QGroupBox):
self.edit_btn.setText(tr("monitoring.overview_edit"))
def refresh(self) -> None:
"""Đọc lại cấu hình an toàn và cập nhật các dòng quyền."""
net_blocked = bool(self._ctx.config.agent_security.get("block_network"))
self.network_val.setText(
tr("monitoring.overview_perm_network_blocked") if net_blocked
@@ -43,6 +43,7 @@ _TOOL_ICON_NAME = {
def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
"""Vẽ icon tròn cho một thẻ công cụ."""
pm = QPixmap(size, size)
pm.fill(Qt.transparent)
p = QPainter(pm)
@@ -59,6 +60,7 @@ def _tool_icon_pixmap(name: str, size: int = 28) -> QPixmap:
def _clear_flow(flow: FlowLayout) -> None:
"""Xoá sạch mọi widget trong một ``FlowLayout`` trước khi dựng lại."""
while flow.count():
item = flow.takeAt(0)
w = item.widget()
@@ -67,7 +69,13 @@ def _clear_flow(flow: FlowLayout) -> None:
class ToolsAdminTab(QWidget):
"""Tab "Công cụ" trong màn Giám sát: bật/tắt từng tool dựng sẵn và tool MCP.
Tắt một tool ở đây là agent không còn được gọi nó nữa — danh sách tắt lưu
trong cấu hình dưới dạng ``tools_disabled``.
"""
def __init__(self, ctx: AppContext):
"""Tab quản trị tool, chia thành các tab con theo nhóm tool."""
super().__init__()
self.ctx = ctx
root = QVBoxLayout(self)
@@ -133,12 +141,14 @@ class ToolsAdminTab(QWidget):
# ---- built-in tools card grid ---------------------------------------------
def refresh(self) -> None:
"""Vẽ lại toàn bộ lưới thẻ công cụ theo cấu hình mới nhất."""
disabled = set(self.ctx.config.tools_disabled)
_clear_flow(self._tool_flow)
for spec in TOOL_SPECS:
self._tool_flow.addWidget(self._tool_card(spec, spec.name not in disabled))
def _tool_card(self, spec, enabled: bool) -> QWidget:
"""Dựng một thẻ công cụ: icon, tên, mô tả và công tắc bật/tắt."""
card = QFrame()
card.setFrameShape(QFrame.NoFrame)
style_card(card)
@@ -190,6 +200,7 @@ class ToolsAdminTab(QWidget):
return card
def _toggle_builtin(self, name: str, enabled: bool) -> None:
"""Bật/tắt một tool dựng sẵn và ghi ngay vào cấu hình."""
self.ctx.config.set_tool_enabled(name, enabled)
# For fetch_url, the Enabled toggle also governs the runtime web-access
# gate (agent_security.allow_url_fetch) — one control for the capability.
@@ -209,17 +220,20 @@ class ToolsAdminTab(QWidget):
return
def job(worker):
"""Chạy nền: chẩn đoán kết nối Internet và tình trạng chứng chỉ TLS."""
from ....core import tls_trust
ok, message = tls_trust.diagnose_internet()
return {"ok": ok, "message": message}
def done(result):
"""Hiện kết quả chẩn đoán, tô xanh/đỏ theo kết quả."""
ok = result.get("ok")
self.test_internet_status.setText(result.get("message", ""))
self.test_internet_status.setStyleSheet("color: #090;" if ok else "color: #c00;")
self.test_internet_btn.setEnabled(True)
def failed(e):
"""Chẩn đoán ném lỗi: hiện lỗi bằng màu đỏ."""
self.test_internet_status.setText(str(e))
self.test_internet_status.setStyleSheet("color: #c00;")
self.test_internet_btn.setEnabled(True)
@@ -235,6 +249,7 @@ class ToolsAdminTab(QWidget):
# ---- i18n -----------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới thẻ."""
self.subtabs.setTabText(0, tr("tools_admin.subtab_tool"))
self.subtabs.setTabText(1, tr("tools_admin.subtab_connector"))
self._hint.setText(tr("tools_admin.hint"))
@@ -41,6 +41,12 @@ class AiTaskCreatorDialog(QDialog):
description, or 📥 Import from a filled Excel/CSV/JSON file."""
def __init__(self, ctx: AppContext, parent=None):
"""Hộp thoại tạo task bằng AI hoặc nhập từ tệp.
Nhớ tab nào đang cấp danh sách task cho nút Đồng ý — hai tab cùng sinh ra
danh sách, không phân biệt thì bấm Đồng ý có thể tạo nhầm danh sách của tab
kia.
"""
super().__init__(parent)
self.ctx = ctx
self._planner = AiTaskPlannerService(provider_factory=ctx.build_active_provider)
@@ -82,6 +88,7 @@ class AiTaskCreatorDialog(QDialog):
# ---- AI-gen tab -------------------------------------------------------
def _build_ai_gen_page(self) -> QWidget:
"""Dựng tab "Sinh bằng AI": ô mô tả, tệp/link đính kèm và nút sinh."""
ai_page = QWidget()
al = QVBoxLayout(ai_page)
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
@@ -117,6 +124,7 @@ class AiTaskCreatorDialog(QDialog):
return ai_page
def _ai_pick_files(self) -> None:
"""Chọn tệp đính kèm làm ngữ cảnh cho AI lập kế hoạch."""
from PySide6.QtWidgets import QFileDialog
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
@@ -125,12 +133,15 @@ class AiTaskCreatorDialog(QDialog):
self.ai_files_edit.setText("; ".join(existing + files))
def _attached_files(self) -> List[str]:
"""Danh sách đường dẫn tệp đã nhập, tách theo dấu ``;``."""
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
def _attached_links(self) -> List[str]:
"""Danh sách link đã nhập, tách theo dấu ``;``."""
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
def _generate(self) -> None:
"""Nhờ AI tách mô tả thành nhiều task; đang chạy dở thì bỏ qua."""
description = self.desc_edit.toPlainText().strip()
if not description or self._worker is not None:
return
@@ -139,6 +150,7 @@ class AiTaskCreatorDialog(QDialog):
self.gen_btn.setText(tr("schedtask.ai_generating"))
def job(worker: AgentWorker):
"""Chạy nền: ghép mô tả với danh sách tệp/link rồi gọi bộ lập kế hoạch."""
full_desc = description
if files or links:
attach_note = "; ".join(files + links)
@@ -154,6 +166,7 @@ class AiTaskCreatorDialog(QDialog):
w.start()
def _on_planned(self, result: dict) -> None:
"""Có kết quả: đổ danh sách task đề xuất ra bảng để người dùng xem lại."""
self._worker = None
self.gen_btn.setEnabled(True)
self.gen_btn.setText(tr("schedtask.ai_generate"))
@@ -172,6 +185,7 @@ class AiTaskCreatorDialog(QDialog):
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._ai_planned))
def _on_failed(self, err: str) -> None:
"""Lập kế hoạch lỗi: trả nút về trạng thái bấm được và hiện lý do."""
self._worker = None
self.gen_btn.setEnabled(True)
self.gen_btn.setText(tr("schedtask.ai_generate"))
@@ -179,12 +193,14 @@ class AiTaskCreatorDialog(QDialog):
# ---- Import tab ---------------------------------------------------------
def _on_import_tasks_changed(self, has_tasks: bool) -> None:
"""Tab Nhập có task thì chuyển nguồn dữ liệu sang đó và mở nút OK."""
if has_tasks:
self._active_source = "import"
self.buttons.button(QDialogButtonBox.Ok).setEnabled(has_tasks)
# ---- confirm ------------------------------------------------------------
def _confirm(self) -> None:
"""Chốt: lấy task từ nguồn đang hoạt động (AI hoặc Nhập) và gắn project cho chúng."""
planned = self.import_panel.planned if self._active_source == "import" else self._ai_planned
project_id = self.workspace_combo.currentData() or ""
for t in planned:
@@ -33,6 +33,7 @@ class _DropZone(QLabel):
file_dropped = Signal(str)
def __init__(self):
"""Vùng kéo–thả tệp danh sách task."""
super().__init__()
from PySide6.QtCore import Qt
@@ -46,12 +47,14 @@ class _DropZone(QLabel):
self.setAcceptDrops(True)
def dragEnterEvent(self, event): # noqa: N802
"""Chỉ nhận tệp có đuôi hợp lệ."""
urls = event.mimeData().urls()
if urls and urls[0].toLocalFile().lower().endswith(
(".xlsx", ".xlsm", ".xls", ".csv", ".json")):
event.acceptProposedAction()
def dropEvent(self, event): # noqa: N802
"""Thả tệp: phát đường dẫn lên để panel đọc và dựng danh sách task."""
urls = event.mimeData().urls()
if urls:
self.file_dropped.emit(urls[0].toLocalFile())
@@ -70,6 +73,7 @@ class ImportTaskPanel(QWidget):
tasks_changed = Signal(bool) # True when the current preview has >=1 valid task
def __init__(self, planner: AiTaskPlannerService, parent=None):
"""Panel nhập task từ tệp: xuất mẫu, chọn tệp, xem trước rồi mới tạo."""
super().__init__(parent)
self._planner = planner
self.planned: List[dict] = []
@@ -96,6 +100,7 @@ class ImportTaskPanel(QWidget):
il.addWidget(self.preview, 1)
def _export_template(self) -> None:
"""Xuất file mẫu Excel để người dùng điền danh sách task rồi nhập lại."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.core.task_excel import export_template
@@ -112,6 +117,7 @@ class ImportTaskPanel(QWidget):
QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc))
def _pick_import_file(self) -> None:
"""Chọn file danh sách task để nhập."""
from PySide6.QtWidgets import QFileDialog
from cowork_local.core.task_import import IMPORT_FILTER
@@ -122,6 +128,9 @@ class ImportTaskPanel(QWidget):
self._load_import_file(path)
def _load_import_file(self, path: str) -> None:
"""Đọc file và dựng danh sách task xem trước; file sai khuôn thì hiện lý do cụ
thể chứ không im lặng bỏ qua.
"""
try:
self.planned = self._planner.import_file(path)
except ValueError as exc:
@@ -28,10 +28,12 @@ _WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun")
class _DayCell(QFrame):
"""Một ô ngày trên lưới lịch: số ngày, nút thêm task, và danh sách task của ngày đó."""
add_requested = Signal(str) # "YYYY-MM-DD"
task_clicked = Signal(str) # task_id
def __init__(self):
"""Một ô ngày trên lịch, chứa các task đến hạn hôm đó."""
super().__init__()
self.setObjectName("dayCell")
self.setFrameShape(QFrame.StyledPanel)
@@ -56,6 +58,12 @@ class _DayCell(QFrame):
def set_day(self, d: date, tasks: List[dict], dim: bool,
today: bool = False, weekend: bool = False) -> None:
"""Vẽ lại ô cho một ngày cụ thể.
Chỉ "hôm nay" được nền tô đậm kèm viền nhấn; cuối tuần chỉ đổi nền chìm.
Nhờ vậy mắt bắt vào hôm nay trước, còn khối cuối tuần chỉ hiện ra khi
người dùng quét cả tháng. ``dim`` làm mờ ngày thuộc tháng khác.
"""
self._date_str = d.isoformat()
self.date_lbl.setText(str(d.day))
p = current_palette()
@@ -81,16 +89,23 @@ class _DayCell(QFrame):
self.list.addItem(item)
def _on_item_clicked(self, item: QListWidgetItem) -> None:
"""Bấm vào một task trong ô: phát id lên để lớp trên mở trình sửa."""
tid = item.data(Qt.UserRole)
if tid:
self.task_clicked.emit(tid)
class CalendarView(QWidget):
"""Khung nhìn Lịch của màn Lịch trình: xem theo tuần, tháng hoặc năm.
Widget này chỉ VẼ và phát tín hiệu; việc tạo/sửa task do vỏ
``ScheduleTaskTab`` làm, vì Kanban cũng cần đúng hành vi đó.
"""
add_task_on_date = Signal(str) # "YYYY-MM-DD"
edit_task = Signal(str) # task_id
def __init__(self):
"""Lịch xem task theo tháng/tuần/ngày, neo vào hôm nay."""
super().__init__()
self.granularity = "month"
self.anchor = date.today()
@@ -131,6 +146,7 @@ class CalendarView(QWidget):
self._retranslate()
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn rồi vẽ lại lưới."""
self.today_btn.setText(tr("schedtask.cal_today"))
self.prev_btn.setToolTip(tr("schedtask.cal_prev"))
self.next_btn.setToolTip(tr("schedtask.cal_next"))
@@ -140,6 +156,7 @@ class CalendarView(QWidget):
# ---- public ------------------------------------------------------
def set_tasks(self, tasks: List[dict]) -> None:
"""Nhận danh sách task mới và vẽ lại toàn bộ khung nhìn."""
self._tasks = tasks
self._render()
@@ -157,14 +174,17 @@ class CalendarView(QWidget):
# ---- navigation ---------------------------------------------------
def _shift(self, direction: int) -> None:
"""Lùi/tiến một kỳ theo đúng độ mịn đang chọn (tuần, tháng hay năm)."""
self.anchor = shift_period(self.anchor, self.granularity, direction)
self._render()
def _go_today(self) -> None:
"""Nhảy về kỳ chứa ngày hôm nay."""
self.anchor = date.today()
self._render()
def _on_granularity_changed(self) -> None:
"""Đổi độ mịn tuần/tháng/năm và vẽ lại."""
data = self.granularity_combo.currentData()
if data:
self.granularity = data
@@ -172,6 +192,11 @@ class CalendarView(QWidget):
# ---- rendering ------------------------------------------------------
def _clear_grid(self) -> None:
"""Xoá sạch lưới trước khi vẽ lại.
Dùng ``deleteLater`` chứ không bỏ tham chiếu: widget Qt còn đang trong
hàng đợi sự kiện, xoá ngay có thể làm vỡ lúc đang xử lý sự kiện chuột.
"""
while self._grid.count():
item = self._grid.takeAt(0)
w = item.widget()
@@ -179,6 +204,7 @@ class CalendarView(QWidget):
w.deleteLater()
def _render(self) -> None:
"""Vẽ lại lưới theo độ mịn đang chọn: tuần, tháng (kèm ngày tràn) hoặc năm."""
self._update_period_label()
self._clear_grid()
by_date = group_tasks_by_date(self._tasks)
@@ -191,6 +217,11 @@ class CalendarView(QWidget):
def _render_days(self, days: List[date], by_date: Dict[str, List[dict]],
mark_month: Optional[int] = None) -> None:
"""Vẽ lưới 7 cột: hàng đầu là tên thứ, các hàng sau là ô ngày.
``mark_month`` là tháng "chính": ngày thuộc tháng khác bị làm mờ. Cột 5 và
6 là thứ Bảy và Chủ nhật vì ``_WEEKDAY_KEYS`` bắt đầu từ thứ Hai.
"""
for col, key in enumerate(_WEEKDAY_KEYS):
lbl = QLabel(tr(f"schedtask.cal_weekday.{key}"))
lbl.setStyleSheet("font-weight:600;")
@@ -210,6 +241,7 @@ class CalendarView(QWidget):
self._grid.addWidget(cell, r, c)
def _render_year(self, by_date: Dict[str, List[dict]]) -> None:
"""Xem theo năm: danh sách 12 tháng kèm số task, bấm vào là mở tháng đó."""
counts = month_task_counts(by_date, self.anchor.year)
lst = QListWidget()
for m in range(1, 13):
@@ -223,6 +255,7 @@ class CalendarView(QWidget):
self._grid.addWidget(lst, 0, 0)
def _update_period_label(self) -> None:
"""Cập nhật nhãn kỳ đang xem: khoảng ngày (tuần), năm, hoặc YYYY-MM (tháng)."""
if self.granularity == "week":
days = week_days(self.anchor)
self.period_lbl.setText(f"{days[0].isoformat()} - {days[-1].isoformat()}")
@@ -52,6 +52,11 @@ class _KanbanColumn(QListWidget):
task_dropped = Signal(str, str) # task_id, new_status
def __init__(self, status: str):
"""Một cột Kanban ứng với một trạng thái task.
Cho chọn nhiều thẻ trong CÙNG một cột (Shift/Ctrl) để chuột phải xoá hàng
loạt, thay vì xoá từng cái.
"""
super().__init__()
self.status = status
self.setDragDropMode(QAbstractItemView.DragDrop)
@@ -68,6 +73,7 @@ class _KanbanColumn(QListWidget):
self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize
def dropEvent(self, event): # noqa: N802
"""Thả một thẻ từ lane khác sang: báo lên bảng để đổi trạng thái task."""
source = event.source()
if isinstance(source, _KanbanColumn) and source is not self:
item = source.currentItem()
@@ -106,6 +112,11 @@ class KanbanBoardWidget(QWidget):
def __init__(self, ctx, tasks_dir=None, scheduler=None,
service: Optional[TaskApplicationService] = None, parent=None):
"""Bảng Kanban các task.
``service`` để None thì tự dựng một cái từ kho task; ``scheduler`` để None
thì bảng chỉ đọc/ghi task chứ không chạy được cái nào.
"""
super().__init__(parent)
self.ctx = ctx
self._tasks_dir = tasks_dir
@@ -148,16 +159,23 @@ class KanbanBoardWidget(QWidget):
scroll.viewport().installEventFilter(self)
def retranslate(self) -> None:
"""Áp lại tooltip của từng lane theo ngôn ngữ đang chọn."""
for status, col in self.columns.items():
col.setToolTip(tr(f"schedtask.col_tip.{status}"))
# ---- lane widths ------------------------------------------------------
def eventFilter(self, obj, event): # noqa: N802
"""Vùng cuộn đổi kích thước thì chia lại bề rộng cho 7 lane."""
if obj is self._board_scroll.viewport() and event.type() == QEvent.Resize:
self._fit_lanes()
return super().eventFilter(obj, event)
def _fit_lanes(self) -> None:
"""Chia bề rộng cho 7 lane sao cho tất cả vừa một màn.
Có sàn tối thiểu tính theo bề rộng ký tự, để tiêu đề lane không bị cắt cụt
khi cửa sổ hẹp.
"""
floor = self.fontMetrics().averageCharWidth() * self._LANE_FLOOR_CH + 24
for col in self.columns.values():
if col.minimumWidth() != floor:
@@ -165,6 +183,7 @@ class KanbanBoardWidget(QWidget):
# ---- rendering ----------------------------------------------------------
def _card_text(self, t: dict) -> str:
"""Chuỗi hiển thị trên một thẻ task: tiêu đề kèm mốc thời gian/độ ưu tiên."""
prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "")
ai = "[AI] " if t.get("is_ai_generated") else ""
sched = t.get("schedule", {})
@@ -218,6 +237,7 @@ class KanbanBoardWidget(QWidget):
# ---- actions --------------------------------------------------------
def _on_double_click(self, item: QListWidgetItem) -> None:
"""Bấm đúp một thẻ: mở trình sửa task đó."""
tid = item.data(Qt.UserRole)
if tid:
self.edit_requested.emit(tid)
@@ -252,6 +272,10 @@ class KanbanBoardWidget(QWidget):
return len(selected) > 1 and item in selected
def _context_menu(self, col: _KanbanColumn, pos) -> None:
"""Menu chuột phải trên một thẻ: chạy ngay, xem log, tạo task tiếp theo, xoá.
Đang chọn nhiều thẻ thì chuyển sang menu xoá hàng loạt.
"""
item = col.itemAt(pos)
if item is None or not item.data(Qt.UserRole):
return
@@ -300,6 +324,7 @@ class KanbanBoardWidget(QWidget):
self.refresh()
def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None:
"""Menu xoá hàng loạt khi đang chọn nhiều thẻ."""
menu = QMenu(col)
del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected)))
chosen = menu.exec(col.viewport().mapToGlobal(pos))
@@ -320,6 +345,7 @@ class KanbanBoardWidget(QWidget):
return True
def _emit_run_now_message(self, result, title: str = "") -> None:
"""Báo kết quả của lệnh "chạy ngay" ra thanh trạng thái."""
if result is None:
return
if result.ok:
@@ -330,6 +356,7 @@ class KanbanBoardWidget(QWidget):
self.status_message.emit(tr("schedtask.msg_no_scheduler"))
def _view_logs(self, task: dict) -> None:
"""Mở thư mục hiện vật chứa log của các lượt chạy task này."""
from cowork_local.core.tasks import ARTIFACTS_DIR
run_id = task.get("logs", {}).get("last_run_id")
@@ -18,6 +18,7 @@ class RunHistoryDialog(QDialog):
double-click a row to open that run's artifact folder."""
def __init__(self, task: dict, parent=None):
"""Hộp thoại xem lịch sử các lần chạy của một task."""
super().__init__(parent)
self._task = task
self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}")
@@ -58,6 +59,7 @@ class RunHistoryDialog(QDialog):
root.addWidget(buttons)
def _open_artifact(self, item: QTableWidgetItem) -> None:
"""Bấm một dòng: mở thư mục hiện vật của lượt chạy đó."""
from cowork_local.core.tasks import ARTIFACTS_DIR
first = self.table.item(item.row(), 0)
@@ -33,9 +33,22 @@ _VIEWS = ("kanban", "calendar")
class ScheduleTaskTab(QWidget):
"""Màn Lịch trình: phần vỏ ghép bảng Kanban và lịch tháng.
Vỏ giữ ba việc mà cả hai khung nhìn đều cần: thanh tiêu đề, nút chuyển
khung nhìn, và mở hộp thoại sửa task. Việc sửa task nằm ở đây (không ở
từng widget con) vì Kanban lẫn Lịch đều cần đúng một hành vi "mở trình
sửa cho task id này" — cả hai phát tín hiệu, vỏ mở hộp thoại.
"""
status_message = Signal(str)
def __init__(self, ctx: AppContext, scheduler=None, tasks_dir: Optional[Path] = None):
"""Vỏ màn Lịch task, ghép bảng Kanban và lịch.
``tasks_dir`` để None thì dùng thư mục mặc định. Cho phép truyền vào là điểm
mới của R08-T11 — bản gộp cũ viết cứng, nên không test được nếu không đụng
thư mục cấu hình thật của người dùng.
"""
super().__init__()
self.ctx = ctx
self.scheduler = scheduler # TaskScheduler (may be None in tests)
@@ -110,6 +123,7 @@ class ScheduleTaskTab(QWidget):
# ---- i18n ------------------------------------------------------------
def _retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, nút và tên hai khung nhìn."""
self._title.setText(tr("schedtask.title"))
self.add_btn.setText(tr("schedtask.add_btn"))
self.add_btn.setToolTip(tr("schedtask.add_tooltip"))
@@ -122,24 +136,33 @@ class ScheduleTaskTab(QWidget):
# ---- Kanban / Calendar view switch --------------------------------
def _on_view_changed(self) -> None:
"""Đổi khung nhìn Kanban ⇄ Lịch theo tab đang chọn."""
self._view_stack.setCurrentIndex(self.view_tabs.currentIndex())
def _on_counts_changed(self, counts: dict) -> None:
"""Cập nhật dòng tóm tắt số task theo trạng thái.
Chỉ liệt kê trạng thái có task; đặt luôn tooltip để khi thanh bị co hẹp
người dùng vẫn đọc được đầy đủ.
"""
summary = " ".join(
f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])
self.counts_lbl.setText(summary)
self.counts_lbl.setToolTip(summary) # full text stays reachable if clipped
def refresh(self) -> None:
"""Đọc lại toàn bộ task và vẽ lại cả Kanban lẫn Lịch."""
all_tasks = self.kanban.refresh()
self.calendar.set_tasks(all_tasks)
# ---- task creation / editing (shared by Kanban + Calendar) -----------
def _save_and_refresh(self, task: dict) -> None:
"""Ghi task xuống đĩa rồi vẽ lại màn hình."""
save_task(task, self._tasks_dir)
self.refresh()
def _add_task(self) -> None:
"""Mở trình sửa để tạo task mới; chỉ lưu khi người dùng bấm xác nhận."""
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
dlg = TaskEditorDialog(None, list_tasks(self._tasks_dir), self, ctx=self.ctx)
@@ -148,6 +171,9 @@ class ScheduleTaskTab(QWidget):
self.status_message.emit(tr("schedtask.msg_created"))
def _edit_task(self, task_id: str) -> None:
"""Mở trình sửa cho một task đã có. Không tìm thấy id thì bỏ qua lặng lẽ
(task có thể vừa bị xoá ở khung nhìn khác).
"""
from cowork_local.ui.task_editor_dialog import TaskEditorDialog
task = load_task(task_id, self._tasks_dir)
@@ -170,6 +196,7 @@ class ScheduleTaskTab(QWidget):
# ---- AI create ----------------------------------------------------------
def _ai_create(self) -> None:
"""Mở hộp thoại nhờ AI lập kế hoạch, rồi lưu toàn bộ task nó sinh ra."""
from cowork_local.presentation.scheduling.ai_task_creator_dialog import (
AiTaskCreatorDialog,
)
@@ -17,7 +17,11 @@ from ...ui.widgets import SegmentedControl, ToggleSwitch
class GeneralSettingsWidget(QWidget):
"""Nhóm "Chung" trong Cài đặt: ngôn ngữ, giao diện, khởi động cùng hệ thống,
thu về khay.
"""
def __init__(self, ctx, parent=None):
"""Nhóm Cài đặt chung: ngôn ngữ, giao diện, thư mục dùng chung."""
super().__init__(parent)
data = ctx.config.data
outer = QVBoxLayout(self)
@@ -58,6 +62,11 @@ class GeneralSettingsWidget(QWidget):
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
"""Ghi các lựa chọn chung vào dict cấu hình.
Không tự áp theme ở đây: ``MainWindow._open_settings`` áp lại sau khi hộp
thoại đóng, làm cả hai chỗ sẽ vẽ lại giao diện hai lần.
"""
data["language"] = self.language_combo.currentData()
# MainWindow._open_settings áp lại giao diện sau khi dialog đóng, nên
# ghi giá trị ở đây là đủ để nó có hiệu lực.
@@ -68,6 +77,7 @@ class GeneralSettingsWidget(QWidget):
def _select(combo, value: str) -> None:
"""Chọn mục mang dữ liệu ``value`` trong một combo; không có thì để nguyên."""
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -15,13 +15,18 @@ from ...i18n import tr
class ParameterSettingsWidget(QGroupBox):
"""Nhóm "Tham số" trong Cài đặt: hạn mức token cho tệp đính kèm, ngữ cảnh,
số lượt song song và các ngưỡng khác.
"""
def __init__(self, ctx, parent=None):
"""Nhóm Tham số: giới hạn token, ngưỡng rủi ro và các mức chặn của agent."""
super().__init__(tr("settings.group.parameter"), parent)
data = ctx.config.data
sec = ctx.config.agent_security
form = QFormLayout(self)
def tieu_de(key: str) -> None:
"""Thêm một dòng tiêu đề phụ vào form."""
lbl = QLabel(tr(key))
lbl.setStyleSheet("font-weight:600; margin-top:6px;")
form.addRow(lbl)
@@ -75,6 +80,10 @@ class ParameterSettingsWidget(QGroupBox):
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
"""Ghi các tham số từ form vào dict cấu hình.
Ô nhập tính bằng NGHÌN token cho dễ đọc, nên phải nhân 1000 khi lưu.
"""
att = data.setdefault("attachments", {})
att["max_tokens"] = self.attach_tokens.value() * 1000
att["max_files"] = self.attach_files.value()
@@ -90,6 +99,7 @@ class ParameterSettingsWidget(QGroupBox):
def _gioi_han(suffix: str, value: int, hi: int = 1_000_000) -> QSpinBox:
"""Ô nhập số nguyên có hậu tố đơn vị, chặn trong khoảng 0..``hi``."""
box = QSpinBox()
box.setRange(0, hi)
box.setSuffix(suffix)
@@ -24,7 +24,18 @@ from ...ui.icons import icon
class ProviderSettingsWidget(QGroupBox):
"""Nhóm "Nhà cung cấp AI" trong hộp thoại Cài đặt.
Giữ một BẢN NHÁP riêng cho từng provider (``_staging``): đổi qua lại giữa
các provider trong lúc chỉnh không được làm mất thứ vừa gõ, và chỉ khi
bấm Lưu mới ghi xuống cấu hình thật.
"""
def __init__(self, ctx, parent=None):
"""Nhóm Provider: endpoint, khoá API và model của từng provider.
Sửa trên một bản nháp (``_staging``) chứ không sửa thẳng cấu hình: người dùng
bấm Huỷ thì mọi thay đổi biến mất, kể cả khoá API vừa gõ.
"""
super().__init__(tr("settings.group.provider"), parent)
self.ctx = ctx
data = ctx.config.data
@@ -60,6 +71,7 @@ class ProviderSettingsWidget(QGroupBox):
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
"""Ghi provider đang chọn và toàn bộ bản nháp vào dict cấu hình."""
data["active_provider"] = self.provider_combo.currentData()
self._stash()
for key, staged in self._staging.items():
@@ -72,6 +84,7 @@ class ProviderSettingsWidget(QGroupBox):
# ---- bản nháp từng provider -----------------------------------------
def _stash(self) -> None:
"""Cất nội dung đang gõ vào bản nháp của provider hiện tại."""
self._staging.setdefault(self._current_key, {}).update({
"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
@@ -79,6 +92,7 @@ class ProviderSettingsWidget(QGroupBox):
})
def _on_provider_changed(self) -> None:
"""Đổi provider: cất bản nháp cũ rồi nạp bản nháp mới lên các ô nhập."""
self._stash()
self._current_key = self.provider_combo.currentData()
conf = self._staging.get(self._current_key, {})
@@ -93,6 +107,11 @@ class ProviderSettingsWidget(QGroupBox):
self.prov_status.setText("")
def _conf_hien_tai(self, provider: str) -> dict:
"""Cấu hình dùng để thử kết nối/nạp model.
Provider đang mở thì lấy thẳng từ ô nhập (kể cả thứ chưa lưu); provider
khác thì lấy từ bản nháp đã cất.
"""
if provider == self._current_key:
return {"base_url": self.prov_base.text().strip(),
"api_key": self.prov_key.text(),
@@ -104,6 +123,7 @@ class ProviderSettingsWidget(QGroupBox):
# ---- hàng model + hai nút -------------------------------------------
def _hang_model(self) -> QWidget:
"""Dựng hàng "Model": ô chọn model kèm nút nạp danh sách và nút thử kết nối."""
row = QWidget()
lay = QHBoxLayout(row)
lay.setContentsMargins(0, 0, 0, 0)
@@ -132,15 +152,22 @@ class ProviderSettingsWidget(QGroupBox):
# ---- việc chạy nền ---------------------------------------------------
def _load_models(self, provider: str) -> None:
"""Nạp danh sách model của provider ở luồng nền.
Giữ nguyên model đang chọn ở đầu danh sách, kể cả khi máy chủ không trả
về nó — người dùng có thể đang dùng một model không được liệt kê.
"""
conf = self._conf_hien_tai(provider)
combo, status = self.prov_model, self.prov_status
def job(worker):
"""Chạy nền: hỏi provider danh sách model."""
from ...providers import build_provider
prov = build_provider(provider, conf)
return {"models": prov.list_models(), "error": getattr(prov, "last_error", "")}
def done(result):
"""Đổ danh sách vào ô chọn, giữ model đang chọn ở đầu."""
models = result.get("models") or []
current = combo.currentText().strip()
combo.clear()
@@ -163,25 +190,34 @@ class ProviderSettingsWidget(QGroupBox):
tr("settings.loading_models"))
def _test_connection(self, provider: str) -> None:
"""Thử kết nối tới provider ở luồng nền, tô xanh/đỏ kết quả."""
conf = self._conf_hien_tai(provider)
status = self.prov_status
def job(worker):
"""Chạy nền: gọi thử provider để xác nhận kết nối."""
from ...providers import build_provider
ok, message = build_provider(provider, conf).test_connection()
return {"ok": ok, "message": message}
def done(result):
"""Hiện kết quả thử, tô xanh khi thành công và đỏ khi thất bại."""
status.setText(result.get("message", ""))
status.setStyleSheet("color: #090;" if result.get("ok") else "color: #c00;")
def failed(e):
"""Thử kết nối ném lỗi: hiện lỗi bằng màu đỏ."""
status.setText(str(e))
status.setStyleSheet("color: #c00;")
self._chay_nen(job, done, failed, tr("settings.testing_connection"))
def _chay_nen(self, job, done, failed, dang_lam: str) -> None:
"""Chạy một việc ở luồng nền và hiện dòng trạng thái trong lúc chờ.
Giữ tham chiếu tới worker trong ``self._workers``: để nó bị thu gom giữa
chừng là luồng chết lặng lẽ, không lỗi, không kết quả.
"""
w = AgentWorker(job)
w.finished_ok.connect(done)
w.failed.connect(failed)
@@ -192,6 +228,12 @@ class ProviderSettingsWidget(QGroupBox):
def _model_combo(value: str) -> QComboBox:
"""Ô chọn model cho phép gõ tay.
Ép co lại theo bề rộng được cấp: mặc định Qt cho combo rộng bằng mục dài
nhất, mà id model thì rất dài — hàng đó sẽ tràn ra ngoài hộp thoại và đẻ ra
thanh cuộn ngang (tệ hơn ở màn 125%/150%). Phần bung ra để popup lo.
"""
combo = QComboBox()
combo.setEditable(True)
# Mặc định combo rộng bằng mục dài nhất; id model thì dài, nên hàng này
@@ -207,6 +249,7 @@ def _model_combo(value: str) -> QComboBox:
def _select(combo: QComboBox, value: str) -> None:
"""Chọn mục mang dữ liệu ``value`` trong một combo; không có thì để nguyên."""
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
@@ -24,7 +24,11 @@ POLICY_KEYS = (("quality", "routing.policy_quality"), ("cost", "routing.policy_c
class RoutingSettingsWidget(QGroupBox):
"""Nhóm "Định tuyến model" trong Cài đặt: chế độ, chính sách, ngưỡng đổi và
cấu hình trọng tài chấm điểm.
"""
def __init__(self, ctx, parent=None):
"""Nhóm Định tuyến: chế độ tự chọn model và các tham số thăm dò."""
super().__init__(tr("routing.settings_group"), parent)
self.ctx = ctx
routing = ctx.config.routing
@@ -82,6 +86,7 @@ class RoutingSettingsWidget(QGroupBox):
# ---- lưu ------------------------------------------------------------
def apply_to(self, data: dict) -> None:
"""Ghi cấu hình định tuyến từ form vào dict cấu hình."""
r = data.setdefault("routing", {})
r["switch_mode"] = self.mode.currentData()
r["policy"] = self.policy.currentData()
@@ -114,6 +119,7 @@ class RoutingSettingsWidget(QGroupBox):
def _select(combo: QComboBox, value: str) -> None:
"""Chọn mục mang dữ liệu ``value`` trong một combo; không có thì để nguyên."""
i = combo.findData(value)
if i >= 0:
combo.setCurrentIndex(i)
+4
View File
@@ -34,6 +34,10 @@ def build_secret_store():
def build_config(path: Path | None = None) -> JsonConfigRepository:
"""Dựng repository cấu hình đã nối sẵn kho bí mật của hệ điều hành.
Đây là Composition Root: chỗ DUY NHẤT quyết định app chạy bằng mảnh nào.
"""
return JsonConfigRepository.load(path, secrets=build_secret_store())
@@ -27,13 +27,20 @@ MIN_W, MIN_H = 820, 520
class LifecycleCoordinator:
"""Vòng đời cửa sổ: canh vừa màn hình, theo dõi đổi màn hình, và quyết định
đóng cửa sổ là thoát hẳn hay thu về khay.
"""
def __init__(self, window):
"""Theo dõi màn hình cửa sổ đang nằm trên, để khôi phục đúng vị trí và tỉ lệ khi
mở lại.
"""
self.window = window
self._last_screen = None
# ---- canh theo màn hình ----------------------------------------------
def fit_to_screen(self, want_w: int, want_h: int) -> None:
"""Canh cửa sổ vừa vùng làm việc của màn hình đang chứa nó."""
w = self.window
screen = w.screen() or QGuiApplication.primaryScreen()
avail = screen.availableGeometry() if screen else None
@@ -73,6 +80,9 @@ class LifecycleCoordinator:
def _apply_minimum(self, avail) -> None:
# Kích thước tối thiểu không bao giờ được vượt quá thứ màn hình hiển
# thị nổi — nếu không thì cửa sổ không thu nhỏ vừa màn được nữa.
"""Đặt kích thước tối thiểu, nhưng không bao giờ vượt quá thứ màn hình hiển thị
nổi — vượt thì cửa sổ không thu nhỏ vừa màn được nữa.
"""
self.window.setMinimumSize(min(MIN_W, avail.width() - MARGIN),
min(MIN_H, avail.height() - MARGIN))
+24
View File
@@ -66,12 +66,18 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
#: Biểu tượng khay, hoặc None nếu máy không có khay. Vẫn giữ tên cũ vì
#: còn vài chỗ đọc thẳng self.tray; bản thân việc dựng/ẩn/thông báo đã
#: chuyển sang self._tray (TrayManager).
"""Cửa sổ chính: thanh menu trái, thanh trên, bốn trang nội dung và khay hệ thống.
Ghép từ nhiều mixin (thanh menu, bộ chọn project, thanh trên, sổ đăng ký
trang, sự kiện phiên) để mỗi phần nằm trong một file dưới trần 400 dòng.
"""
tray = property(lambda self: self._tray.icon)
# Nav rows (Dashboard/Schedule/Monitoring are lazy; Workspace is the eager home page).
_ROW_DASHBOARD, _ROW_SCHEDULE, _ROW_WORKSPACE, _ROW_MONITORING = 0, 1, 2, 3
def __init__(self, ctx: AppContext, user_name: str = ""):
"""Dựng cửa sổ chính: thanh điều hướng, các trang và biểu tượng khay."""
super().__init__()
self.ctx = ctx
self._user_name = user_name
@@ -193,6 +199,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
on_language_changed(self._retranslate)
def resizeEvent(self, event): # noqa: N802 - Qt override
"""Đổi kích thước cửa sổ thì tính lại trần bề rộng thanh menu.
Trần là một tỉ lệ của cửa sổ nên phải tính lại ở đây; tính một lần lúc dựng
sẽ đọc phải kích thước của cửa sổ chưa được cấp phát.
"""
super().resizeEvent(event)
# The rail's ceiling is a share of the window, so it moves with the
# window. Computed once at construction it was read off a not-yet-sized
@@ -204,6 +215,7 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.reposition()
def showEvent(self, event): # noqa: N802 - Qt override
"""Lần hiện đầu tiên: ghim lại trợ lý nổi cho đúng vị trí thật."""
super().showEvent(event)
if getattr(self, "help_agent", None) is not None:
self._update_dock_guard()
@@ -217,6 +229,7 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
QTimer.singleShot(3000, self._prewarm_graph)
def _prewarm_graph(self) -> None:
"""Dựng sẵn khung đồ thị GraphRAG lúc máy rảnh, để lần bấm đầu không giật."""
view = getattr(self, "structure", None)
if view is None or not hasattr(view, "prewarm"):
return
@@ -268,11 +281,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
def _show_window(self) -> None:
"""Đưa cửa sổ trở lại từ khay hệ thống và kéo lên trước."""
self.showNormal()
self.raise_()
self.activateWindow()
def _quit_app(self) -> None:
"""Thoát hẳn ứng dụng (khác với đóng cửa sổ về khay)."""
self._really_quit = True
self.close()
@@ -331,9 +346,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# panel. `want_*` stays the floor so a small screen behaves as before.
# Canh cửa sổ và tắt sạch: presentation/shell/lifecycle_coordinator.py
def _fit_to_screen(self, want_w: int, want_h: int) -> None:
"""Canh cửa sổ vừa màn hình hiện tại."""
self._life.fit_to_screen(want_w, want_h)
def _on_screen_maybe_changed(self) -> None:
"""Cửa sổ có thể đã sang màn hình khác: ghim lại trợ lý và tính lại bố cục."""
if not self._life.screen_maybe_changed():
return
if getattr(self, "help_agent", None) is not None:
@@ -342,6 +359,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
def moveEvent(self, event): # noqa: N802 - Qt override
"""Kéo cửa sổ sang màn hình khác thì vùng làm việc và tỉ lệ hiển thị có thể
khác — trợ lý nổi ghim lại, các pane quyết định lại xem còn vừa không.
"""
super().moveEvent(event)
# Dragged to another monitor: its work area (and scaling) may differ, so
# the floating assistant re-pins and the panes re-decide if they fit.
@@ -350,6 +370,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# ---- lifecycle ---------------------------------------------------
def closeEvent(self, event) -> None: # noqa: N802
"""Đóng cửa sổ: thu về khay nếu cấu hình cho chạy nền, còn không thì thoát hẳn.
Chạy nền tiếp thì task theo lịch vẫn chạy và vẫn tự lưu.
"""
if self._life.should_keep_running():
# Chạy nền tiếp: task vẫn chạy và vẫn tự lưu.
event.ignore()
+2
View File
@@ -351,11 +351,13 @@ class NavRailMixin:
self._nav_wrap.setMaximumWidth(hi)
def _on_split_moved(self, _pos: int, _index: int) -> None:
"""Người dùng kéo thanh chia: nhớ lại bề rộng thanh menu để lần mở sau giữ nguyên."""
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
def _toggle_nav(self) -> None:
"""Thu gọn / mở rộng thanh menu, nhớ bề rộng trước khi thu để khôi phục đúng."""
if not self._nav_collapsed:
self._nav_width = max(_NAV_MIN_WIDTH,
min(self._nav_max_width(), self._nav_wrap.width()))
+30 -2
View File
@@ -17,19 +17,31 @@ from ..scheduling.schedule_task_tab import ScheduleTaskTab
class PageRegistryMixin:
def _page_index(self, widget) -> int:
return self.pages.indexOf(widget)
"""Bốn màn chính của cửa sổ và cách chuyển giữa chúng.
Dashboard và Lịch dựng lười ở lần mở đầu tiên, nên MỌI đường tới một trang
phải đi qua :meth:`_goto` — đó là chỗ duy nhất gọi :meth:`_ensure_page`.
"""
# Lớp này từng khai ``_page_index`` HAI lần: bản đầu chỉ gọi
# ``self.pages.indexOf(widget)`` và bị bản phía dưới che hoàn toàn (Python
# giữ định nghĩa sau cùng), tức là mã chết. Đã gỡ bản đầu; bản còn lại nằm
# ngay dưới ``_ensure_page`` và nó BẮT BUỘC phải so sánh danh tính trước,
# vì trang dựng lười vẫn còn là widget giữ chỗ cho tới lần mở đầu tiên.
def _build_dashboard(self):
"""Dựng màn Bảng điều khiển và nối dòng trạng thái của nó vào thanh dưới."""
d = DashboardTab(self.ctx)
d.status_message.connect(self.statusBar().showMessage)
self.dashboard = d
return d
def _build_schedule(self):
"""Dựng màn Lịch trình và nối dòng trạng thái của nó vào thanh dưới."""
s = ScheduleTaskTab(self.ctx, self.task_scheduler)
s.status_message.connect(self.statusBar().showMessage)
self.schedule = s
return s
def _build_monitoring(self):
"""Dựng màn Giám sát và nối dòng trạng thái của nó vào thanh dưới."""
m = MonitoringTab(self.ctx, cowork=self.cowork, structure=self.structure,
task_scheduler=self.task_scheduler)
m.status_message.connect(self.statusBar().showMessage)
@@ -50,6 +62,12 @@ class PageRegistryMixin:
self._page_widgets[row] = real
self._built[row] = True
def _page_index(self, widget) -> int:
"""Chỉ số trang của một widget.
So sánh theo danh tính trước khi hỏi ``QStackedWidget``: trang dựng lười
vẫn còn là widget giữ chỗ cho tới lần mở đầu tiên, nên ``indexOf`` một
mình sẽ trả về sai chỗ.
"""
if widget is self.workspace:
return self._ROW_WORKSPACE
if self.dashboard is not None and widget is self.dashboard:
@@ -60,6 +78,16 @@ class PageRegistryMixin:
return self._ROW_MONITORING
return self.pages.indexOf(widget)
def _goto(self, page: int, sub) -> None:
"""Chuyển sang một trang (và một sub-tab nếu có).
Gác cổng project được đặt ở đây chứ không ở từng nơi gọi: dòng trên thanh
menu bị làm mờ thì không bấm được, nhưng ``_goto`` còn được gọi từ RECENTS
và từ lúc khôi phục phiên — trước đây nó mở thẳng sub-tab mà cổng đang
đóng, thành ra trang hiện lên nhưng dải tab vẫn giấu nó đi.
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
không theo thứ vừa được bấm.
"""
self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE:
+5
View File
@@ -33,5 +33,10 @@ class _NavItemDelegate(QStyledItemDelegate):
"""
def initStyleOption(self, option, index):
"""Ép icon của thanh menu canh trái và canh giữa theo chiều dọc.
Mặc định Qt canh giữa icon theo chiều ngang, làm các dòng menu lệch nhau khi
thanh được thu gọn.
"""
super().initStyleOption(option, index)
option.decorationAlignment = Qt.AlignLeft | Qt.AlignVCenter
+7
View File
@@ -69,6 +69,11 @@ class RailProjectMixin:
act.triggered.connect(
lambda _checked=False, row=i: self.nav_project.setCurrentIndex(row))
def _on_rail_project_pick(self, _idx: int) -> None:
"""Chọn project ở thanh menu: chuyển workspace sang project đó.
Cờ ``_syncing_rail_project`` chặn vòng lặp khi chính workspace vừa đặt lại
bộ chọn này.
"""
if self._syncing_rail_project:
return
pid = self.nav_project.currentData()
@@ -110,6 +115,7 @@ class RailProjectMixin:
self.nav_recents_hdr.setVisible(not self._nav_collapsed)
self.nav_recents.setVisible(not self._nav_collapsed)
def _on_rail_recent(self, item, _col: int = 0) -> None:
"""Bấm một dòng trong mục GẦN ĐÂY: mở hội thoại đó, hoặc mở khung "Tất cả project…"."""
data = item.data(0, Qt.UserRole) or {}
if data.get("all"):
self._goto(self._ROW_WORKSPACE, self.workspace._cowork_tab_idx)
@@ -129,4 +135,5 @@ class RailProjectMixin:
self.workspace.start_new_chat()
self._select_nav_row(self._ROW_WORKSPACE, self.workspace.current_subtab())
def _nav_new_chat_enabled(self) -> bool:
"""Nút "chat mới" chỉ bật khi đã có ít nhất một project."""
return bool(self.workspace.project_choices())
+5
View File
@@ -19,6 +19,9 @@ from ...ui.workspace_tab import WorkspaceTab
class SessionEventsMixin:
"""Nối các sự kiện phiên chat vào vỏ cửa sổ: làm mới cột lịch sử, chấm trạng thái
và nhãn thư mục khi project đổi tên.
"""
def _running_session_ids(self):
"""All conversation ids currently running — interactive Cowork/Code
chat tab AgentWorkers, plus Schedule Task runs (their own session,
@@ -33,6 +36,7 @@ class SessionEventsMixin:
from PySide6.QtCore import QTimer
def _do() -> None:
"""Đồng bộ cột lịch sử với hội thoại đang mở và các hội thoại đang chạy."""
current = self.cowork.session_id
self.sidebar.set_view_state(current, self._running_session_ids())
self.sidebar.refresh()
@@ -99,6 +103,7 @@ class SessionEventsMixin:
except Exception:
pass
def _on_projects_changed(self) -> None:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
+2
View File
@@ -16,6 +16,7 @@ class Toast(QLabel):
"""A small auto-hiding notification shown at the window's top-left."""
def __init__(self, parent):
"""Thông báo ngắn nổi lên rồi tự tắt; dựng ở trạng thái ẩn."""
super().__init__(parent)
self.setObjectName("toast")
self.setWordWrap(True)
@@ -26,6 +27,7 @@ class Toast(QLabel):
self._timer.timeout.connect(self.hide)
def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None:
"""Hiện một thông báo (xanh khi thành công, đỏ khi lỗi) rồi tự ẩn sau ``ms`` mili giây."""
p = current_palette()
bg = p.success_soft if ok else p.danger_soft
fg = p.success if ok else p.danger
+10
View File
@@ -67,6 +67,7 @@ class TopBarMixin:
self._account_row = self._build_account_row()
def _build_topbar(self) -> QWidget:
"""Dựng thanh trên: thương hiệu, bộ chọn provider/ngôn ngữ/theme và hàng tài khoản."""
bar = QWidget()
bar.setObjectName("topbar")
# Styled centrally (see theme._TEMPLATE): flat, with a single hairline
@@ -173,6 +174,7 @@ class TopBarMixin:
btn.setIcon(_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
return btn
def _set_theme(self, value: str) -> None:
"""Đổi theme, ghi cấu hình và áp stylesheet mới lên toàn ứng dụng."""
from ...ui.icons import icon as _icon
self.ctx.config.theme = value
@@ -180,6 +182,9 @@ class TopBarMixin:
self._apply_theme()
self.theme_btn.setIcon(_icon(self._THEME_ICONS.get(value, "monitor")))
def _on_provider_changed(self, _idx: int) -> None:
"""Đổi provider đang dùng: lưu cấu hình rồi làm mới mọi bề mặt phụ thuộc nó
(tiêu đề Cowork, bộ chọn model của AI-Edit).
"""
self.ctx.config.active_provider = self.provider_combo.currentData()
self.ctx.save()
self.cowork.refresh_header()
@@ -191,6 +196,9 @@ class TopBarMixin:
label=PROVIDER_LABELS.get(self.ctx.config.active_provider))
)
def _on_language_changed(self, _idx: int) -> None:
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
toàn bộ chữ vô ích.
"""
lang = self.language_combo.currentData()
if not lang or lang == get_language():
return
@@ -198,6 +206,7 @@ class TopBarMixin:
self.ctx.save()
set_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
dlg = SettingsDialog(self.ctx, self)
if dlg.exec():
self._apply_theme()
@@ -224,6 +233,7 @@ class TopBarMixin:
self.sidebar.refresh()
self.statusBar().showMessage(tr("app.status.settings_saved"))
def _apply_theme(self) -> None:
"""Áp theme đang cấu hình lên toàn bộ ứng dụng."""
app = QApplication.instance()
if app:
set_active_theme(self.ctx.config.theme)
+5
View File
@@ -19,6 +19,9 @@ class TrayManager:
"""Khay hệ thống của một cửa sổ. An toàn khi máy không có khay."""
def __init__(self, window, *, icon, tooltip: str, tr):
"""Dựng biểu tượng khay nếu hệ điều hành có; không có thì mọi thao tác về sau
thành no-op.
"""
self.window = window
self._tr = tr
self.icon: QSystemTrayIcon | None = None
@@ -51,6 +54,7 @@ class TrayManager:
self.icon.show()
def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho tooltip và menu khay."""
if self.icon is not None:
self.icon.setToolTip(self._tooltip)
if self._open_act is not None:
@@ -58,6 +62,7 @@ class TrayManager:
self._quit_act.setText(self._tr("app.tray.quit"))
def hide(self) -> None:
"""Ẩn biểu tượng khay; máy không có khay thì không làm gì."""
if self.icon is not None:
self.icon.hide()