Soát lại từng dòng plan thì thấy tôi báo R08 xong hơi sớm. Ba chỗ thiếu thật:
T06 ChatPanel vẫn ở ui/, plan đòi presentation/chat/chat_panel.py
T08 agents_admin_tab.py (498) và tools_admin_tab.py (245) vẫn ở ui/
presentation/chat/chat_panel.py 346
presentation/monitoring/tabs/agents_admin_tab.py 383
presentation/monitoring/tabs/agent_edit_dialog.py 143
presentation/monitoring/tabs/tools_admin_tab.py 245
ui/chat_panel.py / agents_admin_tab.py / tools_admin_tab.py ~10 mỗi cái
agents_admin_tab.py 498 dòng nên tách thêm agent_edit_dialog.py: bảng danh
sách và hộp thoại sửa là hai việc, và hộp thoại còn tự đi hỏi provider xem có
model nào — thứ bảng không cần biết.
BA CHỖ CÒN LẠI KHÔNG PHẢI THIẾU, đã kiểm từng cái:
* audio_recorder_widget.py (T04) — repo KHÔNG có chức năng ghi âm nào.
* connector_settings_widget.py (T07) — UI Connector đã dời khỏi Cài đặt.
* sandbox_status_tab.py / mcp_history_tab.py (T08) — Hiệp đặt tên sandbox_tab
và mcp_tab, nội dung đủ.
R08: 14/14 task, 0 file thiếu thật sự.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
209 lines
9.1 KiB
Python
209 lines
9.1 KiB
Python
"""Hộp thoại tạo task bằng AI, và nhập task từ file — R08-T11.
|
|
|
|
Hai tab trong một hộp thoại vì cùng trả lời một câu: "làm sao có task mà
|
|
không phải điền tay từng ô".
|
|
|
|
* **Tạo bằng AI** — gõ một câu tiếng Việt, kèm được file và liên kết; AI sinh
|
|
ra cấu hình task và lịch chạy. Người dùng xem trước rồi mới xác nhận.
|
|
* **Nhập từ file** — xem ``ai_task_import_dialog.py``; phần nhập tách ra đó,
|
|
hộp thoại này chỉ đặt nó vào tab thứ hai.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import copy
|
|
from pathlib import Path
|
|
from typing import Dict, List, Optional
|
|
from PySide6.QtCore import Qt, Signal
|
|
from PySide6.QtWidgets import (
|
|
QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout,
|
|
QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox,
|
|
QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget,
|
|
QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
|
|
)
|
|
from ...core import tasks as taskrepo
|
|
from ...core.projects import list_projects
|
|
from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task
|
|
from ...core.worker import AgentWorker
|
|
from ...i18n import on_language_changed, tr
|
|
from ...state import AppContext
|
|
from ...theme import current_palette
|
|
from ...ui.calendar_view import CalendarView
|
|
from ...ui.icons import icon
|
|
from ...ui.osutil import open_path
|
|
from .ai_task_import_dialog import TaskImportMixin
|
|
from .kanban_board_widget import _DropZone
|
|
|
|
|
|
class _AiCreateDialog(TaskImportMixin, QDialog):
|
|
"""Create tasks two ways, one tab each (both preview first — nothing is
|
|
saved until the user confirms): ✨ AI gen from a natural-language
|
|
description, or 📥 Import from a filled Excel template (pick or drag)."""
|
|
|
|
def __init__(self, ctx: AppContext, parent=None):
|
|
super().__init__(parent)
|
|
from PySide6.QtWidgets import QTabWidget
|
|
|
|
self.ctx = ctx
|
|
self.created_tasks: List[dict] = []
|
|
self._planned: List[dict] = []
|
|
self._worker: Optional[AgentWorker] = None
|
|
self.setWindowTitle(tr("schedtask.ai_btn"))
|
|
self.resize(600, 520)
|
|
|
|
root = QVBoxLayout(self)
|
|
ws_row = QHBoxLayout()
|
|
ws_row.addWidget(QLabel(tr("schedtask.f_workspace")))
|
|
self.workspace_combo = QComboBox()
|
|
self.workspace_combo.addItem(tr("schedtask.no_workspace"), "")
|
|
for p in list_projects():
|
|
self.workspace_combo.addItem(p.name, p.project_id)
|
|
self.workspace_combo.setToolTip(tr("schedtask.hint_workspace"))
|
|
ws_row.addWidget(self.workspace_combo, 1)
|
|
root.addLayout(ws_row)
|
|
self.tabs = QTabWidget()
|
|
root.addWidget(self.tabs, 1)
|
|
|
|
# ---- tab 1: AI gen ------------------------------------------------
|
|
ai_page = QWidget()
|
|
al = QVBoxLayout(ai_page)
|
|
al.addWidget(QLabel(tr("schedtask.ai_desc_label")))
|
|
self.desc_edit = QPlainTextEdit()
|
|
self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph"))
|
|
self.desc_edit.setMaximumHeight(110)
|
|
al.addWidget(self.desc_edit)
|
|
# Attachments (files + links) — merged into every task this generates,
|
|
# AND into the planning prompt so the AI knows they exist.
|
|
attach_row = QHBoxLayout()
|
|
self.ai_files_edit = QLineEdit()
|
|
self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder"))
|
|
ai_pick_btn = QPushButton(tr("schedtask.pick_files"))
|
|
ai_pick_btn.setIcon(icon("folder"))
|
|
ai_pick_btn.clicked.connect(self._ai_pick_files)
|
|
attach_row.addWidget(self.ai_files_edit, 1)
|
|
attach_row.addWidget(ai_pick_btn)
|
|
al.addWidget(QLabel(tr("schedtask.f_files")))
|
|
al.addLayout(attach_row)
|
|
self.ai_links_edit = QLineEdit()
|
|
self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder"))
|
|
al.addWidget(QLabel(tr("schedtask.f_links")))
|
|
al.addWidget(self.ai_links_edit)
|
|
self.gen_btn = QPushButton(tr("schedtask.ai_generate"))
|
|
self.gen_btn.setIcon(icon("sparkle"))
|
|
self.gen_btn.setObjectName("primary")
|
|
self.gen_btn.clicked.connect(self._generate)
|
|
al.addWidget(self.gen_btn)
|
|
al.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
|
self.preview = QPlainTextEdit()
|
|
self.preview.setReadOnly(True)
|
|
al.addWidget(self.preview, 1)
|
|
self.tabs.addTab(ai_page, tr("schedtask.tab_ai"))
|
|
|
|
# ---- tab 2: Import from Excel --------------------------------------
|
|
imp_page = QWidget()
|
|
il = QVBoxLayout(imp_page)
|
|
tpl_btn = QPushButton(tr("schedtask.export_template_btn"))
|
|
tpl_btn.setIcon(icon("upload"))
|
|
tpl_btn.clicked.connect(self._export_template)
|
|
il.addWidget(tpl_btn)
|
|
pick_row = QHBoxLayout()
|
|
pick_btn = QPushButton(tr("schedtask.import_pick_btn"))
|
|
pick_btn.setIcon(icon("folder"))
|
|
pick_btn.clicked.connect(self._pick_import_file)
|
|
pick_row.addWidget(pick_btn)
|
|
pick_row.addStretch(1)
|
|
il.addLayout(pick_row)
|
|
self.drop_zone = _DropZone()
|
|
self.drop_zone.setText(tr("schedtask.drop_hint"))
|
|
self.drop_zone.file_dropped.connect(self._load_import_file)
|
|
il.addWidget(self.drop_zone)
|
|
il.addWidget(QLabel(tr("schedtask.ai_preview_label")))
|
|
self.import_preview = QPlainTextEdit()
|
|
self.import_preview.setReadOnly(True)
|
|
il.addWidget(self.import_preview, 1)
|
|
self.tabs.addTab(imp_page, tr("schedtask.tab_import"))
|
|
|
|
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
|
|
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
|
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
|
|
self.buttons.accepted.connect(self._confirm)
|
|
self.buttons.rejected.connect(self.reject)
|
|
root.addWidget(self.buttons)
|
|
|
|
# ---- Import tab ------------------------------------------------------
|
|
|
|
|
|
|
|
def _ai_pick_files(self) -> None:
|
|
from PySide6.QtWidgets import QFileDialog
|
|
|
|
files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files"))
|
|
if files:
|
|
existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()]
|
|
self.ai_files_edit.setText("; ".join(existing + files))
|
|
|
|
def _attached_files(self) -> List[str]:
|
|
return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()]
|
|
|
|
def _attached_links(self) -> List[str]:
|
|
return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()]
|
|
|
|
def _generate(self) -> None:
|
|
description = self.desc_edit.toPlainText().strip()
|
|
if not description or self._worker is not None:
|
|
return
|
|
files, links = self._attached_files(), self._attached_links()
|
|
self.gen_btn.setEnabled(False)
|
|
self.gen_btn.setText(tr("schedtask.ai_generating"))
|
|
|
|
def job(worker: AgentWorker):
|
|
from ...core.ai_task_planner import plan_tasks
|
|
|
|
provider = self.ctx.build_active_provider()
|
|
full_desc = description
|
|
if files or links:
|
|
attach_note = "; ".join(files + links)
|
|
full_desc += f"\n\n(Attached references available: {attach_note})"
|
|
planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled)
|
|
# Attachments apply to every generated task so they're available
|
|
# at RUN time too, not just visible to the planner.
|
|
for t in planned:
|
|
t["input"]["file_paths"] = list(files)
|
|
t["input"]["links"] = list(links)
|
|
return {"tasks": planned}
|
|
|
|
w = AgentWorker(job)
|
|
w.finished_ok.connect(self._on_planned)
|
|
w.failed.connect(self._on_failed)
|
|
self._worker = w
|
|
w.start()
|
|
|
|
def _on_planned(self, result: dict) -> None:
|
|
self._worker = None
|
|
self.gen_btn.setEnabled(True)
|
|
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
|
self._planned = result.get("tasks") or []
|
|
lines = []
|
|
for i, t in enumerate(self._planned, 1):
|
|
sched = t.get("schedule", {})
|
|
when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule")
|
|
dep = t.get("dependency", {})
|
|
chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else ""
|
|
lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n"
|
|
f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n"
|
|
f" {t.get('description', '')[:150]}")
|
|
self.preview.setPlainText("\n\n".join(lines))
|
|
self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned))
|
|
|
|
def _on_failed(self, err: str) -> None:
|
|
self._worker = None
|
|
self.gen_btn.setEnabled(True)
|
|
self.gen_btn.setText(tr("schedtask.ai_generate"))
|
|
self.preview.setPlainText(str(err))
|
|
|
|
def _confirm(self) -> None:
|
|
project_id = self.workspace_combo.currentData() or ""
|
|
for t in self._planned:
|
|
t["project_id"] = project_id
|
|
self.created_tasks = self._planned
|
|
self.accept()
|