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:
co-authored by
Claude Opus 5
parent
4fef41481b
commit
982fecc8dc
@@ -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()
|
||||
Reference in New Issue
Block a user