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>
381 lines
16 KiB
Python
381 lines
16 KiB
Python
"""Manage custom agent skills: add, edit, delete, enable/disable."""
|
|
from __future__ import annotations
|
|
|
|
from typing import Optional
|
|
|
|
from PySide6.QtCore import Qt
|
|
from PySide6.QtWidgets import (
|
|
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
|
|
QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
|
|
QPushButton, QVBoxLayout,
|
|
)
|
|
|
|
from ..core.skills import (
|
|
Skill, delete_skill, export_skill_md, generate_skill,
|
|
generate_skill_from_template, generate_skill_instructions, list_skills,
|
|
save_skill,
|
|
)
|
|
from ..core.worker import AgentWorker
|
|
from ..i18n import tr
|
|
from .icons import icon
|
|
|
|
|
|
class SkillEditDialog(QDialog):
|
|
"""Hộp thoại thêm/sửa một Skill: tên, mô tả và phần chỉ dẫn."""
|
|
def __init__(self, parent=None, skill: Optional[Skill] = None, ctx=None):
|
|
"""Form thêm/sửa một skill.
|
|
|
|
Skill mới luôn bắt đầu ở trạng thái TẮT: vừa tạo xong đã tự tham gia vào mọi
|
|
lượt chat là điều người dùng không lường trước.
|
|
"""
|
|
super().__init__(parent)
|
|
self.setWindowTitle(tr("skills.edit_title") if skill else tr("skills.add_title"))
|
|
self.setMinimumWidth(520)
|
|
self._enabled = skill.enabled if skill else False # new skills start disabled
|
|
self._ctx = ctx # for the AI "generate from description" button
|
|
self._gen_worker = None
|
|
|
|
lay = QVBoxLayout(self)
|
|
lay.addWidget(QLabel(tr("skills.name_label")))
|
|
self.name = QLineEdit(skill.name if skill else "")
|
|
self.name.setPlaceholderText(tr("skills.name_placeholder"))
|
|
lay.addWidget(self.name)
|
|
|
|
lay.addWidget(QLabel(tr("skills.desc_label")))
|
|
self.desc = QLineEdit(skill.description if skill else "")
|
|
lay.addWidget(self.desc)
|
|
|
|
instr_hdr = QHBoxLayout()
|
|
instr_hdr.addWidget(QLabel(tr("skills.instructions_label")), 1)
|
|
self._gen_btn = QPushButton(tr("skills.gen_from_desc"))
|
|
self._gen_btn.setIcon(icon("sparkle"))
|
|
self._gen_btn.setToolTip(tr("skills.gen_from_desc_tooltip"))
|
|
self._gen_btn.clicked.connect(self._gen_instructions)
|
|
instr_hdr.addWidget(self._gen_btn)
|
|
lay.addLayout(instr_hdr)
|
|
self.instr = QPlainTextEdit(skill.instructions if skill else "")
|
|
self.instr.setPlaceholderText(tr("skills.instructions_placeholder"))
|
|
self.instr.setMinimumHeight(180)
|
|
lay.addWidget(self.instr)
|
|
|
|
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
|
buttons.accepted.connect(self._on_accept)
|
|
buttons.rejected.connect(self.reject)
|
|
lay.addWidget(buttons)
|
|
|
|
# ---- AI: draft the instructions from the short description -------
|
|
def _gen_instructions(self) -> None:
|
|
"""Nhờ AI soạn phần chỉ dẫn từ tên và mô tả người dùng vừa gõ."""
|
|
desc = self.desc.text().strip()
|
|
name = self.name.text().strip()
|
|
if not desc and not name:
|
|
self.desc.setFocus()
|
|
return
|
|
if self._ctx is None:
|
|
return
|
|
self._gen_btn.setEnabled(False)
|
|
self._gen_btn.setText(tr("skills.generating"))
|
|
ctx = self._ctx
|
|
|
|
def job(worker: AgentWorker):
|
|
"""Chạy nền: gọi model sinh nội dung chỉ dẫn cho skill."""
|
|
return {"text": generate_skill_instructions(
|
|
ctx.build_active_provider(), desc, name, worker.is_cancelled)}
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(self._on_gen)
|
|
w.failed.connect(lambda _e: self._reset_gen_btn())
|
|
self._gen_worker = w
|
|
w.start()
|
|
|
|
def _on_gen(self, result) -> None:
|
|
"""Đổ chỉ dẫn vừa sinh vào ô soạn thảo."""
|
|
text = (result or {}).get("text", "")
|
|
if text:
|
|
self.instr.setPlainText(text)
|
|
self._reset_gen_btn()
|
|
|
|
def _reset_gen_btn(self) -> None:
|
|
"""Trả nút "Sinh từ mô tả" về trạng thái bấm được."""
|
|
self._gen_btn.setEnabled(True)
|
|
self._gen_btn.setText(tr("skills.gen_from_desc"))
|
|
|
|
def _on_accept(self) -> None:
|
|
"""Kiểm tra bắt buộc có tên trước khi đóng hộp thoại."""
|
|
if not self.name.text().strip():
|
|
self.name.setFocus()
|
|
return
|
|
self.accept()
|
|
|
|
def result_skill(self) -> Skill:
|
|
"""Bản ghi Skill dựng từ nội dung đang có trên form."""
|
|
return Skill(
|
|
name=self.name.text().strip(),
|
|
description=self.desc.text().strip(),
|
|
instructions=self.instr.toPlainText().strip(),
|
|
enabled=self._enabled,
|
|
)
|
|
|
|
|
|
class SkillsDialog(QDialog):
|
|
"""Trình quản lý Skill: liệt kê, bật/tắt, thêm/sửa/xoá, nhân bản, nhập/xuất,
|
|
và hai lối tạo nhanh bằng AI (từ mô tả, hoặc từ một tệp mẫu).
|
|
"""
|
|
def __init__(self, parent=None, ctx=None):
|
|
"""Hộp thoại quản lý skill: danh sách kèm nút nhập/xuất và hai lối nhờ model tự
|
|
sinh skill.
|
|
"""
|
|
super().__init__(parent)
|
|
self._ctx = ctx # passed to SkillEditDialog for AI-assisted generation
|
|
self._auto_worker = None
|
|
self._template_worker = None
|
|
self.setWindowTitle(tr("skills.title"))
|
|
self.setMinimumSize(560, 420)
|
|
|
|
lay = QVBoxLayout(self)
|
|
hint = QLabel(tr("skills.hint"))
|
|
hint.setObjectName("hint")
|
|
lay.addWidget(hint)
|
|
|
|
self.list = QListWidget()
|
|
self.list.itemChanged.connect(self._on_check)
|
|
lay.addWidget(self.list, 1)
|
|
|
|
row = QHBoxLayout()
|
|
self._auto_btn = QPushButton(tr("skills.auto_generate"))
|
|
self._auto_btn.setIcon(icon("sparkle"))
|
|
self._auto_btn.setObjectName("primary")
|
|
self._auto_btn.setToolTip(tr("skills.auto_generate_tooltip"))
|
|
self._auto_btn.clicked.connect(self._auto_generate)
|
|
self._template_btn = QPushButton(tr("skills.from_template"))
|
|
self._template_btn.setIcon(icon("document"))
|
|
self._template_btn.setToolTip(tr("skills.from_template_tooltip"))
|
|
self._template_btn.clicked.connect(self._from_template)
|
|
import_btn = QPushButton(tr("skills.import_btn"))
|
|
import_btn.setIcon(icon("download"))
|
|
import_btn.setToolTip(tr("skills.import_tooltip"))
|
|
import_btn.clicked.connect(self._import)
|
|
export_btn = QPushButton(tr("skills.export_btn"))
|
|
export_btn.setIcon(icon("upload"))
|
|
export_btn.setToolTip(tr("skills.export_tooltip"))
|
|
export_btn.clicked.connect(self._export_md)
|
|
dup_btn = QPushButton(tr("skills.duplicate_btn"))
|
|
dup_btn.setIcon(icon("document"))
|
|
dup_btn.setToolTip(tr("skills.duplicate_tooltip"))
|
|
dup_btn.clicked.connect(self._duplicate)
|
|
edit_btn = QPushButton(tr("skills.edit_btn"))
|
|
edit_btn.setIcon(icon("edit"))
|
|
edit_btn.clicked.connect(self._edit)
|
|
del_btn = QPushButton(tr("skills.delete_btn"))
|
|
del_btn.setIcon(icon("trash"))
|
|
del_btn.clicked.connect(self._delete)
|
|
close_btn = QPushButton(tr("skills.close_btn"))
|
|
close_btn.setIcon(icon("close"))
|
|
close_btn.clicked.connect(self.accept)
|
|
row.addWidget(self._auto_btn)
|
|
row.addWidget(self._template_btn)
|
|
row.addWidget(import_btn)
|
|
row.addWidget(export_btn)
|
|
row.addWidget(dup_btn)
|
|
row.addWidget(edit_btn)
|
|
row.addWidget(del_btn)
|
|
row.addStretch(1)
|
|
row.addWidget(close_btn)
|
|
lay.addLayout(row)
|
|
|
|
self._reload()
|
|
|
|
# ---- AI: auto-generate a whole skill from a one-line description ----
|
|
def _auto_generate(self) -> None:
|
|
"""Nhờ AI dựng một skill hoàn chỉnh từ vài dòng mô tả người dùng gõ."""
|
|
if self._ctx is None:
|
|
QMessageBox.information(self, tr("skills.auto_generate_title"),
|
|
tr("skills.auto_generate_unavailable"))
|
|
return
|
|
prompt, ok = QInputDialog.getMultiLineText(
|
|
self, tr("skills.auto_generate_title"),
|
|
tr("skills.auto_generate_prompt"), "")
|
|
if not ok or not prompt.strip():
|
|
return
|
|
self._auto_btn.setEnabled(False)
|
|
self._auto_btn.setText(tr("skills.generating"))
|
|
ctx = self._ctx
|
|
text = prompt.strip()
|
|
|
|
def job(worker: AgentWorker):
|
|
"""Chạy nền: gọi model sinh skill; lỗi thì trả về kèm thông điệp để hiện ra."""
|
|
try:
|
|
skill = generate_skill(ctx.build_active_provider(), text, worker.is_cancelled)
|
|
return {"skill": skill}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"error": str(exc)}
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(self._on_auto_generated)
|
|
w.failed.connect(lambda _e: self._on_auto_generated({"error": "failed"}))
|
|
self._auto_worker = w
|
|
w.start()
|
|
|
|
def _on_auto_generated(self, result) -> None:
|
|
"""Skill vừa sinh xong: mở hộp thoại sửa để người dùng xem lại trước khi lưu."""
|
|
self._auto_btn.setEnabled(True)
|
|
self._auto_btn.setText(tr("skills.auto_generate"))
|
|
skill = (result or {}).get("skill")
|
|
if not isinstance(skill, Skill):
|
|
QMessageBox.information(
|
|
self, tr("skills.auto_generate_title"), tr("skills.auto_generate_failed"))
|
|
return
|
|
# Open the editor pre-filled so the user can review/tweak before saving.
|
|
dlg = SkillEditDialog(self, skill, ctx=self._ctx)
|
|
if dlg.exec():
|
|
save_skill(dlg.result_skill())
|
|
self._reload()
|
|
|
|
# ---- AI: analyze a pptx/xlsx TEMPLATE file's structure into a skill ----
|
|
def _from_template(self) -> None:
|
|
"""Nhờ AI dựng skill từ một tệp mẫu người dùng chọn (quy trình, biểu mẫu…)."""
|
|
if self._ctx is None:
|
|
QMessageBox.information(self, tr("skills.from_template_title"),
|
|
tr("skills.auto_generate_unavailable"))
|
|
return
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, tr("skills.from_template_dialog_title"), "",
|
|
tr("skills.from_template_dialog_filter"))
|
|
if not path:
|
|
return
|
|
self._template_btn.setEnabled(False)
|
|
self._template_btn.setText(tr("skills.generating"))
|
|
ctx = self._ctx
|
|
|
|
def job(worker: AgentWorker):
|
|
"""Chạy nền: đọc tệp mẫu và gọi model sinh skill tương ứng."""
|
|
try:
|
|
skill = generate_skill_from_template(
|
|
ctx.build_active_provider(), path, worker.is_cancelled)
|
|
return {"skill": skill}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"error": str(exc)}
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(self._on_template_generated)
|
|
w.failed.connect(lambda _e: self._on_template_generated({"error": "failed"}))
|
|
self._template_worker = w
|
|
w.start()
|
|
|
|
def _on_template_generated(self, result) -> None:
|
|
"""Skill từ mẫu đã sinh xong: mở hộp thoại sửa để xem lại trước khi lưu."""
|
|
self._template_btn.setEnabled(True)
|
|
self._template_btn.setText(tr("skills.from_template"))
|
|
skill = (result or {}).get("skill")
|
|
if not isinstance(skill, Skill):
|
|
QMessageBox.information(
|
|
self, tr("skills.from_template_title"), tr("skills.from_template_failed"))
|
|
return
|
|
dlg = SkillEditDialog(self, skill, ctx=self._ctx)
|
|
if dlg.exec():
|
|
save_skill(dlg.result_skill())
|
|
self._reload()
|
|
|
|
def _reload(self) -> None:
|
|
"""Nạp lại danh sách skill.
|
|
|
|
Chặn tín hiệu trong lúc nạp: đặt lại ô đánh dấu sẽ phát ``itemChanged`` và bị
|
|
hiểu nhầm là người dùng vừa bật/tắt skill.
|
|
"""
|
|
self.list.blockSignals(True)
|
|
self.list.clear()
|
|
for s in list_skills():
|
|
text = s.name + (f" — {s.description}" if s.description else "")
|
|
item = QListWidgetItem(text)
|
|
item.setFlags(item.flags() | Qt.ItemIsUserCheckable)
|
|
item.setCheckState(Qt.Checked if s.enabled else Qt.Unchecked)
|
|
item.setData(Qt.UserRole, s)
|
|
self.list.addItem(item)
|
|
if self.list.count() == 0:
|
|
placeholder = QListWidgetItem(tr("skills.no_skills"))
|
|
placeholder.setFlags(Qt.NoItemFlags)
|
|
self.list.addItem(placeholder)
|
|
self.list.blockSignals(False)
|
|
|
|
def _on_check(self, item: QListWidgetItem) -> None:
|
|
"""Bật/tắt một skill và lưu ngay."""
|
|
skill = item.data(Qt.UserRole)
|
|
if not isinstance(skill, Skill):
|
|
return
|
|
skill.enabled = item.checkState() == Qt.Checked
|
|
save_skill(skill)
|
|
|
|
def _current_skill(self) -> Optional[Skill]:
|
|
"""Skill đang chọn trong danh sách; ``None`` nếu chưa chọn gì."""
|
|
item = self.list.currentItem()
|
|
if item is None:
|
|
return None
|
|
skill = item.data(Qt.UserRole)
|
|
return skill if isinstance(skill, Skill) else None
|
|
|
|
def _import(self) -> None:
|
|
"""Nhập skill từ tệp ``.md`` hoặc gói ``.zip``."""
|
|
from ..core.skills import import_skill_file
|
|
|
|
path, _ = QFileDialog.getOpenFileName(
|
|
self, tr("skills.import_dialog_title"), "", tr("skills.import_dialog_filter"))
|
|
if not path:
|
|
return
|
|
try:
|
|
import_skill_file(path)
|
|
self._reload()
|
|
except Exception as exc: # noqa: BLE001
|
|
QMessageBox.warning(self, tr("skills.import_dialog_title"), tr("skills.import_failed", err=exc))
|
|
|
|
def _export_md(self) -> None:
|
|
"""Xuất skill đang chọn ra tệp Markdown."""
|
|
skill = self._current_skill()
|
|
if skill is None:
|
|
QMessageBox.information(self, tr("skills.export_btn"), tr("skills.export_pick"))
|
|
return
|
|
default = f"{Skill(name=skill.name).slug}.md"
|
|
path, _ = QFileDialog.getSaveFileName(
|
|
self, tr("skills.export_dialog_title"), default, tr("skills.export_dialog_filter"))
|
|
if not path:
|
|
return
|
|
try:
|
|
out = export_skill_md(skill, path)
|
|
QMessageBox.information(self, tr("skills.export_btn"),
|
|
tr("skills.export_done", path=str(out)))
|
|
except Exception as exc: # noqa: BLE001
|
|
QMessageBox.warning(self, tr("skills.export_btn"), tr("skills.export_failed", err=exc))
|
|
|
|
def _duplicate(self) -> None:
|
|
"""Nhân bản skill đang chọn thành một skill mới."""
|
|
skill = self._current_skill()
|
|
if skill is None:
|
|
QMessageBox.information(self, tr("skills.duplicate_btn"), tr("skills.export_pick"))
|
|
return
|
|
copy = Skill(name=tr("skills.copy_name", name=skill.name),
|
|
description=skill.description, instructions=skill.instructions, enabled=False)
|
|
save_skill(copy)
|
|
self._reload()
|
|
# Open the copy for immediate editing/renaming.
|
|
dlg = SkillEditDialog(self, copy, ctx=self._ctx)
|
|
if dlg.exec():
|
|
save_skill(dlg.result_skill(), old_name=copy.name)
|
|
self._reload()
|
|
|
|
def _edit(self) -> None:
|
|
"""Mở hộp thoại sửa skill đang chọn."""
|
|
skill = self._current_skill()
|
|
if skill is None:
|
|
return
|
|
dlg = SkillEditDialog(self, skill, ctx=self._ctx)
|
|
if dlg.exec():
|
|
save_skill(dlg.result_skill(), old_name=skill.name)
|
|
self._reload()
|
|
|
|
def _delete(self) -> None:
|
|
"""Xoá skill đang chọn sau khi hỏi xác nhận."""
|
|
skill = self._current_skill()
|
|
if skill is None:
|
|
return
|
|
delete_skill(skill.name)
|
|
self._reload()
|