CI / test (push) Canceled after 0s
## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.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()
|