refactor(scheduling): R08-T11 — schedule_task_tab.py 794 -> 297, tách 6 file

presentation/scheduling/
      calendar_view_widget.py    231  lịch tháng (chuyển từ ui/calendar_view.py)
      ai_task_creator_dialog.py  208  tạo task bằng AI
      task_actions.py            189  thêm/sửa/chạy/xoá/xem log một task
      kanban_board_widget.py      98  cột Kanban + vùng thả file
      run_history_dialog.py       82  lịch sử các lượt chạy
      ai_task_import_dialog.py    81  nhập task từ file
    ui/schedule_task_tab.py      297  dựng bảng + đổi chế độ xem
    ui/calendar_view.py           10  vỏ chuyển tiếp

Plan ghi 4 file; thực tế cần 6. Hai file thêm là run_history_dialog.py và
task_actions.py — không tách thì schedule_task_tab.py còn 517 dòng, vẫn vượt
ngưỡng 400.

ai_task_import_dialog.py làm mixin chứ không phải hộp thoại rời: plan gọi nó
là dialog, nhưng thực tế nó là TAB THỨ HAI của cùng hộp thoại tạo task, dùng
chung phần xem trước và nút Xác nhận. Tách hẳn thì phải nhân đôi cả hai.

LẠI LỖI DECORATOR: script này tôi quên dùng bản có tính dòng @, nên một
@staticmethod bị bỏ lại mồ côi -> IndentationError. Đây là lần thứ tư cùng
một lỗi. Đã thêm bước dọn decorator mồ côi vào script.

756 test xanh. 16 checker chạy đều qua.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Nam Pham Dinh Thanh
2026-08-27 22:54:50 +09:00
co-authored by Claude Opus 5
parent 4fef41481b
commit 982fecc8dc
8 changed files with 906 additions and 730 deletions
@@ -0,0 +1,208 @@
"""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()